mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix(inference): stop retry loop + disable /responses fallback after a 404 (nous-portal) (#4065)
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user