mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-29 22:23:01 +00:00
fix(provider): gate Responses API by endpoint host + demote first 404 (#4209)
This commit is contained in:
@@ -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<String> {
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// `"<provider> 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(),
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user