fix(inference): stop retry loop + disable /responses fallback after a 404 (nous-portal) (#4065)

This commit is contained in:
Mega Mind
2026-06-24 10:11:08 -07:00
committed by GitHub
parent f0489cae48
commit ed8aa69812
6 changed files with 176 additions and 8 deletions
@@ -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<String>,
@@ -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<std::collections::HashSet<String>>
{
static CACHE: std::sync::OnceLock<std::sync::Mutex<std::collections::HashSet<String>>> =
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).
///
@@ -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 `(<status>)` 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 {
@@ -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
@@ -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(
"<html><body>404 page not found</body></html>"
)
);
// 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(
+1 -1
View File
@@ -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;
@@ -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 `(<status>)` 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,