From 2ec10f9c32edaaa9f401a8677a07743ff479ccfb Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 28 Jul 2026 07:19:34 +0300 Subject: [PATCH 1/3] feat(providers): add local runtime presets --- src/harness/providers/openai/test.rs | 13 +++++ src/harness/providers/openai/transport.rs | 70 ++++++++++++++++++++++- 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/src/harness/providers/openai/test.rs b/src/harness/providers/openai/test.rs index f53a11e..c5145b0 100644 --- a/src/harness/providers/openai/test.rs +++ b/src/harness/providers/openai/test.rs @@ -602,6 +602,19 @@ fn compatible_presets_set_base_url_and_default_model() { assert_eq!(named.model(), "custom-model"); } +#[test] +fn local_runtime_presets_normalize_endpoint_and_model() { + let ollama = OpenAiModel::ollama_at("127.0.0.1:11434/", "qwen3:8b"); + 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"); + 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"); +} + #[test] fn provider_spec_builds_compatible_model() { let spec = ProviderSpec::for_kind(ProviderKind::Ollama).with_model("qwen2.5"); diff --git a/src/harness/providers/openai/transport.rs b/src/harness/providers/openai/transport.rs index ad6a33a..412134c 100644 --- a/src/harness/providers/openai/transport.rs +++ b/src/harness/providers/openai/transport.rs @@ -755,9 +755,55 @@ impl OpenAiModel { } /// A local Ollama server (`http://localhost:11434/v1`), default model - /// `llama3.2`. Ollama ignores the API key, so a placeholder is used. + /// `llama3.2`. pub fn ollama() -> Self { - Self::compatible_provider("ollama", "ollama", "http://localhost:11434/v1", "llama3.2") + Self::ollama_at("http://localhost:11434", "llama3.2") + } + + /// An Ollama server exposed through its OpenAI-compatible HTTP API. + pub fn ollama_at(base_url: impl Into, model: impl Into) -> Self { + Self::local_runtime( + "ollama", + normalize_local_v1_base_url(base_url.into(), "http://localhost:11434"), + "", + model, + ) + } + + /// An LM Studio server exposed through its OpenAI-compatible HTTP API. + /// + /// Authentication is disabled when `api_key` is empty and uses a bearer + /// token otherwise, matching LM Studio's optional API-token mode. + pub fn lm_studio( + base_url: impl Into, + api_key: impl Into, + model: impl Into, + ) -> Self { + let api_key = api_key.into(); + let auth = if api_key.trim().is_empty() { + AuthStyle::None + } else { + AuthStyle::Bearer + }; + Self::local_runtime( + "lm_studio", + normalize_local_v1_base_url(base_url.into(), "http://localhost:1234"), + api_key, + model, + ) + .with_auth_style(auth) + } + + fn local_runtime( + provider: &str, + base_url: String, + api_key: impl Into, + model: impl Into, + ) -> Self { + Self::compatible_provider(provider, api_key, base_url, model) + .with_auth_style(AuthStyle::None) + .with_native_tool_calling(false) + .with_vision(false) } /// Returns the default model id this instance will request. @@ -1187,6 +1233,26 @@ impl OpenAiModel { } } +fn normalize_local_v1_base_url(raw: String, default_root: &str) -> String { + let trimmed = raw.trim().trim_end_matches('/'); + let root = if trimmed.is_empty() { + default_root.to_owned() + } else if trimmed.contains("://") { + trimmed.to_owned() + } else { + format!("http://{trimmed}") + }; + let root = root + .trim_end_matches("/chat/completions") + .trim_end_matches("/models") + .trim_end_matches('/'); + if root.ends_with("/v1") { + root.to_owned() + } else { + format!("{root}/v1") + } +} + /// Request-shape degradations to apply when building an OpenAI wire body. /// /// Each field, when `true`, replaces a request shape that some local From 57823e4047e6cc8fcc78a83c71f516c07e1f89b5 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 28 Jul 2026 08:24:18 +0300 Subject: [PATCH 2/3] fix(providers): preserve local runtime invariants --- src/harness/providers/openai/test.rs | 14 ++++++ src/harness/providers/openai/transport.rs | 52 +++++++++++++++++++---- 2 files changed, 57 insertions(+), 9 deletions(-) diff --git a/src/harness/providers/openai/test.rs b/src/harness/providers/openai/test.rs index c5145b0..289305e 100644 --- a/src/harness/providers/openai/test.rs +++ b/src/harness/providers/openai/test.rs @@ -613,6 +613,20 @@ fn local_runtime_presets_normalize_endpoint_and_model() { 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(), + "http://models/v1" + ); + assert_eq!( + OpenAiModel::ollama_at("http://v1", "qwen3").base_url(), + "http://v1/v1" + ); + + let overridden = OpenAiModel::ollama().with_model("qwen3:8b"); + let profile = >::profile(&overridden).unwrap(); + assert!(!profile.tool_calling); + assert!(!profile.modalities.image_in); } #[test] diff --git a/src/harness/providers/openai/transport.rs b/src/harness/providers/openai/transport.rs index 412134c..7be0474 100644 --- a/src/harness/providers/openai/transport.rs +++ b/src/harness/providers/openai/transport.rs @@ -75,6 +75,9 @@ pub struct OpenAiModel { /// Capability profile derived from the default model id, optionally adjusted /// by [`Self::with_native_tool_calling`] / [`Self::with_vision`]. profile: ModelProfile, + /// Preserve conservative local-runtime tool/vision capability overrides + /// when callers subsequently replace the default model id. + local_capabilities_locked: bool, /// Provider-specific options baked onto every request (e.g. a local model's /// `{"options": {"num_ctx": 8192}}`). Merged under each request's own /// `provider_options`, which win on key conflicts. `Value::Null` by default @@ -319,6 +322,7 @@ impl OpenAiModel { provider: "openai".to_string(), base_url: DEFAULT_BASE_URL.to_string(), profile: derive_profile("openai", DEFAULT_MODEL), + local_capabilities_locked: false, default_provider_options: Value::Null, responses_api_primary: false, responses_omit_max_output_tokens: false, @@ -520,14 +524,28 @@ impl OpenAiModel { /// Overrides the default model id. pub fn with_model(mut self, model: impl Into) -> Self { self.model = model.into(); + let local_capabilities = self + .local_capabilities_locked + .then(|| (self.profile.tool_calling, self.profile.modalities.image_in)); self.profile = derive_profile(&self.provider, &self.model); + if let Some((tool_calling, image_in)) = local_capabilities { + self.profile.tool_calling = tool_calling; + self.profile.modalities.image_in = image_in; + } self } /// Overrides the provider family id used in profiles and normalized errors. pub fn with_provider(mut self, provider: impl Into) -> Self { self.provider = provider.into(); + let local_capabilities = self + .local_capabilities_locked + .then(|| (self.profile.tool_calling, self.profile.modalities.image_in)); self.profile = derive_profile(&self.provider, &self.model); + if let Some((tool_calling, image_in)) = local_capabilities { + self.profile.tool_calling = tool_calling; + self.profile.modalities.image_in = image_in; + } self } @@ -804,6 +822,12 @@ impl OpenAiModel { .with_auth_style(AuthStyle::None) .with_native_tool_calling(false) .with_vision(false) + .lock_local_capabilities() + } + + fn lock_local_capabilities(mut self) -> Self { + self.local_capabilities_locked = true; + self } /// Returns the default model id this instance will request. @@ -1242,15 +1266,25 @@ fn normalize_local_v1_base_url(raw: String, default_root: &str) -> String { } else { format!("http://{trimmed}") }; - let root = root - .trim_end_matches("/chat/completions") - .trim_end_matches("/models") - .trim_end_matches('/'); - if root.ends_with("/v1") { - root.to_owned() - } else { - format!("{root}/v1") - } + let mut url = + reqwest::Url::parse(&root).expect("local runtime URL is normalized with a scheme"); + let mut segments: Vec<&str> = url + .path() + .split('/') + .filter(|part| !part.is_empty()) + .collect(); + if segments.ends_with(&["chat", "completions"]) { + segments.truncate(segments.len() - 2); + } else if segments.last() == Some(&"models") { + segments.pop(); + } + if segments.last() != Some(&"v1") { + segments.push("v1"); + } + url.set_path(&format!("/{}", segments.join("/"))); + url.set_query(None); + url.set_fragment(None); + url.to_string().trim_end_matches('/').to_owned() } /// Request-shape degradations to apply when building an OpenAI wire body. From 1f12b631d9981ec2ef1779f488713cc3ef65b4e8 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Tue, 28 Jul 2026 08:29:27 +0300 Subject: [PATCH 3/3] fix(providers): satisfy strict clippy --- src/harness/providers/openai/transport.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/harness/providers/openai/transport.rs b/src/harness/providers/openai/transport.rs index 7be0474..bd2daf5 100644 --- a/src/harness/providers/openai/transport.rs +++ b/src/harness/providers/openai/transport.rs @@ -526,7 +526,7 @@ impl OpenAiModel { self.model = model.into(); let local_capabilities = self .local_capabilities_locked - .then(|| (self.profile.tool_calling, self.profile.modalities.image_in)); + .then_some((self.profile.tool_calling, self.profile.modalities.image_in)); self.profile = derive_profile(&self.provider, &self.model); if let Some((tool_calling, image_in)) = local_capabilities { self.profile.tool_calling = tool_calling; @@ -540,7 +540,7 @@ impl OpenAiModel { self.provider = provider.into(); let local_capabilities = self .local_capabilities_locked - .then(|| (self.profile.tool_calling, self.profile.modalities.image_in)); + .then_some((self.profile.tool_calling, self.profile.modalities.image_in)); self.profile = derive_profile(&self.provider, &self.model); if let Some((tool_calling, image_in)) = local_capabilities { self.profile.tool_calling = tool_calling;