From a2daa8c657dde1b245a31d4e1cee724340ace6a7 Mon Sep 17 00:00:00 2001 From: Mega Mind <146339422+M3gA-Mind@users.noreply.github.com> Date: Mon, 29 Jun 2026 09:55:23 +0530 Subject: [PATCH] fix(provider): gate Responses API by endpoint host + demote first 404 (#4209) --- .../config/schema/cloud_providers.rs | 151 +++++++++++++++++- .../inference/provider/compatible_helpers.rs | 23 ++- src/openhuman/inference/provider/factory.rs | 15 +- 3 files changed, 181 insertions(+), 8 deletions(-) diff --git a/src/openhuman/config/schema/cloud_providers.rs b/src/openhuman/config/schema/cloud_providers.rs index db0b104c2..6fdc3f655 100644 --- a/src/openhuman/config/schema/cloud_providers.rs +++ b/src/openhuman/config/schema/cloud_providers.rs @@ -229,6 +229,65 @@ pub fn builtin_cloud_supports_responses_api(slug: &str) -> bool { matches!(slug, "openai") } +/// Extract the lowercased authority host from an endpoint URL, dropping the +/// scheme, any userinfo, the port, and the path. Returns `None` when no host +/// can be parsed. Tolerant of a missing scheme and of IPv6 literals. +pub(crate) fn endpoint_host(endpoint: &str) -> Option { + let s = endpoint.trim(); + // Drop the scheme (`https://…`); tolerate a bare `host/path` form. + let after_scheme = s.split_once("://").map(|(_, rest)| rest).unwrap_or(s); + // The authority ends at the first path / query / fragment delimiter. + let authority = after_scheme + .split(['/', '?', '#']) + .next() + .unwrap_or(after_scheme); + // Strip any `user:pass@` userinfo prefix. + let host_port = authority + .rsplit_once('@') + .map(|(_, host)| host) + .unwrap_or(authority); + // Strip the port, handling bracketed IPv6 literals (`[::1]:8080`). + let host = if let Some(rest) = host_port.strip_prefix('[') { + rest.split_once(']').map(|(h, _)| h).unwrap_or(rest) + } else { + host_port + .rsplit_once(':') + .map(|(h, _)| h) + .unwrap_or(host_port) + }; + let host = host.trim().to_ascii_lowercase(); + (!host.is_empty()).then_some(host) +} + +/// Whether an endpoint **host** is a known cloud host that does NOT serve the +/// OpenAI Responses API (`/v1/responses`) — i.e. it is chat-completions-only, +/// regardless of which user slug points at it. +/// +/// Derived entirely from [`BUILTIN_CLOUD_PROVIDERS`]: a host is chat-only when +/// some built-in preset uses it AND no preset at that host advertises the +/// Responses API (only OpenAI's `api.openai.com` does). This closes the +/// custom-slug gap behind the builtin-slug gate +/// ([`builtin_cloud_supports_responses_api`]): a user slug pointed at, e.g., +/// `integrate.api.nvidia.com` must never attempt `/responses` (TAURI-RUST-5A1), +/// while a genuinely unknown proxy host keeps the permissive fallback so a real +/// OpenAI proxy still gets `/responses`. +pub fn endpoint_host_is_chat_completions_only(endpoint: &str) -> bool { + let Some(host) = endpoint_host(endpoint) else { + return false; + }; + let mut matched_chat_only = false; + for provider in BUILTIN_CLOUD_PROVIDERS { + if endpoint_host(provider.endpoint).as_deref() == Some(host.as_str()) { + if builtin_cloud_supports_responses_api(provider.slug) { + // A Responses-capable built-in lives at this host → not chat-only. + return false; + } + matched_chat_only = true; + } + } + matched_chat_only +} + /// Authentication header style for a cloud provider. /// /// Wire format is lowercase (e.g. `"bearer"`). Determines which HTTP headers @@ -504,7 +563,8 @@ impl CloudProviderType { #[cfg(test)] mod tests { use super::{ - builtin_cloud_supports_responses_api, is_builtin_cloud_slug, is_slug_reserved, + builtin_cloud_supports_responses_api, endpoint_host, + endpoint_host_is_chat_completions_only, is_builtin_cloud_slug, is_slug_reserved, migrate_legacy_fields, AuthStyle, CloudProviderCreds, BUILTIN_CLOUD_PROVIDERS, }; @@ -634,4 +694,93 @@ mod tests { ); } } + + #[test] + fn endpoint_host_parses_scheme_path_and_port() { + assert_eq!( + endpoint_host("https://integrate.api.nvidia.com/v1").as_deref(), + Some("integrate.api.nvidia.com") + ); + // Missing scheme, mixed case, trailing path. + assert_eq!( + endpoint_host("API.OpenAI.com/v1/chat").as_deref(), + Some("api.openai.com") + ); + // Userinfo + explicit port are stripped. + assert_eq!( + endpoint_host("https://user:pass@api.groq.com:443/openai/v1").as_deref(), + Some("api.groq.com") + ); + // Bracketed IPv6 literal with port. + assert_eq!( + endpoint_host("http://[::1]:8080/v1").as_deref(), + Some("::1") + ); + assert_eq!(endpoint_host(" ").as_deref(), None); + } + + /// TAURI-RUST-5A1: a *custom* slug pointed at a known chat-only host (NVIDIA) + /// must be classified chat-only so the factory disables the guaranteed-404 + /// `/responses` fallback — the builtin-slug gate alone misses this because + /// the slug is not builtin. + #[test] + fn nvidia_host_is_chat_completions_only_regardless_of_slug() { + assert!(endpoint_host_is_chat_completions_only( + "https://integrate.api.nvidia.com/v1" + )); + // Other chat-only built-in hosts too. + for endpoint in [ + "https://api.deepseek.com/v1", + "https://api.groq.com/openai/v1", + "https://api.mistral.ai/v1", + ] { + assert!( + endpoint_host_is_chat_completions_only(endpoint), + "{endpoint} is a chat-completions-only built-in host" + ); + } + } + + #[test] + fn openai_host_and_unknown_proxies_keep_the_responses_fallback() { + // OpenAI's first-party host serves /responses — must NOT be gated off, + // even via a custom proxy slug pointed at it. + assert!(!endpoint_host_is_chat_completions_only( + "https://api.openai.com/v1" + )); + // Genuinely unknown proxy hosts keep the permissive default (they may be + // real OpenAI proxies that implement /responses). + for endpoint in [ + "https://my-llm-proxy.internal.example/v1", + "https://litellm.mycorp.dev/v1", + "", + ] { + assert!( + !endpoint_host_is_chat_completions_only(endpoint), + "{endpoint:?} is an unknown host and must keep the fallback" + ); + } + } + + /// Drift guard: the host-based gate must agree with the slug-based + /// capability for every built-in preset's own endpoint, so adding a preset + /// can't silently desync the two gates. + #[test] + fn host_gate_agrees_with_slug_capability_for_every_builtin() { + for provider in BUILTIN_CLOUD_PROVIDERS { + // OpenhumanJwt / Anthropic presets never route through the + // OpenAI-compatible Responses fallback; the gate only matters for + // the Bearer OpenAI-compatible hosts. + if provider.auth_style != AuthStyle::Bearer { + continue; + } + let host_chat_only = endpoint_host_is_chat_completions_only(provider.endpoint); + let slug_supports = builtin_cloud_supports_responses_api(provider.slug); + assert_eq!( + host_chat_only, !slug_supports, + "host gate for built-in {} disagrees with its slug capability", + provider.slug + ); + } + } } diff --git a/src/openhuman/inference/provider/compatible_helpers.rs b/src/openhuman/inference/provider/compatible_helpers.rs index 4b8460d75..3425c3163 100644 --- a/src/openhuman/inference/provider/compatible_helpers.rs +++ b/src/openhuman/inference/provider/compatible_helpers.rs @@ -144,13 +144,28 @@ impl OpenAiCompatibleProvider { // model on a Responses-capable endpoint. Skip when Responses is the // primary path (Codex OAuth): the fallback flag is never consulted // and a 404 there is not evidence the route is missing. - if status == reqwest::StatusCode::NOT_FOUND + let responses_route_missing = status == reqwest::StatusCode::NOT_FOUND && !self.responses_api_primary - && Self::responses_404_indicates_missing_route(&error) - { + && Self::responses_404_indicates_missing_route(&error); + if responses_route_missing { super::mark_responses_api_unsupported(&self.base_url); } - if super::super::is_budget_exhausted_http_400(status, &error) { + if responses_route_missing { + // The `/responses` route 404'd: this endpoint is chat-completions + // only. We've just cached it unsupported so the fallback won't fire + // again this process, but the very first probe per process still + // lands here — and reporting it floods Sentry with one + // `" Responses API error (404): …"` event per fresh + // session (TAURI-RUST-5A1, ~900 status=404 events). It is an + // expected capability-probe miss (the chat-completions path serves + // the request), not a Sentry-actionable defect, so demote to info. + log::info!( + "[provider] {} /responses route 404 — endpoint is chat-completions only; \ + demoting and caching unsupported (fallback disabled henceforth): {}", + self.name, + super::super::factory::redact_endpoint(&self.base_url), + ); + } else if super::super::is_budget_exhausted_http_400(status, &error) { super::super::log_budget_exhausted_http_400( "responses_api", self.name.as_str(), diff --git a/src/openhuman/inference/provider/factory.rs b/src/openhuman/inference/provider/factory.rs index 188e7ac2f..bdd24a86e 100644 --- a/src/openhuman/inference/provider/factory.rs +++ b/src/openhuman/inference/provider/factory.rs @@ -25,7 +25,8 @@ //! Unknown slugs and missing-creds configurations produce actionable errors. use crate::openhuman::config::schema::cloud_providers::{ - builtin_cloud_supports_responses_api, is_builtin_cloud_slug, AuthStyle, + builtin_cloud_supports_responses_api, endpoint_host_is_chat_completions_only, + is_builtin_cloud_slug, AuthStyle, }; use crate::openhuman::config::Config; use crate::openhuman::credentials::AuthService; @@ -1639,8 +1640,16 @@ fn make_cloud_provider_by_slug( // local-provider TAURI-RUST-59Y fix). OpenAI keeps the fallback // (genuine `/responses`), and so do custom / unknown slugs, whose // endpoint may be a real OpenAI proxy. - let responses_fallback = - !is_builtin_cloud_slug(slug) || builtin_cloud_supports_responses_api(slug); + // + // The builtin-slug gate alone leaks for a *custom* slug pointed at a + // known chat-only host (e.g. a user slug at + // `integrate.api.nvidia.com`): `is_builtin_cloud_slug` is false so + // the fallback stayed on and `/responses` 404'd (TAURI-RUST-5A1). + // Also consult the endpoint host so a chat-only host disables the + // fallback regardless of slug; an unknown proxy host still keeps it. + let responses_fallback = (!is_builtin_cloud_slug(slug) + || builtin_cloud_supports_responses_api(slug)) + && !endpoint_host_is_chat_completions_only(&openai_codex_routing.endpoint); let credential = (!key.trim().is_empty()).then_some(key.as_str()); let base_provider = if responses_fallback { OpenAiCompatibleProvider::new(