diff --git a/src/openhuman/inference/provider/compatible.rs b/src/openhuman/inference/provider/compatible.rs index a0083ca22..34eb4d08b 100644 --- a/src/openhuman/inference/provider/compatible.rs +++ b/src/openhuman/inference/provider/compatible.rs @@ -282,6 +282,22 @@ impl OpenAiCompatibleProvider { self } + /// Whether the chat-completions-404 → `/v1/responses` fallback should fire + /// for this provider on the current call. + /// + /// `supports_responses_fallback` is the static (factory-decided) capability, + /// but a custom / unknown slug whose endpoint does not actually implement + /// the Responses API only reveals that at runtime — the `/responses` route + /// returns its own 404. Once we have seen that, [`responses_api_known_unsupported`] + /// reports the endpoint as Responses-incapable and we stop re-issuing the + /// guaranteed-failing second request, routing to chat-completions only. This + /// is the runtime complement to the builtin-slug gate in `factory.rs` + /// (`builtin_cloud_supports_responses_api`) for custom slugs like + /// `nous-portal` that the factory can't classify (TAURI-RUST-FJZ). + pub(super) fn responses_fallback_active(&self) -> bool { + self.supports_responses_fallback && !responses_api_known_unsupported(&self.base_url) + } + pub fn with_extra_query_param( mut self, name: impl Into, @@ -297,6 +313,45 @@ impl OpenAiCompatibleProvider { } } +/// Endpoints (`base_url`) whose `/v1/responses` route has returned 404 — i.e. +/// they do not implement the OpenAI Responses API. Once an endpoint is recorded +/// here, the chat-completions-404 → `/responses` fallback is disabled for it for +/// the rest of the process lifetime (see [`OpenAiCompatibleProvider::responses_fallback_active`]), +/// so a permanent client 404 stops triggering a second guaranteed 404 on every +/// retry (TAURI-RUST-FJZ — `nous-portal`, a chat-completions-only custom slug). +/// +/// Keyed on `base_url` because that, not the user-facing slug, determines +/// Responses support: two slugs pointed at the same endpoint share its +/// capability, and a slug rename must not reset what we learned. +fn responses_unsupported_endpoints() -> &'static std::sync::Mutex> +{ + static CACHE: std::sync::OnceLock>> = + std::sync::OnceLock::new(); + CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new())) +} + +/// Record that `base_url` returned 404 from its `/v1/responses` route and is +/// therefore not Responses-capable. Idempotent. +pub(super) fn mark_responses_api_unsupported(base_url: &str) { + if let Ok(mut set) = responses_unsupported_endpoints().lock() { + if set.insert(base_url.to_string()) { + log::debug!( + "[provider] /responses route 404'd — disabling responses fallback for \ + endpoint {} (chat-completions only henceforth)", + super::factory::redact_endpoint(base_url), + ); + } + } +} + +/// Whether `base_url` has been recorded as not implementing the Responses API. +pub(super) fn responses_api_known_unsupported(base_url: &str) -> bool { + responses_unsupported_endpoints() + .lock() + .map(|set| set.contains(base_url)) + .unwrap_or(false) +} + /// Prompt-cache behaviour for an OpenAI-compatible provider, keyed on its /// configured slug (#3939). /// diff --git a/src/openhuman/inference/provider/compatible_helpers.rs b/src/openhuman/inference/provider/compatible_helpers.rs index 3c3b4d49f..11d7fc6ec 100644 --- a/src/openhuman/inference/provider/compatible_helpers.rs +++ b/src/openhuman/inference/provider/compatible_helpers.rs @@ -74,7 +74,30 @@ impl OpenAiCompatibleProvider { let status_str = status.as_u16().to_string(); let error = response.text().await?; let sanitized = super::super::sanitize_api_error(&error); - let message = format!("{} Responses API error: {sanitized}", self.name); + // Emit the status in the structured `()` position the retry + // classifier understands (`reliable::structured_http_4xx`). The bare + // `"… Responses API error: 404 Not Found"` form left the `404` + // unanchored, so a terminal 404 was misclassified as retryable and + // looped indefinitely (TAURI-RUST-FJZ, ~15k events). + let message = format!( + "{} Responses API error ({status_str}): {sanitized}", + self.name + ); + // A 404 from the `/responses` route can mean this endpoint has no + // Responses API at all — disable the chat-completions-404 → + // `/responses` fallback for it so we stop issuing a guaranteed second + // 404. Guard against poisoning the process-global cache on a + // model/deployment-specific 404 (the route exists, the model + // doesn't), which would wrongly drop the fallback for every other + // 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 + && !self.responses_api_primary + && Self::responses_404_indicates_missing_route(&error) + { + super::mark_responses_api_unsupported(&self.base_url); + } if super::super::is_budget_exhausted_http_400(status, &error) { super::super::log_budget_exhausted_http_400( "responses_api", @@ -601,6 +624,20 @@ impl OpenAiCompatibleProvider { || lower.contains("unexpected")) } + /// Disambiguate a 404 from the `/responses` route: `true` when it signals the + /// *route itself* is absent (this endpoint has no Responses API), `false` when + /// it looks model/deployment-specific (the route exists, that model doesn't). + /// + /// Only a missing-route 404 should disable the fallback for the whole endpoint + /// (TAURI-RUST-FJZ). A bad-model 404 must NOT poison the process-global cache, + /// or a single bad model would drop the `/responses` fallback for every other + /// model on a Responses-capable endpoint. Conservative: any mention of a + /// model/deployment keeps the fallback enabled. + pub(super) fn responses_404_indicates_missing_route(error: &str) -> bool { + let lower = error.to_lowercase(); + !(lower.contains("model") || lower.contains("deployment")) + } + /// Detect a 404 whose body says the model is completion-only. See issue #3193. pub(super) fn is_completion_only_model_404(status: reqwest::StatusCode, error: &str) -> bool { if status != reqwest::StatusCode::NOT_FOUND { diff --git a/src/openhuman/inference/provider/compatible_provider_impl.rs b/src/openhuman/inference/provider/compatible_provider_impl.rs index 37359ce34..5691b7ed6 100644 --- a/src/openhuman/inference/provider/compatible_provider_impl.rs +++ b/src/openhuman/inference/provider/compatible_provider_impl.rs @@ -100,7 +100,7 @@ impl Provider for OpenAiCompatibleProvider { { Ok(response) => response, Err(chat_error) => { - if self.supports_responses_fallback { + if self.responses_fallback_active() { let detail = super::super::format_error_chain(&chat_error); return self .chat_via_responses(credential, &fallback_messages, model, None) @@ -131,7 +131,7 @@ impl Provider for OpenAiCompatibleProvider { return Err(err); } - if status == reqwest::StatusCode::NOT_FOUND && self.supports_responses_fallback { + if status == reqwest::StatusCode::NOT_FOUND && self.responses_fallback_active() { return self .chat_via_responses(credential, &fallback_messages, model, None) .await @@ -297,7 +297,7 @@ impl Provider for OpenAiCompatibleProvider { { Ok(response) => response, Err(chat_error) => { - if self.supports_responses_fallback { + if self.responses_fallback_active() { let detail = super::super::format_error_chain(&chat_error); return self .chat_via_responses(credential, &effective_messages, model, None) @@ -326,7 +326,7 @@ impl Provider for OpenAiCompatibleProvider { return Err(err); } - if self.supports_responses_fallback { + if self.responses_fallback_active() { return self .chat_via_responses(credential, &effective_messages, model, None) .await @@ -662,7 +662,7 @@ impl Provider for OpenAiCompatibleProvider { { Ok(response) => response, Err(chat_error) => { - if self.supports_responses_fallback { + if self.responses_fallback_active() { let detail = super::super::format_error_chain(&chat_error); return self .chat_via_responses( @@ -718,7 +718,7 @@ impl Provider for OpenAiCompatibleProvider { return Err(err); } - if status == reqwest::StatusCode::NOT_FOUND && self.supports_responses_fallback { + if status == reqwest::StatusCode::NOT_FOUND && self.responses_fallback_active() { return self .chat_via_responses(credential, &effective_messages, model, request.max_tokens) .await diff --git a/src/openhuman/inference/provider/compatible_tests.rs b/src/openhuman/inference/provider/compatible_tests.rs index 176cf159d..3973af2e5 100644 --- a/src/openhuman/inference/provider/compatible_tests.rs +++ b/src/openhuman/inference/provider/compatible_tests.rs @@ -2972,6 +2972,67 @@ fn custom_openai_provider_has_no_responses_fallback() { ); } +#[test] +fn responses_404_disables_fallback_for_endpoint() { + // TAURI-RUST-FJZ: a custom slug (factory can't classify it, so the static + // fallback flag is ON) whose endpoint 404s on `/responses` must stop + // attempting that fallback once the route is known-missing — routing to + // chat-completions only. Use a unique base_url; the cache is process-global. + let base_url = "https://responses-404-test.example.com/v1"; + let p = + OpenAiCompatibleProvider::new("nous-portal", base_url, Some("sk-test"), AuthStyle::Bearer); + assert!( + p.responses_fallback_active(), + "a fresh custom slug starts with the fallback enabled" + ); + + super::mark_responses_api_unsupported(base_url); + + assert!( + super::responses_api_known_unsupported(base_url), + "the endpoint is recorded as Responses-incapable after a 404" + ); + assert!( + !p.responses_fallback_active(), + "the fallback is disabled once `/responses` has 404'd for this endpoint" + ); + + // A provider for a different endpoint is unaffected. + let other = OpenAiCompatibleProvider::new( + "nous-portal", + "https://responses-404-test.example.com/v2", + Some("sk-test"), + AuthStyle::Bearer, + ); + assert!( + other.responses_fallback_active(), + "the cache is keyed per-endpoint, not globally" + ); +} + +#[test] +fn responses_404_route_vs_model_disambiguation() { + // A generic "route missing" 404 → this endpoint has no Responses API. + assert!(OpenAiCompatibleProvider::responses_404_indicates_missing_route("404 Not Found")); + assert!( + OpenAiCompatibleProvider::responses_404_indicates_missing_route( + "404 page not found" + ) + ); + // A model/deployment-specific 404 → the route exists; keep the fallback so + // we don't poison the cache for other models on a Responses-capable endpoint. + assert!( + !OpenAiCompatibleProvider::responses_404_indicates_missing_route( + r#"{"error":{"message":"model 'gpt-x' not found","code":"model_not_found"}}"# + ) + ); + assert!( + !OpenAiCompatibleProvider::responses_404_indicates_missing_route( + r#"{"error":{"message":"The API deployment for this resource does not exist"}}"# + ) + ); +} + #[test] fn enrich_404_message_adds_hint_when_no_fallback() { let p = OpenAiCompatibleProvider::new_no_responses_fallback( diff --git a/src/openhuman/inference/provider/factory.rs b/src/openhuman/inference/provider/factory.rs index 5d31460b8..80dcc9ae2 100644 --- a/src/openhuman/inference/provider/factory.rs +++ b/src/openhuman/inference/provider/factory.rs @@ -1724,7 +1724,7 @@ fn make_openai_compatible_provider_with_config( } /// Return a safe-to-log representation of a URL endpoint: `scheme://host` only. -fn redact_endpoint(url: &str) -> String { +pub(super) fn redact_endpoint(url: &str) -> String { let trimmed = url.trim(); if let Some(rest) = trimmed.split_once("://") { let scheme = rest.0; diff --git a/src/openhuman/inference/provider/reliable_tests.rs b/src/openhuman/inference/provider/reliable_tests.rs index f19e3bf56..640f38f37 100644 --- a/src/openhuman/inference/provider/reliable_tests.rs +++ b/src/openhuman/inference/provider/reliable_tests.rs @@ -219,6 +219,21 @@ fn non_retryable_detects_common_patterns() { assert!(is_non_retryable(&anyhow::anyhow!( "SESSION_EXPIRED: backend session not active — sign in to resume LLM work" ))); + // TAURI-RUST-FJZ: the Responses-path error now carries the status in the + // structured `()` position, so a terminal 404 from a provider that + // lacks the Responses API is classified non-retryable and the retry loop + // stops instead of hammering the permanent 404 (~15k events). + assert!(is_non_retryable(&anyhow::anyhow!( + "nous-portal Responses API error (404): Not Found" + ))); + // The pre-fix form left `404` unanchored (preceded by `error: `), so it + // slipped past the structured-status regex and looped — guard the regression. + assert!( + !is_non_retryable(&anyhow::anyhow!( + "nous-portal Responses API error: 404 Not Found" + )), + "documents the pre-fix misclassification the structured `(404)` form fixes" + ); } // C10: a 4xx-looking digit run that appears in *free text* (latency figures,