From ba339825cae6b01b4f3810946b9bedad2ac5cb0b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 May 2026 17:42:43 +0000 Subject: [PATCH 01/17] fix: allow bare model names for Ollama and strip openai/ prefix on wire Fixes two Ollama API compatibility issues (#3123): 1. validate_model_syntax now allows bare model names (e.g. "qwen2.5-coder:7b") when OPENAI_BASE_URL is set, since local providers like Ollama don't use the provider/model naming convention. 2. wire_model_for_base_url always strips the "openai/" routing prefix before sending the model name to the API. This prefix is a claw-code internal routing hint and should never be sent on the wire to any endpoint. --- .../crates/api/src/providers/openai_compat.rs | 57 +++++++++++++------ .../api/tests/openai_compat_integration.rs | 2 +- rust/crates/rusty-claude-cli/src/main.rs | 25 +++++++- 3 files changed, 66 insertions(+), 18 deletions(-) diff --git a/rust/crates/api/src/providers/openai_compat.rs b/rust/crates/api/src/providers/openai_compat.rs index d5291b8eae..5cefafccbe 100644 --- a/rust/crates/api/src/providers/openai_compat.rs +++ b/rust/crates/api/src/providers/openai_compat.rs @@ -934,8 +934,8 @@ fn strip_routing_prefix(model: &str) -> &str { fn wire_model_for_base_url<'a>( model: &'a str, - config: OpenAiCompatConfig, - base_url: &str, + _config: OpenAiCompatConfig, + _base_url: &str, ) -> Cow<'a, str> { let Some(pos) = model.find('/') else { return Cow::Borrowed(model); @@ -944,20 +944,11 @@ fn wire_model_for_base_url<'a>( let lowered_prefix = prefix.to_ascii_lowercase(); if lowered_prefix == "openai" { - let trimmed_base_url = base_url.trim_end_matches('/'); - let default_openai = DEFAULT_OPENAI_BASE_URL.trim_end_matches('/'); - if matches!( - lowered_prefix.as_str(), - "xai" | "grok" | "kimi" | "gemini" | "gemma" - ) { - return Cow::Borrowed(&model[pos + 1..]); - } - if config.provider_name == "OpenAI" && trimmed_base_url != default_openai { - // Only preserve the full slug if it's NOT a model we want to strip - if !model.contains("gemini") && !model.contains("gemma") { - return Cow::Borrowed(model); - } - } + // The `openai/` prefix is a claw-code routing hint to select the + // OpenAI-compatible provider. It should always be stripped before + // sending the model name on the wire — both for the default OpenAI + // endpoint and for custom endpoints (Ollama, LM Studio, vLLM, etc.) + // where the upstream API only knows the bare model name. return Cow::Borrowed(&model[pos + 1..]); } @@ -2730,4 +2721,38 @@ mod tests { assert_eq!(super::strip_routing_prefix("kimi-k2.5"), "kimi-k2.5"); // no prefix, unchanged assert_eq!(super::strip_routing_prefix("kimi/kimi-k1.5"), "kimi-k1.5"); } + + #[test] + fn wire_model_strips_openai_prefix_for_custom_base_url() { + // Issue #3123: Ollama models with openai/ prefix should have prefix + // stripped when sent on the wire to any endpoint (including custom ones). + use std::borrow::Cow; + let ollama_url = "http://localhost:11434/v1"; + let config = super::OpenAiCompatConfig::openai(); + + // openai/ prefix stripped for custom base URL (Ollama) + assert_eq!( + super::wire_model_for_base_url("openai/qwen2.5-coder:7b", config, ollama_url), + Cow::Borrowed("qwen2.5-coder:7b") + ); + + // openai/ prefix stripped for default OpenAI URL too + assert_eq!( + super::wire_model_for_base_url("openai/gpt-4o", config, super::DEFAULT_OPENAI_BASE_URL), + Cow::Borrowed("gpt-4o") + ); + + // Bare model names (no slash) pass through unchanged + assert_eq!( + super::wire_model_for_base_url("qwen2.5-coder:7b", config, ollama_url), + Cow::Borrowed("qwen2.5-coder:7b") + ); + + // xai/ prefix stripped + let xai_config = super::OpenAiCompatConfig::xai(); + assert_eq!( + super::wire_model_for_base_url("xai/grok-3", xai_config, super::DEFAULT_XAI_BASE_URL), + Cow::Borrowed("grok-3") + ); + } } diff --git a/rust/crates/api/tests/openai_compat_integration.rs b/rust/crates/api/tests/openai_compat_integration.rs index d87446756e..cd4ef664ba 100644 --- a/rust/crates/api/tests/openai_compat_integration.rs +++ b/rust/crates/api/tests/openai_compat_integration.rs @@ -206,7 +206,7 @@ async fn custom_openai_gateway_preserves_slash_model_ids_and_extra_body_params() let captured = state.lock().await; let request = captured.first().expect("captured request"); let body: serde_json::Value = serde_json::from_str(&request.body).expect("json body"); - assert_eq!(body["model"], json!("openai/gpt-4.1-mini")); + assert_eq!(body["model"], json!("gpt-4.1-mini")); assert_eq!( body["web_search_options"], json!({"search_context_size": "low"}) diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 7c5ce197ee..78be44b11c 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -1819,7 +1819,10 @@ fn resolve_model_alias_with_config(model: &str) -> String { } /// Validate model syntax at parse time. -/// Accepts: known aliases (opus, sonnet, haiku) or provider/model pattern. +/// Accepts: known aliases (opus, sonnet, haiku), provider/model pattern, +/// or bare model names when `OPENAI_BASE_URL` is set (for local providers +/// like Ollama, LM Studio, vLLM where model names don't follow provider/model +/// format — e.g. "qwen2.5-coder:7b", "llama3:8b"). /// Rejects: empty, whitespace-only, strings with spaces, or invalid chars. fn validate_model_syntax(model: &str) -> Result<(), String> { let trimmed = model.trim(); @@ -1836,6 +1839,14 @@ fn validate_model_syntax(model: &str) -> Result<(), String> { // Check provider/model format: provider_id/model_id let parts: Vec<&str> = trimmed.split('/').collect(); if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() { + // When OPENAI_BASE_URL is set, the user has configured a local + // OpenAI-compatible endpoint (Ollama, LM Studio, vLLM, etc.). + // These providers use bare model names (e.g. "qwen2.5-coder:7b", + // "llama3:8b") that don't follow the provider/model convention. + // Allow them through without requiring a prefix. + if std::env::var_os("OPENAI_BASE_URL").is_some() { + return Ok(()); + } // #154: hint if the model looks like it belongs to a different provider let mut err_msg = format!( "invalid model syntax: '{}'.\nExpected provider/model (e.g., anthropic/claude-opus-4-7)", @@ -16175,4 +16186,16 @@ mod alias_resolution_tests { assert_eq!(resolve_model_alias_with_config(model), model); assert!(validate_model_syntax(model).is_ok()); } + + #[test] + fn test_bare_model_name_passes_when_openai_base_url_set() { + // Issue #3123: Ollama-style bare model names (no provider/ prefix) + // should pass validation when OPENAI_BASE_URL is set, indicating + // a local OpenAI-compatible endpoint is configured. + std::env::set_var("OPENAI_BASE_URL", "http://localhost:11434/v1"); + assert!(validate_model_syntax("qwen2.5-coder:7b").is_ok()); + assert!(validate_model_syntax("llama3:8b").is_ok()); + assert!(validate_model_syntax("mistral").is_ok()); + std::env::remove_var("OPENAI_BASE_URL"); + } } From 92890b94cf6591b5db8cd7b4633381a63a01fa4a Mon Sep 17 00:00:00 2001 From: Georgije Savic <135692650+GeorgijeSav@users.noreply.github.com> Date: Tue, 26 May 2026 19:51:41 +0200 Subject: [PATCH 02/17] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- rust/crates/rusty-claude-cli/src/main.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 78be44b11c..94e0a30b0a 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -1843,8 +1843,9 @@ fn validate_model_syntax(model: &str) -> Result<(), String> { // OpenAI-compatible endpoint (Ollama, LM Studio, vLLM, etc.). // These providers use bare model names (e.g. "qwen2.5-coder:7b", // "llama3:8b") that don't follow the provider/model convention. - // Allow them through without requiring a prefix. - if std::env::var_os("OPENAI_BASE_URL").is_some() { + // Allow them through without requiring a prefix, but only for + // bare model names with no slash at all. + if parts.len() == 1 && std::env::var_os("OPENAI_BASE_URL").is_some() { return Ok(()); } // #154: hint if the model looks like it belongs to a different provider From bd12cebea92110b42daa9368db487985b3bf252c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 May 2026 17:55:17 +0000 Subject: [PATCH 03/17] fix: rename misleading test and add non-routing slash model ID coverage --- .../api/tests/openai_compat_integration.rs | 41 ++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/rust/crates/api/tests/openai_compat_integration.rs b/rust/crates/api/tests/openai_compat_integration.rs index cd4ef664ba..d5a03305a6 100644 --- a/rust/crates/api/tests/openai_compat_integration.rs +++ b/rust/crates/api/tests/openai_compat_integration.rs @@ -162,7 +162,7 @@ async fn send_message_preserves_deepseek_reasoning_content_before_text() { } #[tokio::test] -async fn custom_openai_gateway_preserves_slash_model_ids_and_extra_body_params() { +async fn custom_openai_gateway_strips_openai_prefix_and_preserves_extra_body_params() { let state = Arc::new(Mutex::new(Vec::::new())); let body = concat!( "{", @@ -214,6 +214,45 @@ async fn custom_openai_gateway_preserves_slash_model_ids_and_extra_body_params() assert_eq!(body["parallel_tool_calls"], json!(false)); } +#[tokio::test] +async fn custom_openai_gateway_preserves_non_routing_slash_model_ids() { + let state = Arc::new(Mutex::new(Vec::::new())); + let body = concat!( + "{", + "\"id\":\"chatcmpl_non_routing_slug\",", + "\"model\":\"my-org/my-fine-tuned-model\",", + "\"choices\":[{", + "\"message\":{\"role\":\"assistant\",\"content\":\"Custom model reply\",\"tool_calls\":[]},", + "\"finish_reason\":\"stop\"", + "}],", + "\"usage\":{\"prompt_tokens\":4,\"completion_tokens\":3}", + "}" + ); + let server = spawn_server( + state.clone(), + vec![http_response("200 OK", "application/json", body)], + ) + .await; + + let client = OpenAiCompatClient::new("openai-test-key", OpenAiCompatConfig::openai()) + .with_base_url(server.base_url()); + let response = client + .send_message(&MessageRequest { + model: "my-org/my-fine-tuned-model".to_string(), + ..sample_request(false) + }) + .await + .expect("gateway request should succeed"); + + assert_eq!(response.model, "my-org/my-fine-tuned-model"); + assert_eq!(response.total_tokens(), 7); + + let captured = state.lock().await; + let request = captured.first().expect("captured request"); + let body: serde_json::Value = serde_json::from_str(&request.body).expect("json body"); + assert_eq!(body["model"], json!("my-org/my-fine-tuned-model")); +} + #[tokio::test] async fn send_message_blocks_oversized_xai_requests_before_the_http_call() { let state = Arc::new(Mutex::new(Vec::::new())); From dbaee08e206fe449ece1f03b4f498477f4249ff0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 May 2026 17:55:57 +0000 Subject: [PATCH 04/17] fix: correct test fixture ID from slug to slash for consistency --- rust/crates/api/tests/openai_compat_integration.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/crates/api/tests/openai_compat_integration.rs b/rust/crates/api/tests/openai_compat_integration.rs index d5a03305a6..e503391ad5 100644 --- a/rust/crates/api/tests/openai_compat_integration.rs +++ b/rust/crates/api/tests/openai_compat_integration.rs @@ -219,7 +219,7 @@ async fn custom_openai_gateway_preserves_non_routing_slash_model_ids() { let state = Arc::new(Mutex::new(Vec::::new())); let body = concat!( "{", - "\"id\":\"chatcmpl_non_routing_slug\",", + "\"id\":\"chatcmpl_non_routing_slash\",", "\"model\":\"my-org/my-fine-tuned-model\",", "\"choices\":[{", "\"message\":{\"role\":\"assistant\",\"content\":\"Custom model reply\",\"tool_calls\":[]},", From 7357156c3d5867977f3208c1e77c26f51827d450 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 May 2026 17:55:46 +0000 Subject: [PATCH 05/17] fix: preserve openai/ slug for custom non-local gateways (e.g. OpenRouter) --- .../crates/api/src/providers/openai_compat.rs | 41 ++++++++++++++----- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/rust/crates/api/src/providers/openai_compat.rs b/rust/crates/api/src/providers/openai_compat.rs index 5cefafccbe..4236c89387 100644 --- a/rust/crates/api/src/providers/openai_compat.rs +++ b/rust/crates/api/src/providers/openai_compat.rs @@ -934,8 +934,8 @@ fn strip_routing_prefix(model: &str) -> &str { fn wire_model_for_base_url<'a>( model: &'a str, - _config: OpenAiCompatConfig, - _base_url: &str, + config: OpenAiCompatConfig, + base_url: &str, ) -> Cow<'a, str> { let Some(pos) = model.find('/') else { return Cow::Borrowed(model); @@ -944,12 +944,23 @@ fn wire_model_for_base_url<'a>( let lowered_prefix = prefix.to_ascii_lowercase(); if lowered_prefix == "openai" { - // The `openai/` prefix is a claw-code routing hint to select the - // OpenAI-compatible provider. It should always be stripped before - // sending the model name on the wire — both for the default OpenAI - // endpoint and for custom endpoints (Ollama, LM Studio, vLLM, etc.) - // where the upstream API only knows the bare model name. - return Cow::Borrowed(&model[pos + 1..]); + // The `openai/` prefix is a claw-code routing hint. Whether to strip it + // depends on the target endpoint: + // + // - Default OpenAI endpoint: strip (it is only a routing prefix here). + // - Known-local endpoints (localhost / 127.0.0.1 / [::1], e.g. Ollama, + // LM Studio): strip because local servers use bare model names. + // - Custom non-local endpoints (OpenRouter, other gateways): preserve + // the full slug so the gateway receives the model ID it expects + // (e.g. `openai/gpt-4.1-mini` for OpenRouter). + let is_default_url = base_url == config.default_base_url; + let is_local_url = base_url.contains("localhost") + || base_url.contains("127.0.0.1") + || base_url.contains("[::1]"); + if is_default_url || is_local_url { + return Cow::Borrowed(&model[pos + 1..]); + } + return Cow::Borrowed(model); } if matches!(lowered_prefix.as_str(), "xai" | "grok" | "qwen" | "kimi") { @@ -2725,23 +2736,31 @@ mod tests { #[test] fn wire_model_strips_openai_prefix_for_custom_base_url() { // Issue #3123: Ollama models with openai/ prefix should have prefix - // stripped when sent on the wire to any endpoint (including custom ones). + // stripped for the default OpenAI endpoint and for known-local endpoints, + // but preserved for custom non-local gateways (e.g. OpenRouter). use std::borrow::Cow; let ollama_url = "http://localhost:11434/v1"; + let openrouter_url = "https://openrouter.ai/api/v1"; let config = super::OpenAiCompatConfig::openai(); - // openai/ prefix stripped for custom base URL (Ollama) + // openai/ prefix stripped for known-local URL (Ollama) assert_eq!( super::wire_model_for_base_url("openai/qwen2.5-coder:7b", config, ollama_url), Cow::Borrowed("qwen2.5-coder:7b") ); - // openai/ prefix stripped for default OpenAI URL too + // openai/ prefix stripped for default OpenAI URL assert_eq!( super::wire_model_for_base_url("openai/gpt-4o", config, super::DEFAULT_OPENAI_BASE_URL), Cow::Borrowed("gpt-4o") ); + // openai/ prefix preserved for custom non-local gateway (OpenRouter) + assert_eq!( + super::wire_model_for_base_url("openai/gpt-4.1-mini", config, openrouter_url), + Cow::Borrowed("openai/gpt-4.1-mini") + ); + // Bare model names (no slash) pass through unchanged assert_eq!( super::wire_model_for_base_url("qwen2.5-coder:7b", config, ollama_url), From 0136aca49eae58eeb607571502f454730b509716 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 May 2026 18:00:05 +0000 Subject: [PATCH 06/17] fix: use precise host extraction to avoid false-positive localhost matches --- .../crates/api/src/providers/openai_compat.rs | 56 +++++++++++++++++-- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/rust/crates/api/src/providers/openai_compat.rs b/rust/crates/api/src/providers/openai_compat.rs index 4236c89387..8fab4c0768 100644 --- a/rust/crates/api/src/providers/openai_compat.rs +++ b/rust/crates/api/src/providers/openai_compat.rs @@ -932,6 +932,29 @@ fn strip_routing_prefix(model: &str) -> &str { } } +/// Extract the host (without port) from a URL string. +/// Returns an empty string if the URL cannot be parsed. +fn url_host(url: &str) -> &str { + // Strip scheme ("https://", "http://", etc.) + let rest = match url.split_once("://") { + Some((_, r)) => r, + None => return "", + }; + // Isolate the authority (before the first '/', '?', or '#') + let authority = rest.split(['/', '?', '#']).next().unwrap_or(""); + if authority.starts_with('[') { + // IPv6 literal: host is between '[' and ']' + authority + .split(']') + .next() + .unwrap_or("") + .trim_start_matches('[') + } else { + // IPv4 or hostname: strip optional port + authority.split(':').next().unwrap_or("") + } +} + fn wire_model_for_base_url<'a>( model: &'a str, config: OpenAiCompatConfig, @@ -948,15 +971,13 @@ fn wire_model_for_base_url<'a>( // depends on the target endpoint: // // - Default OpenAI endpoint: strip (it is only a routing prefix here). - // - Known-local endpoints (localhost / 127.0.0.1 / [::1], e.g. Ollama, + // - Known-local endpoints (localhost / 127.0.0.1 / ::1, e.g. Ollama, // LM Studio): strip because local servers use bare model names. // - Custom non-local endpoints (OpenRouter, other gateways): preserve // the full slug so the gateway receives the model ID it expects // (e.g. `openai/gpt-4.1-mini` for OpenRouter). let is_default_url = base_url == config.default_base_url; - let is_local_url = base_url.contains("localhost") - || base_url.contains("127.0.0.1") - || base_url.contains("[::1]"); + let is_local_url = matches!(url_host(base_url), "localhost" | "127.0.0.1" | "::1"); if is_default_url || is_local_url { return Cow::Borrowed(&model[pos + 1..]); } @@ -2749,6 +2770,22 @@ mod tests { Cow::Borrowed("qwen2.5-coder:7b") ); + // openai/ prefix stripped for 127.0.0.1 + assert_eq!( + super::wire_model_for_base_url( + "openai/llama3.2", + config, + "http://127.0.0.1:11434/v1" + ), + Cow::Borrowed("llama3.2") + ); + + // openai/ prefix stripped for IPv6 loopback + assert_eq!( + super::wire_model_for_base_url("openai/llama3.2", config, "http://[::1]:11434/v1"), + Cow::Borrowed("llama3.2") + ); + // openai/ prefix stripped for default OpenAI URL assert_eq!( super::wire_model_for_base_url("openai/gpt-4o", config, super::DEFAULT_OPENAI_BASE_URL), @@ -2761,6 +2798,17 @@ mod tests { Cow::Borrowed("openai/gpt-4.1-mini") ); + // openai/ prefix preserved for a domain that contains "localhost" as a substring + // (false-positive guard: must match the host exactly, not via substring) + assert_eq!( + super::wire_model_for_base_url( + "openai/gpt-4.1-mini", + config, + "https://not-localhost.example.com/v1" + ), + Cow::Borrowed("openai/gpt-4.1-mini") + ); + // Bare model names (no slash) pass through unchanged assert_eq!( super::wire_model_for_base_url("qwen2.5-coder:7b", config, ollama_url), From 69e1b7add277f83ccf07952847fd0fefbfdcfd6e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 May 2026 18:01:57 +0000 Subject: [PATCH 07/17] fix: add env mutex with Drop guard to test_bare_model_name_passes_when_openai_base_url_set --- rust/crates/rusty-claude-cli/src/main.rs | 36 ++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 94e0a30b0a..4d17149b48 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -16139,8 +16139,40 @@ mod dump_manifests_tests { #[cfg(test)] mod alias_resolution_tests { + use std::ffi::OsString; + use std::sync::{Mutex, MutexGuard, OnceLock}; + use super::{resolve_model_alias_with_config, validate_model_syntax}; + fn env_lock() -> MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + struct ScopedEnvVar { + key: &'static str, + previous: Option, + } + + impl ScopedEnvVar { + fn set(key: &'static str, value: &str) -> Self { + let previous = std::env::var_os(key); + std::env::set_var(key, value); + Self { key, previous } + } + } + + impl Drop for ScopedEnvVar { + fn drop(&mut self) { + match &self.previous { + Some(value) => std::env::set_var(self.key, value), + None => std::env::remove_var(self.key), + } + } + } + #[test] fn test_alias_resolution_builtin() { // Built-in aliases should resolve to their full IDs @@ -16193,10 +16225,10 @@ mod alias_resolution_tests { // Issue #3123: Ollama-style bare model names (no provider/ prefix) // should pass validation when OPENAI_BASE_URL is set, indicating // a local OpenAI-compatible endpoint is configured. - std::env::set_var("OPENAI_BASE_URL", "http://localhost:11434/v1"); + let _guard = env_lock(); + let _url = ScopedEnvVar::set("OPENAI_BASE_URL", "http://localhost:11434/v1"); assert!(validate_model_syntax("qwen2.5-coder:7b").is_ok()); assert!(validate_model_syntax("llama3:8b").is_ok()); assert!(validate_model_syntax("mistral").is_ok()); - std::env::remove_var("OPENAI_BASE_URL"); } } From 4c8b63c818157015c8fda212ccd0e1ed9d51870c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 May 2026 18:07:17 +0000 Subject: [PATCH 08/17] fix: resolve rustfmt formatting failure in wire_model unit test --- rust/crates/api/src/providers/openai_compat.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/rust/crates/api/src/providers/openai_compat.rs b/rust/crates/api/src/providers/openai_compat.rs index 8fab4c0768..542febf3b4 100644 --- a/rust/crates/api/src/providers/openai_compat.rs +++ b/rust/crates/api/src/providers/openai_compat.rs @@ -2772,11 +2772,7 @@ mod tests { // openai/ prefix stripped for 127.0.0.1 assert_eq!( - super::wire_model_for_base_url( - "openai/llama3.2", - config, - "http://127.0.0.1:11434/v1" - ), + super::wire_model_for_base_url("openai/llama3.2", config, "http://127.0.0.1:11434/v1"), Cow::Borrowed("llama3.2") ); From 7f4ed25973aba19c2ceb983640032f1b7d147cce Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 May 2026 18:22:16 +0000 Subject: [PATCH 09/17] fix: normalize base URL before comparing to default in wire_model_for_base_url --- .../crates/api/src/providers/openai_compat.rs | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/rust/crates/api/src/providers/openai_compat.rs b/rust/crates/api/src/providers/openai_compat.rs index 542febf3b4..75cfd80881 100644 --- a/rust/crates/api/src/providers/openai_compat.rs +++ b/rust/crates/api/src/providers/openai_compat.rs @@ -932,6 +932,20 @@ fn strip_routing_prefix(model: &str) -> &str { } } +/// Normalize a base URL for comparison purposes. +/// +/// Strips any trailing slashes and a trailing `/chat/completions` path +/// component so that the following variants are all treated as equivalent: +/// - `https://api.openai.com/v1` +/// - `https://api.openai.com/v1/` +/// - `https://api.openai.com/v1/chat/completions` +fn normalize_base_url(url: &str) -> &str { + let url = url.trim_end_matches('/'); + url.strip_suffix("/chat/completions") + .map(|s| s.trim_end_matches('/')) + .unwrap_or(url) +} + /// Extract the host (without port) from a URL string. /// Returns an empty string if the URL cannot be parsed. fn url_host(url: &str) -> &str { @@ -976,7 +990,8 @@ fn wire_model_for_base_url<'a>( // - Custom non-local endpoints (OpenRouter, other gateways): preserve // the full slug so the gateway receives the model ID it expects // (e.g. `openai/gpt-4.1-mini` for OpenRouter). - let is_default_url = base_url == config.default_base_url; + let is_default_url = + normalize_base_url(base_url) == normalize_base_url(config.default_base_url); let is_local_url = matches!(url_host(base_url), "localhost" | "127.0.0.1" | "::1"); if is_default_url || is_local_url { return Cow::Borrowed(&model[pos + 1..]); @@ -2817,5 +2832,21 @@ mod tests { super::wire_model_for_base_url("xai/grok-3", xai_config, super::DEFAULT_XAI_BASE_URL), Cow::Borrowed("grok-3") ); + + // Regression: trailing slash on the default OpenAI URL must still strip openai/ + assert_eq!( + super::wire_model_for_base_url("openai/gpt-4o", config, "https://api.openai.com/v1/"), + Cow::Borrowed("gpt-4o") + ); + + // Regression: full chat/completions path as base URL must still strip openai/ + assert_eq!( + super::wire_model_for_base_url( + "openai/gpt-4o", + config, + "https://api.openai.com/v1/chat/completions" + ), + Cow::Borrowed("gpt-4o") + ); } } From d4d23a7fd50832e4775915a407ed52aa853d7988 Mon Sep 17 00:00:00 2001 From: Georgije Savic <135692650+GeorgijeSav@users.noreply.github.com> Date: Tue, 26 May 2026 20:36:34 +0200 Subject: [PATCH 10/17] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- rust/crates/rusty-claude-cli/src/main.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 4d17149b48..36a35a7db1 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -1823,7 +1823,8 @@ fn resolve_model_alias_with_config(model: &str) -> String { /// or bare model names when `OPENAI_BASE_URL` is set (for local providers /// like Ollama, LM Studio, vLLM where model names don't follow provider/model /// format — e.g. "qwen2.5-coder:7b", "llama3:8b"). -/// Rejects: empty, whitespace-only, strings with spaces, or invalid chars. +/// Rejects: empty or whitespace-only strings, strings containing spaces, +/// and malformed provider/model structure when provider/model syntax is required. fn validate_model_syntax(model: &str) -> Result<(), String> { let trimmed = model.trim(); if trimmed.is_empty() { From e757b4a5d8963453310fb4861d53b12d4e44c42c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 May 2026 18:42:14 +0000 Subject: [PATCH 11/17] Fix case-insensitive base URL host matching for OpenAI prefix stripping --- rust/crates/api/src/providers/openai_compat.rs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/rust/crates/api/src/providers/openai_compat.rs b/rust/crates/api/src/providers/openai_compat.rs index 75cfd80881..99a9308ab1 100644 --- a/rust/crates/api/src/providers/openai_compat.rs +++ b/rust/crates/api/src/providers/openai_compat.rs @@ -991,8 +991,10 @@ fn wire_model_for_base_url<'a>( // the full slug so the gateway receives the model ID it expects // (e.g. `openai/gpt-4.1-mini` for OpenRouter). let is_default_url = - normalize_base_url(base_url) == normalize_base_url(config.default_base_url); - let is_local_url = matches!(url_host(base_url), "localhost" | "127.0.0.1" | "::1"); + normalize_base_url(base_url).eq_ignore_ascii_case(normalize_base_url(config.default_base_url)); + let host = url_host(base_url); + let is_local_url = + host.eq_ignore_ascii_case("localhost") || matches!(host, "127.0.0.1" | "::1"); if is_default_url || is_local_url { return Cow::Borrowed(&model[pos + 1..]); } @@ -2848,5 +2850,17 @@ mod tests { ), Cow::Borrowed("gpt-4o") ); + + // Regression: host matching is case-insensitive for default OpenAI URL + assert_eq!( + super::wire_model_for_base_url("openai/gpt-4o", config, "https://API.OPENAI.COM/v1"), + Cow::Borrowed("gpt-4o") + ); + + // Regression: host matching is case-insensitive for known-local URLs + assert_eq!( + super::wire_model_for_base_url("openai/llama3.2", config, "http://LOCALHOST:11434/v1"), + Cow::Borrowed("llama3.2") + ); } } From 7e5e31b33cdc830f8666b4d95fede82bfd9109ac Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 May 2026 19:11:17 +0000 Subject: [PATCH 12/17] Stabilize alias syntax tests around OPENAI_BASE_URL --- rust/crates/rusty-claude-cli/src/main.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 36a35a7db1..f082da6370 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -16163,6 +16163,12 @@ mod alias_resolution_tests { std::env::set_var(key, value); Self { key, previous } } + + fn remove(key: &'static str) -> Self { + let previous = std::env::var_os(key); + std::env::remove_var(key); + Self { key, previous } + } } impl Drop for ScopedEnvVar { @@ -16193,6 +16199,9 @@ mod alias_resolution_tests { #[test] fn test_alias_resolution_syntax_validation() { + let _guard = env_lock(); + let _url = ScopedEnvVar::remove("OPENAI_BASE_URL"); + // Resolved aliases should pass syntax validation let resolved = resolve_model_alias_with_config("opus"); assert!(validate_model_syntax(&resolved).is_ok()); @@ -16203,6 +16212,9 @@ mod alias_resolution_tests { #[test] fn test_unknown_alias_fails_validation() { + let _guard = env_lock(); + let _url = ScopedEnvVar::remove("OPENAI_BASE_URL"); + // Unknown aliases resolve to themselves let resolved = resolve_model_alias_with_config("unknown-alias"); assert_eq!(resolved, "unknown-alias"); From fb69d022e28f7746831ad3e723b8de81187db740 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 May 2026 19:34:59 +0000 Subject: [PATCH 13/17] fix: handle userinfo in url_host and restrict bare model bypass to local hosts - url_host() now strips userinfo (user:pass@) before extracting the host, so URLs like ******localhost:11434/v1 are correctly identified as local. - validate_model_syntax() bare model bypass now only triggers when OPENAI_BASE_URL points to a loopback host (localhost/127.0.0.1/::1), not for any non-local gateway like OpenRouter. - Added regression tests for both fixes. --- .../crates/api/src/providers/openai_compat.rs | 25 +++++++ rust/crates/rusty-claude-cli/src/main.rs | 70 ++++++++++++++++--- 2 files changed, 84 insertions(+), 11 deletions(-) diff --git a/rust/crates/api/src/providers/openai_compat.rs b/rust/crates/api/src/providers/openai_compat.rs index 99a9308ab1..d83a569493 100644 --- a/rust/crates/api/src/providers/openai_compat.rs +++ b/rust/crates/api/src/providers/openai_compat.rs @@ -956,6 +956,11 @@ fn url_host(url: &str) -> &str { }; // Isolate the authority (before the first '/', '?', or '#') let authority = rest.split(['/', '?', '#']).next().unwrap_or(""); + // Strip optional userinfo (e.g. "user:pass@" in "user:pass@localhost:11434") + let authority = match authority.rsplit_once('@') { + Some((_, host_port)) => host_port, + None => authority, + }; if authority.starts_with('[') { // IPv6 literal: host is between '[' and ']' authority @@ -2862,5 +2867,25 @@ mod tests { super::wire_model_for_base_url("openai/llama3.2", config, "http://LOCALHOST:11434/v1"), Cow::Borrowed("llama3.2") ); + + // Regression: URLs with userinfo should still be recognized as local + assert_eq!( + super::wire_model_for_base_url( + "openai/llama3.2", + config, + "http://user:pass@localhost:11434/v1" + ), + Cow::Borrowed("llama3.2") + ); + + // Regression: URLs with userinfo for non-local gateways should preserve prefix + assert_eq!( + super::wire_model_for_base_url( + "openai/gpt-4.1-mini", + config, + "https://user:pass@openrouter.ai/api/v1" + ), + Cow::Borrowed("openai/gpt-4.1-mini") + ); } } diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index f082da6370..f7cd9be183 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -1818,11 +1818,37 @@ fn resolve_model_alias_with_config(model: &str) -> String { resolve_model_alias(trimmed).to_string() } +/// Returns true if the given base URL points to a loopback/local host +/// (localhost, 127.0.0.1, or ::1). +fn is_local_base_url(url: &str) -> bool { + let rest = match url.split_once("://") { + Some((_, r)) => r, + None => return false, + }; + let authority = rest.split(['/', '?', '#']).next().unwrap_or(""); + // Strip optional userinfo (e.g. "user:pass@") + let authority = match authority.rsplit_once('@') { + Some((_, host_port)) => host_port, + None => authority, + }; + let host = if authority.starts_with('[') { + // IPv6 literal + authority + .split(']') + .next() + .unwrap_or("") + .trim_start_matches('[') + } else { + authority.split(':').next().unwrap_or("") + }; + host.eq_ignore_ascii_case("localhost") || matches!(host, "127.0.0.1" | "::1") +} + /// Validate model syntax at parse time. /// Accepts: known aliases (opus, sonnet, haiku), provider/model pattern, -/// or bare model names when `OPENAI_BASE_URL` is set (for local providers -/// like Ollama, LM Studio, vLLM where model names don't follow provider/model -/// format — e.g. "qwen2.5-coder:7b", "llama3:8b"). +/// or bare model names when `OPENAI_BASE_URL` points to a local endpoint +/// (for providers like Ollama, LM Studio, vLLM where model names don't follow +/// provider/model format — e.g. "qwen2.5-coder:7b", "llama3:8b"). /// Rejects: empty or whitespace-only strings, strings containing spaces, /// and malformed provider/model structure when provider/model syntax is required. fn validate_model_syntax(model: &str) -> Result<(), String> { @@ -1840,14 +1866,18 @@ fn validate_model_syntax(model: &str) -> Result<(), String> { // Check provider/model format: provider_id/model_id let parts: Vec<&str> = trimmed.split('/').collect(); if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() { - // When OPENAI_BASE_URL is set, the user has configured a local - // OpenAI-compatible endpoint (Ollama, LM Studio, vLLM, etc.). - // These providers use bare model names (e.g. "qwen2.5-coder:7b", - // "llama3:8b") that don't follow the provider/model convention. - // Allow them through without requiring a prefix, but only for - // bare model names with no slash at all. - if parts.len() == 1 && std::env::var_os("OPENAI_BASE_URL").is_some() { - return Ok(()); + // When OPENAI_BASE_URL points to a loopback/local endpoint + // (Ollama, LM Studio, vLLM, etc.), allow bare model names + // (e.g. "qwen2.5-coder:7b", "llama3:8b") that don't follow the + // provider/model convention. Only bypass for local hosts to avoid + // masking errors for non-local gateways (e.g. OpenRouter) where + // a slash-containing slug is typically required. + if parts.len() == 1 { + if let Ok(base_url) = std::env::var("OPENAI_BASE_URL") { + if is_local_base_url(&base_url) { + return Ok(()); + } + } } // #154: hint if the model looks like it belongs to a different provider let mut err_msg = format!( @@ -16244,4 +16274,22 @@ mod alias_resolution_tests { assert!(validate_model_syntax("llama3:8b").is_ok()); assert!(validate_model_syntax("mistral").is_ok()); } + + #[test] + fn test_bare_model_name_rejected_for_non_local_openai_base_url() { + // Bare model names should be rejected when OPENAI_BASE_URL points to + // a non-local gateway (e.g. OpenRouter), since those typically require + // a slash-containing slug like `openai/gpt-4.1-mini`. + let _guard = env_lock(); + let _url = ScopedEnvVar::set("OPENAI_BASE_URL", "https://openrouter.ai/api/v1"); + assert!(validate_model_syntax("mistral").is_err()); + assert!(validate_model_syntax("qwen2.5-coder:7b").is_err()); + } + + #[test] + fn test_bare_model_name_passes_for_127_0_0_1() { + let _guard = env_lock(); + let _url = ScopedEnvVar::set("OPENAI_BASE_URL", "http://127.0.0.1:11434/v1"); + assert!(validate_model_syntax("llama3:8b").is_ok()); + } } From 015857cfaff9ac8425816058842831abd1bb509f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 14:30:02 +0000 Subject: [PATCH 14/17] fix: stabilize model syntax tests and satisfy rust fmt workflow --- rust/crates/api/src/providers/openai_compat.rs | 4 ++-- rust/crates/rusty-claude-cli/src/main.rs | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/rust/crates/api/src/providers/openai_compat.rs b/rust/crates/api/src/providers/openai_compat.rs index d83a569493..39fcec233d 100644 --- a/rust/crates/api/src/providers/openai_compat.rs +++ b/rust/crates/api/src/providers/openai_compat.rs @@ -995,8 +995,8 @@ fn wire_model_for_base_url<'a>( // - Custom non-local endpoints (OpenRouter, other gateways): preserve // the full slug so the gateway receives the model ID it expects // (e.g. `openai/gpt-4.1-mini` for OpenRouter). - let is_default_url = - normalize_base_url(base_url).eq_ignore_ascii_case(normalize_base_url(config.default_base_url)); + let is_default_url = normalize_base_url(base_url) + .eq_ignore_ascii_case(normalize_base_url(config.default_base_url)); let host = url_host(base_url); let is_local_url = host.eq_ignore_ascii_case("localhost") || matches!(host, "127.0.0.1" | "::1"); diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index f7cd9be183..3c6299edae 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -16257,6 +16257,9 @@ mod alias_resolution_tests { #[test] fn test_direct_provider_model_passes() { + let _guard = env_lock(); + let _url = ScopedEnvVar::remove("OPENAI_BASE_URL"); + // Direct provider/model strings should remain unchanged and pass let model = "openai/gpt-4o"; assert_eq!(resolve_model_alias_with_config(model), model); From 8e719943bc8811cb55450da42c3240e5dc4e8cd9 Mon Sep 17 00:00:00 2001 From: Georgije Savic <135692650+GeorgijeSav@users.noreply.github.com> Date: Wed, 27 May 2026 19:23:15 +0200 Subject: [PATCH 15/17] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- rust/crates/rusty-claude-cli/src/main.rs | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 3c6299edae..f29432ec98 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -16171,16 +16171,8 @@ mod dump_manifests_tests { #[cfg(test)] mod alias_resolution_tests { use std::ffi::OsString; - use std::sync::{Mutex, MutexGuard, OnceLock}; - - use super::{resolve_model_alias_with_config, validate_model_syntax}; - fn env_lock() -> MutexGuard<'static, ()> { - static LOCK: OnceLock> = OnceLock::new(); - LOCK.get_or_init(|| Mutex::new(())) - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - } + use super::{env_lock, resolve_model_alias_with_config, validate_model_syntax}; struct ScopedEnvVar { key: &'static str, From 00f9810f0d3b6c7b081b85dbdf4fa7c55a0d5a82 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 17:29:37 +0000 Subject: [PATCH 16/17] fix: add root-level env_lock so alias_resolution_tests can use super::env_lock --- rust/crates/rusty-claude-cli/src/main.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index f29432ec98..637cbee4b7 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -11112,6 +11112,15 @@ fn print_help(output_format: CliOutputFormat) -> Result<(), Box std::sync::MutexGuard<'static, ()> { + use std::sync::{Mutex, OnceLock}; + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + #[cfg(test)] mod tests { use super::{ From c426cfd5adcf866fb53e2bf83217317e1392c816 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 27 May 2026 17:41:04 +0000 Subject: [PATCH 17/17] fix: correct validate_model_syntax doc comment and consolidate env_lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Doc comment previously said the function accepts "known aliases (opus, sonnet, haiku)" which is incorrect — raw aliases are rejected and must be resolved by callers first. Updated to accurately describe what is accepted and rejected. - mod tests::env_lock was backed by its own OnceLock>, separate from the module-level env_lock added for alias_resolution_tests. Two independent mutexes mean env-var mutations in the two test modules could race. Replaced the inner definition with a thin wrapper that delegates to super::env_lock() so all test modules share one lock. --- rust/crates/rusty-claude-cli/src/main.rs | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 637cbee4b7..94d8099667 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -1844,13 +1844,15 @@ fn is_local_base_url(url: &str) -> bool { host.eq_ignore_ascii_case("localhost") || matches!(host, "127.0.0.1" | "::1") } -/// Validate model syntax at parse time. -/// Accepts: known aliases (opus, sonnet, haiku), provider/model pattern, -/// or bare model names when `OPENAI_BASE_URL` points to a local endpoint -/// (for providers like Ollama, LM Studio, vLLM where model names don't follow -/// provider/model format — e.g. "qwen2.5-coder:7b", "llama3:8b"). +/// Validate model syntax at parse time. Callers must resolve model aliases +/// (e.g. "opus" → "anthropic/claude-opus-4-6") before calling this function; +/// raw aliases are rejected. +/// +/// Accepts: `provider/model` format (e.g. "anthropic/claude-opus-4-6"), +/// or bare model names when `OPENAI_BASE_URL` points to a loopback host +/// (for local providers like Ollama, LM Studio, vLLM — e.g. "qwen2.5-coder:7b"). /// Rejects: empty or whitespace-only strings, strings containing spaces, -/// and malformed provider/model structure when provider/model syntax is required. +/// and bare model names when no loopback `OPENAI_BASE_URL` is configured. fn validate_model_syntax(model: &str) -> Result<(), String> { let trimmed = model.trim(); if trimmed.is_empty() { @@ -11407,11 +11409,8 @@ mod tests { ); } - fn env_lock() -> MutexGuard<'static, ()> { - static LOCK: OnceLock> = OnceLock::new(); - LOCK.get_or_init(|| Mutex::new(())) - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) + fn env_lock() -> std::sync::MutexGuard<'static, ()> { + super::env_lock() } fn with_current_dir(cwd: &Path, f: impl FnOnce() -> T) -> T {