From 38bc6750d8d923aba1e9f070018c391fca22d6b8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 1 Jun 2026 16:41:22 +0000 Subject: [PATCH 1/5] =?UTF-8?q?fix:=20address=20PR=20#3167=20review=20comm?= =?UTF-8?q?ents=20=E2=80=94=20expand=20local=20URL=20detection,=20allow=20?= =?UTF-8?q?bare=20models=20for=20any=20OPENAI=5FBASE=5FURL,=20fix=20scheme?= =?UTF-8?q?-less=20URLs,=20rename=20test,=20extract=20shared=20is=5Flocal?= =?UTF-8?q?=5Furl?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rust/crates/api/src/lib.rs | 2 +- .../crates/api/src/providers/openai_compat.rs | 129 ++++++++++++++++-- rust/crates/rusty-claude-cli/src/main.rs | 81 ++++++----- 3 files changed, 158 insertions(+), 54 deletions(-) diff --git a/rust/crates/api/src/lib.rs b/rust/crates/api/src/lib.rs index e6624a3e5a..efa571e04a 100644 --- a/rust/crates/api/src/lib.rs +++ b/rust/crates/api/src/lib.rs @@ -21,7 +21,7 @@ pub use prompt_cache::{ pub use providers::anthropic::{AnthropicClient, AnthropicClient as ApiClient, AuthSource}; pub use providers::openai_compat::{ build_chat_completion_request, check_request_body_size, estimate_request_body_size, - flatten_tool_result_content, is_reasoning_model, model_rejects_is_error_field, + flatten_tool_result_content, is_local_url, is_reasoning_model, model_rejects_is_error_field, model_requires_reasoning_content_in_history, translate_message, OpenAiCompatClient, OpenAiCompatConfig, }; diff --git a/rust/crates/api/src/providers/openai_compat.rs b/rust/crates/api/src/providers/openai_compat.rs index 39fcec233d..2ddb95399e 100644 --- a/rust/crates/api/src/providers/openai_compat.rs +++ b/rust/crates/api/src/providers/openai_compat.rs @@ -947,12 +947,15 @@ fn normalize_base_url(url: &str) -> &str { } /// Extract the host (without port) from a URL string. +/// Supports URLs with or without a scheme (e.g. both `http://localhost:11434/v1` +/// and `localhost:11434/v1` are handled correctly). /// Returns an empty string if the URL cannot be parsed. fn url_host(url: &str) -> &str { - // Strip scheme ("https://", "http://", etc.) + // Strip scheme ("https://", "http://", etc.) if present; otherwise treat + // the whole string as authority+path (scheme-less input like "localhost:11434/v1"). let rest = match url.split_once("://") { Some((_, r)) => r, - None => return "", + None => url, }; // Isolate the authority (before the first '/', '?', or '#') let authority = rest.split(['/', '?', '#']).next().unwrap_or(""); @@ -974,6 +977,47 @@ fn url_host(url: &str) -> &str { } } +/// Returns `true` if the given URL points to a loopback or RFC 1918 private +/// network host. Recognised as local: +/// +/// - `localhost` (case-insensitive) +/// - Any `127.x.x.x` address (loopback range) +/// - `::1` (IPv6 loopback) +/// - `10.x.x.x` (RFC 1918 class A private) +/// - `172.16.x.x` – `172.31.x.x` (RFC 1918 class B private) +/// - `192.168.x.x` (RFC 1918 class C private) +/// +/// Accepts scheme-less inputs (e.g. `localhost:11434/v1`). +pub fn is_local_url(url: &str) -> bool { + let host = url_host(url); + if host.eq_ignore_ascii_case("localhost") || host == "::1" { + return true; + } + is_local_ipv4(host) +} + +/// Returns `true` for IPv4 addresses in the loopback (127/8) or RFC 1918 +/// private ranges. +fn is_local_ipv4(host: &str) -> bool { + let parts: Vec<&str> = host.split('.').collect(); + if parts.len() != 4 { + return false; + } + let Ok(a) = parts[0].parse::() else { + return false; + }; + let Ok(b) = parts[1].parse::() else { + return false; + }; + match a { + 127 => true, // 127.0.0.0/8 loopback + 10 => true, // 10.0.0.0/8 private + 172 => (16..=31).contains(&b), // 172.16.0.0/12 private + 192 => b == 168, // 192.168.0.0/16 private + _ => false, + } +} + fn wire_model_for_base_url<'a>( model: &'a str, config: OpenAiCompatConfig, @@ -990,17 +1034,15 @@ 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, - // LM Studio): strip because local servers use bare model names. + // - Local endpoints (loopback or RFC 1918, e.g. Ollama, LM Studio, + // vLLM on a local/private network): strip because these 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 = 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 { + if is_default_url || is_local_url(base_url) { return Cow::Borrowed(&model[pos + 1..]); } return Cow::Borrowed(model); @@ -2777,10 +2819,11 @@ mod tests { } #[test] - fn wire_model_strips_openai_prefix_for_custom_base_url() { + fn wire_model_strips_for_default_and_local_preserves_for_non_local_gateways() { // Issue #3123: Ollama models with openai/ prefix should have prefix - // stripped for the default OpenAI endpoint and for known-local endpoints, - // but preserved for custom non-local gateways (e.g. OpenRouter). + // stripped for the default OpenAI endpoint and for known-local endpoints + // (loopback or RFC 1918 private IPs), 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"; @@ -2887,5 +2930,69 @@ mod tests { ), Cow::Borrowed("openai/gpt-4.1-mini") ); + + // Scheme-less input (e.g. OPENAI_BASE_URL=localhost:11434/v1) should be + // treated as local and strip the openai/ prefix. + assert_eq!( + super::wire_model_for_base_url("openai/llama3.2", config, "localhost:11434/v1"), + Cow::Borrowed("llama3.2") + ); + + // RFC 1918 private addresses should be treated as local and strip the prefix. + assert_eq!( + super::wire_model_for_base_url( + "openai/llama3.2", + config, + "http://192.168.1.100:11434/v1" + ), + Cow::Borrowed("llama3.2") + ); + assert_eq!( + super::wire_model_for_base_url("openai/llama3.2", config, "http://10.0.0.5:11434/v1"), + Cow::Borrowed("llama3.2") + ); + assert_eq!( + super::wire_model_for_base_url( + "openai/llama3.2", + config, + "http://172.20.0.1:11434/v1" + ), + Cow::Borrowed("llama3.2") + ); + + // 127.x.x.x (all loopback, not just 127.0.0.1) should be treated as local. + assert_eq!( + super::wire_model_for_base_url( + "openai/llama3.2", + config, + "http://127.0.0.2:11434/v1" + ), + Cow::Borrowed("llama3.2") + ); + } + + #[test] + fn is_local_url_recognises_loopback_and_private_ranges() { + // Loopback + assert!(super::is_local_url("http://localhost:11434/v1")); + assert!(super::is_local_url("http://LOCALHOST/v1")); + assert!(super::is_local_url("http://127.0.0.1:11434/v1")); + assert!(super::is_local_url("http://127.0.0.2/v1")); + assert!(super::is_local_url("http://[::1]:11434/v1")); + // Scheme-less + assert!(super::is_local_url("localhost:11434/v1")); + assert!(super::is_local_url("127.0.0.1:11434/v1")); + // RFC 1918 private ranges + assert!(super::is_local_url("http://10.0.0.5/v1")); + assert!(super::is_local_url("http://10.255.255.255/v1")); + assert!(super::is_local_url("http://172.16.0.1/v1")); + assert!(super::is_local_url("http://172.31.0.1/v1")); + assert!(super::is_local_url("http://192.168.1.100/v1")); + // Non-local should return false + assert!(!super::is_local_url("https://openrouter.ai/api/v1")); + assert!(!super::is_local_url("https://api.openai.com/v1")); + assert!(!super::is_local_url("http://172.15.0.1/v1")); // just outside 172.16/12 + assert!(!super::is_local_url("http://172.32.0.1/v1")); // just outside 172.16/12 + assert!(!super::is_local_url("https://not-localhost.example.com/v1")); } } diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 4872472a9d..39fc0709ea 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -1926,41 +1926,17 @@ 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. 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"). +/// or bare model names when `OPENAI_BASE_URL` is set to any non-empty value +/// (for local and custom OpenAI-compatible providers like Ollama, LM Studio, +/// vLLM, or any corporate LLM API that does not use the provider/model +/// convention — e.g. "qwen2.5-coder:7b"). /// Rejects: empty or whitespace-only strings, strings containing spaces, -/// and bare model names when no loopback `OPENAI_BASE_URL` is configured. +/// and bare model names when no `OPENAI_BASE_URL` is configured. fn validate_model_syntax(model: &str) -> Result<(), String> { let trimmed = model.trim(); if trimmed.is_empty() { @@ -1976,15 +1952,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 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. + // When OPENAI_BASE_URL is set (to any non-empty value), allow bare + // model names (e.g. "qwen2.5-coder:7b", "llama3:8b") that don't + // follow the provider/model convention. This covers local providers + // (Ollama, LM Studio, vLLM) as well as corporate LLM APIs and any + // other custom OpenAI-compatible endpoint that uses bare model IDs. if parts.len() == 1 { if let Ok(base_url) = std::env::var("OPENAI_BASE_URL") { - if is_local_base_url(&base_url) { + if !base_url.trim().is_empty() { return Ok(()); } } @@ -16589,8 +16564,8 @@ mod alias_resolution_tests { #[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. + // should pass validation when OPENAI_BASE_URL is set to any non-empty + // value — including local, private-network, and corporate endpoints. 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()); @@ -16599,12 +16574,34 @@ mod alias_resolution_tests { } #[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`. + fn test_bare_model_name_passes_for_any_custom_openai_base_url() { + // Bare model names should be allowed for any custom OPENAI_BASE_URL, + // not just loopback addresses. Corporate LLM APIs, private-network + // endpoints (e.g. DGX Spark), and scheme-less URLs all qualify. let _guard = env_lock(); + + // Non-local gateway: bare names now allowed let _url = ScopedEnvVar::set("OPENAI_BASE_URL", "https://openrouter.ai/api/v1"); + assert!(validate_model_syntax("mistral").is_ok()); + assert!(validate_model_syntax("qwen2.5-coder:7b").is_ok()); + drop(_url); + + // Private-network inference server + let _url = ScopedEnvVar::set("OPENAI_BASE_URL", "http://192.168.1.100:11434/v1"); + assert!(validate_model_syntax("llama3:8b").is_ok()); + drop(_url); + + // Scheme-less input + let _url = ScopedEnvVar::set("OPENAI_BASE_URL", "localhost:11434/v1"); + assert!(validate_model_syntax("mistral").is_ok()); + } + + #[test] + fn test_bare_model_name_rejected_when_no_openai_base_url() { + // Bare model names should be rejected when no OPENAI_BASE_URL is set, + // since the default Anthropic endpoint requires provider/model format. + let _guard = env_lock(); + std::env::remove_var("OPENAI_BASE_URL"); assert!(validate_model_syntax("mistral").is_err()); assert!(validate_model_syntax("qwen2.5-coder:7b").is_err()); } From 29b94467ab810dea0eaddacdbe4f651797f3a690 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 1 Jun 2026 16:44:17 +0000 Subject: [PATCH 2/5] fix: validate all IPv4 octets in is_local_ipv4, fix spelling, add invalid IP tests --- rust/crates/api/src/providers/openai_compat.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/rust/crates/api/src/providers/openai_compat.rs b/rust/crates/api/src/providers/openai_compat.rs index 2ddb95399e..64fce24279 100644 --- a/rust/crates/api/src/providers/openai_compat.rs +++ b/rust/crates/api/src/providers/openai_compat.rs @@ -1009,6 +1009,10 @@ fn is_local_ipv4(host: &str) -> bool { let Ok(b) = parts[1].parse::() else { return false; }; + // Validate remaining octets to reject inputs like "10.x.y.z" + if parts[2].parse::().is_err() || parts[3].parse::().is_err() { + return false; + } match a { 127 => true, // 127.0.0.0/8 loopback 10 => true, // 10.0.0.0/8 private @@ -2972,7 +2976,7 @@ mod tests { } #[test] - fn is_local_url_recognises_loopback_and_private_ranges() { + fn is_local_url_recognizes_loopback_and_private_ranges() { // Loopback assert!(super::is_local_url("http://localhost:11434/v1")); assert!(super::is_local_url("http://LOCALHOST/v1")); @@ -2994,5 +2998,8 @@ mod tests { assert!(!super::is_local_url("http://172.15.0.1/v1")); // just outside 172.16/12 assert!(!super::is_local_url("http://172.32.0.1/v1")); // just outside 172.16/12 assert!(!super::is_local_url("https://not-localhost.example.com/v1")); + // Invalid IPv4-like strings must not produce false positives + assert!(!super::is_local_url("http://10.x.y.z/v1")); + assert!(!super::is_local_url("http://192.168.x.1/v1")); } } From 52345a593aa4f9146aae1b399245c163ecfa6edc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 1 Jun 2026 16:57:58 +0000 Subject: [PATCH 3/5] fix: use Ipv4Addr::from_str in is_local_ipv4, trim URL whitespace, use ScopedEnvVar::remove in test, fix cargo fmt --- .../crates/api/src/providers/openai_compat.rs | 37 +++++-------------- rust/crates/rusty-claude-cli/src/main.rs | 2 +- 2 files changed, 11 insertions(+), 28 deletions(-) diff --git a/rust/crates/api/src/providers/openai_compat.rs b/rust/crates/api/src/providers/openai_compat.rs index 64fce24279..13f7cf2636 100644 --- a/rust/crates/api/src/providers/openai_compat.rs +++ b/rust/crates/api/src/providers/openai_compat.rs @@ -1,5 +1,6 @@ use std::borrow::Cow; use std::collections::{BTreeMap, VecDeque}; +use std::net::Ipv4Addr; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -989,7 +990,7 @@ fn url_host(url: &str) -> &str { /// /// Accepts scheme-less inputs (e.g. `localhost:11434/v1`). pub fn is_local_url(url: &str) -> bool { - let host = url_host(url); + let host = url_host(url.trim()); if host.eq_ignore_ascii_case("localhost") || host == "::1" { return true; } @@ -999,25 +1000,15 @@ pub fn is_local_url(url: &str) -> bool { /// Returns `true` for IPv4 addresses in the loopback (127/8) or RFC 1918 /// private ranges. fn is_local_ipv4(host: &str) -> bool { - let parts: Vec<&str> = host.split('.').collect(); - if parts.len() != 4 { - return false; - } - let Ok(a) = parts[0].parse::() else { - return false; - }; - let Ok(b) = parts[1].parse::() else { + let Ok(addr) = host.parse::() else { return false; }; - // Validate remaining octets to reject inputs like "10.x.y.z" - if parts[2].parse::().is_err() || parts[3].parse::().is_err() { - return false; - } + let [a, b, ..] = addr.octets(); match a { - 127 => true, // 127.0.0.0/8 loopback - 10 => true, // 10.0.0.0/8 private - 172 => (16..=31).contains(&b), // 172.16.0.0/12 private - 192 => b == 168, // 192.168.0.0/16 private + 127 => true, // 127.0.0.0/8 loopback + 10 => true, // 10.0.0.0/8 private + 172 => (16..=31).contains(&b), // 172.16.0.0/12 private + 192 => b == 168, // 192.168.0.0/16 private _ => false, } } @@ -2956,21 +2947,13 @@ mod tests { Cow::Borrowed("llama3.2") ); assert_eq!( - super::wire_model_for_base_url( - "openai/llama3.2", - config, - "http://172.20.0.1:11434/v1" - ), + super::wire_model_for_base_url("openai/llama3.2", config, "http://172.20.0.1:11434/v1"), Cow::Borrowed("llama3.2") ); // 127.x.x.x (all loopback, not just 127.0.0.1) should be treated as local. assert_eq!( - super::wire_model_for_base_url( - "openai/llama3.2", - config, - "http://127.0.0.2:11434/v1" - ), + super::wire_model_for_base_url("openai/llama3.2", config, "http://127.0.0.2:11434/v1"), Cow::Borrowed("llama3.2") ); } diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 39fc0709ea..3183ba2a6e 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -16601,7 +16601,7 @@ mod alias_resolution_tests { // Bare model names should be rejected when no OPENAI_BASE_URL is set, // since the default Anthropic endpoint requires provider/model format. let _guard = env_lock(); - std::env::remove_var("OPENAI_BASE_URL"); + let _url = ScopedEnvVar::remove("OPENAI_BASE_URL"); assert!(validate_model_syntax("mistral").is_err()); assert!(validate_model_syntax("qwen2.5-coder:7b").is_err()); } From 7fd5b73d6d11cad41e5efd97be73fe691410d2ff Mon Sep 17 00:00:00 2001 From: Georgije Savic <135692650+GeorgijeSav@users.noreply.github.com> Date: Mon, 1 Jun 2026 19:28:50 +0200 Subject: [PATCH 4/5] 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, 2 insertions(+), 3 deletions(-) diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 3183ba2a6e..ca6d2583b9 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -1926,10 +1926,9 @@ fn resolve_model_alias_with_config(model: &str) -> String { resolve_model_alias(trimmed).to_string() } -/// Validate model syntax at parse time. Callers must resolve model aliases +/// Validate model syntax at parse time. Callers should resolve model aliases /// (e.g. "opus" → "anthropic/claude-opus-4-6") before calling this function; -/// raw aliases are rejected. -/// +/// otherwise an alias may be treated as a bare model id (when OPENAI_BASE_URL is set). /// Accepts: `provider/model` format (e.g. "anthropic/claude-opus-4-6"), /// or bare model names when `OPENAI_BASE_URL` is set to any non-empty value /// (for local and custom OpenAI-compatible providers like Ollama, LM Studio, From 32a98111be95d1f7c62eb547767f2dd0f3701fa3 Mon Sep 17 00:00:00 2001 From: Georgije Savic <135692650+GeorgijeSav@users.noreply.github.com> Date: Mon, 1 Jun 2026 19:29:02 +0200 Subject: [PATCH 5/5] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- rust/crates/api/src/providers/openai_compat.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/crates/api/src/providers/openai_compat.rs b/rust/crates/api/src/providers/openai_compat.rs index 13f7cf2636..f41724a205 100644 --- a/rust/crates/api/src/providers/openai_compat.rs +++ b/rust/crates/api/src/providers/openai_compat.rs @@ -950,7 +950,7 @@ fn normalize_base_url(url: &str) -> &str { /// Extract the host (without port) from a URL string. /// Supports URLs with or without a scheme (e.g. both `http://localhost:11434/v1` /// and `localhost:11434/v1` are handled correctly). -/// Returns an empty string if the URL cannot be parsed. +/// Returns an empty string if no authority/host segment can be extracted. fn url_host(url: &str) -> &str { // Strip scheme ("https://", "http://", etc.) if present; otherwise treat // the whole string as authority+path (scheme-less input like "localhost:11434/v1").