Skip to content
50 changes: 44 additions & 6 deletions src/harness/embeddings/ollama.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};

use super::EmbeddingModel;
use crate::error::{Result, TinyAgentsError};
Expand All @@ -17,7 +21,7 @@ pub struct OllamaEmbeddingModel {
client: reqwest::Client,
base_url: String,
model: String,
dimensions: usize,
dimensions: Arc<AtomicUsize>,
options: Option<OllamaOptions>,
}

Expand All @@ -27,11 +31,22 @@ impl OllamaEmbeddingModel {
client: reqwest::Client::new(),
base_url: normalize_base_url(base_url)?,
model: normalize_model(model)?,
dimensions: if dimensions == 0 {
dimensions: Arc::new(AtomicUsize::new(if dimensions == 0 {
DEFAULT_OLLAMA_DIMENSIONS
} else {
dimensions
},
})),
options: None,
})
}

/// Builds a model that learns its vector width from the first response.
pub fn try_new_dynamic(base_url: &str, model: &str) -> Result<Self> {
Ok(Self {
client: reqwest::Client::new(),
base_url: normalize_base_url(base_url)?,
model: normalize_model(model)?,
dimensions: Arc::new(AtomicUsize::new(0)),
Comment thread
senamakel marked this conversation as resolved.
options: None,
})
}
Expand Down Expand Up @@ -135,10 +150,24 @@ impl OllamaEmbeddingModel {
}

fn validate_dimensions(&self, index: usize, vector: &[f32]) -> Result<()> {
if vector.len() != self.dimensions {
if vector.is_empty() {
return Err(TinyAgentsError::Embedding(format!(
"ollama embed returned an empty vector at index {index}"
)));
}
let expected = match self.dimensions.compare_exchange(
0,
vector.len(),
Ordering::AcqRel,
Ordering::Acquire,
) {
Ok(_) => vector.len(),
Err(expected) => expected,
};
if vector.len() != expected {
return Err(TinyAgentsError::Embedding(format!(
"ollama embed dimension mismatch at index {index}: expected {}, got {}",
self.dimensions,
expected,
vector.len()
)));
}
Expand Down Expand Up @@ -284,7 +313,7 @@ impl EmbeddingModel for OllamaEmbeddingModel {
}

fn dimensions(&self) -> usize {
self.dimensions
self.dimensions.load(Ordering::Acquire)
}

async fn embed(&self, texts: &[String]) -> Result<Vec<Vec<f32>>> {
Expand Down Expand Up @@ -376,6 +405,15 @@ mod tests {
assert!(OllamaEmbeddingModel::try_new("http://host:11434", "local-v1", 1).is_err());
}

#[test]
fn dynamic_dimensions_are_learned_and_enforced() {
let model = OllamaEmbeddingModel::try_new_dynamic("http://host:11434", "custom").unwrap();
assert_eq!(model.dimensions(), 0);
model.validate_dimensions(0, &[0.0; 7]).unwrap();
assert_eq!(model.dimensions(), 7);
assert!(model.validate_dimensions(1, &[0.0; 8]).is_err());
}

#[tokio::test]
async fn blank_inputs_preserve_positions_without_network() {
let model = OllamaEmbeddingModel::default();
Expand Down
14 changes: 11 additions & 3 deletions src/harness/providers/openai/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,20 @@ of the rest of the harness.
- `OpenAiModel::from_spec(spec, api_key)` / `from_spec_env(spec)` — build from a
`providers::ProviderSpec` (base URL, default model, provider id already
resolved).
- **Compatibility presets** — thin wrappers over `new` + `with_base_url` +
`with_model` for endpoints that speak the same Chat Completions wire format:
- **Compatibility presets** for hosted endpoints that speak the same Chat
Completions wire format:
`compatible(base_url, model)` / `compatible_provider(..)` (arbitrary
endpoint), `deepseek`, `anthropic` (compat endpoint, not the native
Anthropic API), `groq`, `xai`, `openrouter`, `together`, `mistral`, `ollama`.
Anthropic API), `groq`, `xai`, `openrouter`, `together`, and `mistral`.
Override the preset's default model with `.with_model(..)`.
- **Local-runtime presets** — `ollama()`, fallible
`ollama_at(base_url, model)`, and fallible
`lm_studio(base_url, api_key, model)`. These normalize a server or
`/v1/models` URL to its `/v1` API base and use conservative local defaults:
no native or parallel tool calls, no streamed tool chunks, and no image
input. Ollama sends no authorization header; LM Studio sends a bearer token
only when its API key is non-empty. `ProviderSpec::Ollama` uses the same
defaults.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

Accessors: `.model()`, `.provider()`, `.base_url()`.

Expand Down
30 changes: 26 additions & 4 deletions src/harness/providers/openai/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -639,29 +639,38 @@ fn compatible_presets_set_base_url_and_default_model() {

#[test]
fn local_runtime_presets_normalize_endpoint_and_model() {
let ollama = OpenAiModel::ollama_at("127.0.0.1:11434/", "qwen3:8b");
let ollama = OpenAiModel::ollama_at("127.0.0.1:11434/", "qwen3:8b").unwrap();
assert_eq!(ollama.provider(), "ollama");
assert_eq!(ollama.base_url(), "http://127.0.0.1:11434/v1");
assert_eq!(ollama.model(), "qwen3:8b");

let lm_studio = OpenAiModel::lm_studio("http://127.0.0.1:1234/v1/models", "", "local-model");
let lm_studio =
OpenAiModel::lm_studio("http://127.0.0.1:1234/v1/models", "", "local-model").unwrap();
assert_eq!(lm_studio.provider(), "lm_studio");
assert_eq!(lm_studio.base_url(), "http://127.0.0.1:1234/v1");
assert_eq!(lm_studio.model(), "local-model");

assert_eq!(
OpenAiModel::ollama_at("http://models", "qwen3").base_url(),
OpenAiModel::ollama_at("http://models", "qwen3")
.unwrap()
.base_url(),
"http://models/v1"
);
assert_eq!(
OpenAiModel::ollama_at("http://v1", "qwen3").base_url(),
OpenAiModel::ollama_at("http://v1", "qwen3")
.unwrap()
.base_url(),
"http://v1/v1"
);

let overridden = OpenAiModel::ollama().with_model("qwen3:8b");
let profile = <OpenAiModel as ChatModel<()>>::profile(&overridden).unwrap();
assert!(!profile.tool_calling);
assert!(!profile.parallel_tool_calls);
assert!(!profile.streaming_tool_chunks);
assert!(!profile.modalities.image_in);

assert!(OpenAiModel::ollama_at("http://[::1", "qwen3").is_err());
}

#[test]
Expand All @@ -679,6 +688,19 @@ fn provider_spec_builds_compatible_model() {
.as_deref(),
Some("ollama")
);
let profile = <OpenAiModel as ChatModel<()>>::profile(&model).unwrap();
assert!(!profile.tool_calling);
assert!(!profile.parallel_tool_calls);
assert!(!profile.streaming_tool_chunks);
assert!(!profile.modalities.image_in);

let mut authenticated = ProviderSpec::for_kind(ProviderKind::Ollama);
authenticated.requires_api_key = true;
let authenticated = OpenAiModel::from_spec(authenticated, "proxy-secret").unwrap();
assert_eq!(
authenticated.auth_config(),
("proxy-secret", &AuthStyle::Bearer)
);
}

#[test]
Expand Down
76 changes: 56 additions & 20 deletions src/harness/providers/openai/transport.rs
Original file line number Diff line number Diff line change
Expand Up @@ -545,12 +545,19 @@ impl OpenAiModel {
/// Overrides the default model id.
pub fn with_model(mut self, model: impl Into<String>) -> Self {
self.model = model.into();
let local_capabilities = self
.local_capabilities_locked
.then_some((self.profile.tool_calling, self.profile.modalities.image_in));
let local_capabilities = self.local_capabilities_locked.then_some((
self.profile.tool_calling,
self.profile.parallel_tool_calls,
self.profile.streaming_tool_chunks,
self.profile.modalities.image_in,
));
self.profile = derive_profile(&self.provider, &self.model);
if let Some((tool_calling, image_in)) = local_capabilities {
if let Some((tool_calling, parallel_tool_calls, streaming_tool_chunks, image_in)) =
local_capabilities
{
self.profile.tool_calling = tool_calling;
self.profile.parallel_tool_calls = parallel_tool_calls;
self.profile.streaming_tool_chunks = streaming_tool_chunks;
self.profile.modalities.image_in = image_in;
}
self
Expand All @@ -559,12 +566,19 @@ impl OpenAiModel {
/// Overrides the provider family id used in profiles and normalized errors.
pub fn with_provider(mut self, provider: impl Into<String>) -> Self {
self.provider = provider.into();
let local_capabilities = self
.local_capabilities_locked
.then_some((self.profile.tool_calling, self.profile.modalities.image_in));
let local_capabilities = self.local_capabilities_locked.then_some((
self.profile.tool_calling,
self.profile.parallel_tool_calls,
self.profile.streaming_tool_chunks,
self.profile.modalities.image_in,
));
self.profile = derive_profile(&self.provider, &self.model);
if let Some((tool_calling, image_in)) = local_capabilities {
if let Some((tool_calling, parallel_tool_calls, streaming_tool_chunks, image_in)) =
local_capabilities
{
self.profile.tool_calling = tool_calling;
self.profile.parallel_tool_calls = parallel_tool_calls;
self.profile.streaming_tool_chunks = streaming_tool_chunks;
self.profile.modalities.image_in = image_in;
}
self
Expand Down Expand Up @@ -614,6 +628,7 @@ impl OpenAiModel {
/// Builds an OpenAI-compatible model from a provider spec and explicit API
/// key.
pub fn from_spec(spec: ProviderSpec, api_key: impl Into<String>) -> Result<Self> {
let api_key = api_key.into();
if spec.model.trim().is_empty() {
return Err(TinyAgentsError::Validation(
"provider spec model must not be empty".to_string(),
Expand All @@ -624,6 +639,20 @@ impl OpenAiModel {
"provider spec base_url must not be empty".to_string(),
));
}
if spec.kind == crate::harness::providers::ProviderKind::Ollama {
let auth = if spec.requires_api_key {
AuthStyle::Bearer
} else {
AuthStyle::None
};
return Ok(Self::local_runtime(
&spec.provider,
normalize_local_v1_base_url(spec.base_url, "http://localhost:11434")?,
api_key,
spec.model,
)
.with_auth_style(auth));
}
Ok(Self::compatible_provider(
spec.provider,
api_key,
Expand Down Expand Up @@ -797,16 +826,17 @@ impl OpenAiModel {
/// `llama3.2`.
pub fn ollama() -> Self {
Self::ollama_at("http://localhost:11434", "llama3.2")
.expect("the built-in Ollama URL is valid")
}

/// An Ollama server exposed through its OpenAI-compatible HTTP API.
pub fn ollama_at(base_url: impl Into<String>, model: impl Into<String>) -> Self {
Self::local_runtime(
pub fn ollama_at(base_url: impl Into<String>, model: impl Into<String>) -> Result<Self> {
Ok(Self::local_runtime(
"ollama",
normalize_local_v1_base_url(base_url.into(), "http://localhost:11434"),
normalize_local_v1_base_url(base_url.into(), "http://localhost:11434")?,
"",
model,
)
))
}

/// An LM Studio server exposed through its OpenAI-compatible HTTP API.
Expand All @@ -817,20 +847,20 @@ impl OpenAiModel {
base_url: impl Into<String>,
api_key: impl Into<String>,
model: impl Into<String>,
) -> Self {
) -> Result<Self> {
let api_key = api_key.into();
let auth = if api_key.trim().is_empty() {
AuthStyle::None
} else {
AuthStyle::Bearer
};
Self::local_runtime(
Ok(Self::local_runtime(
"lm_studio",
normalize_local_v1_base_url(base_url.into(), "http://localhost:1234"),
normalize_local_v1_base_url(base_url.into(), "http://localhost:1234")?,
api_key,
model,
)
.with_auth_style(auth)
.with_auth_style(auth))
}

fn local_runtime(
Expand All @@ -851,6 +881,11 @@ impl OpenAiModel {
self
}

#[cfg(test)]
pub(super) fn auth_config(&self) -> (&str, &AuthStyle) {
(&self.api_key, &self.auth)
}

/// Returns the default model id this instance will request.
pub fn model(&self) -> &str {
&self.model
Expand Down Expand Up @@ -1278,7 +1313,7 @@ impl OpenAiModel {
}
}

fn normalize_local_v1_base_url(raw: String, default_root: &str) -> String {
fn normalize_local_v1_base_url(raw: String, default_root: &str) -> Result<String> {
let trimmed = raw.trim().trim_end_matches('/');
let root = if trimmed.is_empty() {
default_root.to_owned()
Expand All @@ -1287,8 +1322,9 @@ fn normalize_local_v1_base_url(raw: String, default_root: &str) -> String {
} else {
format!("http://{trimmed}")
};
let mut url =
reqwest::Url::parse(&root).expect("local runtime URL is normalized with a scheme");
let mut url = reqwest::Url::parse(&root).map_err(|error| {
TinyAgentsError::Validation(format!("invalid local runtime URL `{root}`: {error}"))
})?;
Comment thread
senamakel marked this conversation as resolved.
let mut segments: Vec<&str> = url
.path()
.split('/')
Expand All @@ -1305,7 +1341,7 @@ fn normalize_local_v1_base_url(raw: String, default_root: &str) -> String {
url.set_path(&format!("/{}", segments.join("/")));
url.set_query(None);
url.set_fragment(None);
url.to_string().trim_end_matches('/').to_owned()
Ok(url.to_string().trim_end_matches('/').to_owned())
}

/// Request-shape degradations to apply when building an OpenAI wire body.
Expand Down
Loading