mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix(inference): don't send max_output_tokens to the Codex OAuth /responses endpoint (#4068)
This commit is contained in:
@@ -53,147 +53,189 @@ impl OpenAiCompatibleProvider {
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
let request = ResponsesRequest {
|
||||
// TAURI-RUST-EWD: the Codex/ChatGPT OAuth Responses endpoint
|
||||
// (`chatgpt.com/backend-api/codex/responses`) rejects `max_output_tokens`
|
||||
// outright (400 `Unsupported parameter: max_output_tokens`), the same way
|
||||
// it rejects `stream: false` (#3201). Omit the cap proactively for that
|
||||
// endpoint; every other Responses backend keeps it (the cap still flows
|
||||
// through for `responses_api_primary` on a real `/v1/responses`).
|
||||
if is_codex_oauth_responses && max_output_tokens.is_some() {
|
||||
log::debug!(
|
||||
"[provider] {} omitting max_output_tokens={:?} for Codex OAuth responses endpoint (unsupported param)",
|
||||
self.name,
|
||||
max_output_tokens,
|
||||
);
|
||||
}
|
||||
let mut request = ResponsesRequest {
|
||||
model: model.to_string(),
|
||||
input,
|
||||
instructions,
|
||||
stream: Some(is_codex_oauth_responses),
|
||||
store: Some(false),
|
||||
max_output_tokens,
|
||||
max_output_tokens: if is_codex_oauth_responses {
|
||||
None
|
||||
} else {
|
||||
max_output_tokens
|
||||
},
|
||||
};
|
||||
|
||||
let url = self.responses_url();
|
||||
let mut retried_without_cap = false;
|
||||
|
||||
let response = self
|
||||
.apply_auth_header(self.http_client().post(&url).json(&request), credential)
|
||||
.send()
|
||||
.await?;
|
||||
let (status, error) = loop {
|
||||
let response = self
|
||||
.apply_auth_header(self.http_client().post(&url).json(&request), credential)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if response.status().is_success() {
|
||||
let body = response.text().await?;
|
||||
if is_codex_oauth_responses {
|
||||
return aggregate_responses_sse_body(&self.name, &body);
|
||||
}
|
||||
let responses = parse_responses_response_body(&self.name, &body)?;
|
||||
return extract_responses_text(responses).ok_or_else(|| {
|
||||
anyhow::anyhow!("No response from {} Responses API", self.name)
|
||||
});
|
||||
}
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let status_str = status.as_u16().to_string();
|
||||
let error = response.text().await?;
|
||||
let sanitized = super::super::sanitize_api_error(&error);
|
||||
// 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)
|
||||
|
||||
// Reactive defense-in-depth: a strict Responses backend may still
|
||||
// reject `max_output_tokens` with a 400 (e.g. a future Codex endpoint
|
||||
// variant we don't match above). Strip the field and retry once,
|
||||
// mirroring the no-tools / frequency_penalty retries. Bounded to a
|
||||
// single retry so a genuinely different 400 still surfaces.
|
||||
if !retried_without_cap
|
||||
&& request.max_output_tokens.is_some()
|
||||
&& status == reqwest::StatusCode::BAD_REQUEST
|
||||
&& Self::err_indicates_max_output_tokens_unsupported(&error)
|
||||
{
|
||||
super::mark_responses_api_unsupported(&self.base_url);
|
||||
log::info!(
|
||||
"[provider] {} rejected max_output_tokens — retrying responses request without it",
|
||||
self.name,
|
||||
);
|
||||
request.max_output_tokens = None;
|
||||
retried_without_cap = true;
|
||||
continue;
|
||||
}
|
||||
if super::super::is_budget_exhausted_http_400(status, &error) {
|
||||
super::super::log_budget_exhausted_http_400(
|
||||
"responses_api",
|
||||
self.name.as_str(),
|
||||
Some(model),
|
||||
status,
|
||||
);
|
||||
} else if super::super::is_custom_openai_upstream_bad_request_http_400(
|
||||
self.name.as_str(),
|
||||
status,
|
||||
&error,
|
||||
) {
|
||||
super::super::log_custom_openai_upstream_bad_request_http_400(
|
||||
"responses_api",
|
||||
self.name.as_str(),
|
||||
Some(model),
|
||||
status,
|
||||
);
|
||||
} else if super::super::is_provider_access_policy_denied_http_403(status, &error) {
|
||||
super::super::log_provider_access_policy_denied_http_403(
|
||||
"responses_api",
|
||||
self.name.as_str(),
|
||||
Some(model),
|
||||
status,
|
||||
);
|
||||
} else if super::super::is_provider_config_rejection_http(
|
||||
status,
|
||||
self.name.as_str(),
|
||||
&error,
|
||||
) {
|
||||
super::super::log_provider_config_rejection(
|
||||
"responses_api",
|
||||
self.name.as_str(),
|
||||
Some(model),
|
||||
status,
|
||||
);
|
||||
} else if super::super::is_byo_provider_auth_failure_http(
|
||||
self.name.as_str(),
|
||||
status,
|
||||
&error,
|
||||
) {
|
||||
super::super::log_byo_provider_auth_failure(
|
||||
"responses_api",
|
||||
self.name.as_str(),
|
||||
Some(model),
|
||||
status,
|
||||
);
|
||||
} else if super::super::is_openai_oauth_session_expired_http(
|
||||
self.name.as_str(),
|
||||
status,
|
||||
&error,
|
||||
) {
|
||||
super::super::log_openai_oauth_session_expired(
|
||||
"responses_api",
|
||||
self.name.as_str(),
|
||||
Some(model),
|
||||
status,
|
||||
);
|
||||
} else if super::super::is_provider_insufficient_credits_402(status, &error) {
|
||||
// Insufficient-credits 402: the user's own BYO provider account
|
||||
// is out of balance — a flat billing fact, not a reservation-
|
||||
// window error, so there is NO local max_tokens lever to apply.
|
||||
// Demote to info instead of paging on every retry; this is the
|
||||
// complete classification for a genuinely-unpreventable
|
||||
// BYO-balance condition
|
||||
// (TAURI-RUST-4QF — DeepSeek "Insufficient Balance").
|
||||
super::super::log_provider_insufficient_credits_402(
|
||||
"responses_api",
|
||||
self.name.as_str(),
|
||||
Some(model),
|
||||
status,
|
||||
);
|
||||
} else if super::super::should_report_provider_http_failure(status) {
|
||||
crate::core::observability::report_error(
|
||||
message.as_str(),
|
||||
"llm_provider",
|
||||
"responses_api",
|
||||
&[
|
||||
("provider", self.name.as_str()),
|
||||
("model", model),
|
||||
("status", status_str.as_str()),
|
||||
("failure", "non_2xx"),
|
||||
],
|
||||
);
|
||||
}
|
||||
anyhow::bail!(message);
|
||||
}
|
||||
|
||||
let body = response.text().await?;
|
||||
if is_codex_oauth_responses {
|
||||
return aggregate_responses_sse_body(&self.name, &body);
|
||||
}
|
||||
let responses = parse_responses_response_body(&self.name, &body)?;
|
||||
break (status, error);
|
||||
};
|
||||
|
||||
extract_responses_text(responses)
|
||||
.ok_or_else(|| anyhow::anyhow!("No response from {} Responses API", self.name))
|
||||
let status_str = status.as_u16().to_string();
|
||||
let sanitized = super::super::sanitize_api_error(&error);
|
||||
// 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",
|
||||
self.name.as_str(),
|
||||
Some(model),
|
||||
status,
|
||||
);
|
||||
} else if super::super::is_custom_openai_upstream_bad_request_http_400(
|
||||
self.name.as_str(),
|
||||
status,
|
||||
&error,
|
||||
) {
|
||||
super::super::log_custom_openai_upstream_bad_request_http_400(
|
||||
"responses_api",
|
||||
self.name.as_str(),
|
||||
Some(model),
|
||||
status,
|
||||
);
|
||||
} else if super::super::is_provider_access_policy_denied_http_403(status, &error) {
|
||||
super::super::log_provider_access_policy_denied_http_403(
|
||||
"responses_api",
|
||||
self.name.as_str(),
|
||||
Some(model),
|
||||
status,
|
||||
);
|
||||
} else if super::super::is_provider_config_rejection_http(
|
||||
status,
|
||||
self.name.as_str(),
|
||||
&error,
|
||||
) {
|
||||
super::super::log_provider_config_rejection(
|
||||
"responses_api",
|
||||
self.name.as_str(),
|
||||
Some(model),
|
||||
status,
|
||||
);
|
||||
} else if super::super::is_byo_provider_auth_failure_http(
|
||||
self.name.as_str(),
|
||||
status,
|
||||
&error,
|
||||
) {
|
||||
super::super::log_byo_provider_auth_failure(
|
||||
"responses_api",
|
||||
self.name.as_str(),
|
||||
Some(model),
|
||||
status,
|
||||
);
|
||||
} else if super::super::is_openai_oauth_session_expired_http(
|
||||
self.name.as_str(),
|
||||
status,
|
||||
&error,
|
||||
) {
|
||||
super::super::log_openai_oauth_session_expired(
|
||||
"responses_api",
|
||||
self.name.as_str(),
|
||||
Some(model),
|
||||
status,
|
||||
);
|
||||
} else if super::super::is_provider_insufficient_credits_402(status, &error) {
|
||||
// Insufficient-credits 402: the user's own BYO provider account
|
||||
// is out of balance — a flat billing fact, not a reservation-
|
||||
// window error, so there is NO local max_tokens lever to apply.
|
||||
// Demote to info instead of paging on every retry; this is the
|
||||
// complete classification for a genuinely-unpreventable
|
||||
// BYO-balance condition
|
||||
// (TAURI-RUST-4QF — DeepSeek "Insufficient Balance").
|
||||
super::super::log_provider_insufficient_credits_402(
|
||||
"responses_api",
|
||||
self.name.as_str(),
|
||||
Some(model),
|
||||
status,
|
||||
);
|
||||
} else if super::super::should_report_provider_http_failure(status) {
|
||||
crate::core::observability::report_error(
|
||||
message.as_str(),
|
||||
"llm_provider",
|
||||
"responses_api",
|
||||
&[
|
||||
("provider", self.name.as_str()),
|
||||
("model", model),
|
||||
("status", status_str.as_str()),
|
||||
("failure", "non_2xx"),
|
||||
],
|
||||
);
|
||||
}
|
||||
anyhow::bail!(message);
|
||||
}
|
||||
|
||||
pub(super) fn convert_tool_specs(
|
||||
@@ -624,6 +666,29 @@ impl OpenAiCompatibleProvider {
|
||||
|| lower.contains("unexpected"))
|
||||
}
|
||||
|
||||
/// Detect a Responses backend rejecting the `max_output_tokens` field as an
|
||||
/// *unrecognized parameter*. The Codex/ChatGPT OAuth endpoint 400s with
|
||||
/// `Unsupported parameter: max_output_tokens` (TAURI-RUST-EWD); when this
|
||||
/// fires the responses path retries once with the field omitted (mirrors the
|
||||
/// no-tools and frequency_penalty retries). String-based because the
|
||||
/// rejection surfaces as the API error body.
|
||||
///
|
||||
/// Deliberately matches only *parameter-not-accepted* wording, **not**
|
||||
/// invalid-value wording (e.g. "value above the allowed range"). Stripping
|
||||
/// the cap turns a bounded request into an uncapped generation, so on a
|
||||
/// value-range error we must surface the config error rather than silently
|
||||
/// drop the credit-preflight / response-size protection `max_tokens` gives.
|
||||
pub(super) fn err_indicates_max_output_tokens_unsupported(error: &str) -> bool {
|
||||
let lower = error.to_lowercase();
|
||||
lower.contains("max_output_tokens")
|
||||
&& (lower.contains("unsupported")
|
||||
|| lower.contains("unknown")
|
||||
|| lower.contains("unrecognized")
|
||||
|| lower.contains("not supported")
|
||||
|| lower.contains("does not support")
|
||||
|| lower.contains("unexpected parameter"))
|
||||
}
|
||||
|
||||
/// 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).
|
||||
|
||||
@@ -885,6 +885,191 @@ async fn responses_api_primary_posts_directly_to_responses() {
|
||||
assert_eq!(text, "hello from responses");
|
||||
}
|
||||
|
||||
/// TAURI-RUST-EWD: the Codex/ChatGPT OAuth Responses endpoint rejects
|
||||
/// `max_output_tokens` (400 `Unsupported parameter: max_output_tokens`). The
|
||||
/// memory-extraction cap (`ChatRequest::max_tokens`) must therefore be dropped
|
||||
/// on the wire for `/backend-api/codex/responses`. The exact `body_json`
|
||||
/// matcher omits the field, so a request that still carried it would fail to
|
||||
/// match the mock and the call would error — i.e. a match positively proves the
|
||||
/// omission.
|
||||
#[tokio::test]
|
||||
async fn codex_responses_omits_max_output_tokens() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/backend-api/codex/responses"))
|
||||
.and(body_json(serde_json::json!({
|
||||
"model": "gpt-5.5",
|
||||
"input": [{
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "hello"}]
|
||||
}],
|
||||
"stream": true,
|
||||
"store": false
|
||||
})))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(
|
||||
"data: {\"type\":\"response.output_text.delta\",\"delta\":\"ok\"}\n\n\
|
||||
data: {\"type\":\"response.completed\",\"response\":{\"output_text\":\"ok\"}}\n\n\
|
||||
data: [DONE]\n\n",
|
||||
))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let provider = OpenAiCompatibleProvider::new(
|
||||
"openai",
|
||||
&format!("{}/backend-api/codex", server.uri()),
|
||||
Some("oauth-access-token"),
|
||||
AuthStyle::Bearer,
|
||||
)
|
||||
.with_responses_api_primary();
|
||||
|
||||
let messages = [ChatMessage::user("hello")];
|
||||
let request = crate::openhuman::inference::provider::traits::ChatRequest {
|
||||
messages: &messages,
|
||||
tools: None,
|
||||
stream: None,
|
||||
// A bounded cap (memory extraction) that the Codex endpoint rejects.
|
||||
max_tokens: Some(256),
|
||||
};
|
||||
let resp = provider.chat(request, "gpt-5.5", 0.0).await.unwrap();
|
||||
assert_eq!(resp.text.as_deref(), Some("ok"));
|
||||
}
|
||||
|
||||
/// A non-Codex Responses backend (`/v1/responses`) must still receive the cap —
|
||||
/// the omission is endpoint-specific, not a blanket drop (guards against
|
||||
/// silently uncapping memory extraction on real `/v1/responses`, TAURI-RUST-C62).
|
||||
#[tokio::test]
|
||||
async fn standard_responses_keeps_max_output_tokens() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/responses"))
|
||||
.and(body_json(serde_json::json!({
|
||||
"model": "gpt-5.4",
|
||||
"input": [{
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "hello"}]
|
||||
}],
|
||||
"stream": false,
|
||||
"store": false,
|
||||
"max_output_tokens": 256
|
||||
})))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_string(r#"{"output_text":"ok","output":[]}"#),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let provider = OpenAiCompatibleProvider::new(
|
||||
"openai",
|
||||
&format!("{}/v1", server.uri()),
|
||||
Some("sk-test"),
|
||||
AuthStyle::Bearer,
|
||||
)
|
||||
.with_responses_api_primary();
|
||||
|
||||
let messages = [ChatMessage::user("hello")];
|
||||
let request = crate::openhuman::inference::provider::traits::ChatRequest {
|
||||
messages: &messages,
|
||||
tools: None,
|
||||
stream: None,
|
||||
max_tokens: Some(256),
|
||||
};
|
||||
let resp = provider.chat(request, "gpt-5.4", 0.0).await.unwrap();
|
||||
assert_eq!(resp.text.as_deref(), Some("ok"));
|
||||
}
|
||||
|
||||
/// The reactive strip-and-retry defends Responses backends we don't match by
|
||||
/// URL: a 400 whose body flags `max_output_tokens` as unsupported triggers one
|
||||
/// retry with the field removed, which then succeeds. Exact-body matchers are
|
||||
/// mutually exclusive on `max_output_tokens`, so the result is deterministic
|
||||
/// regardless of mock precedence.
|
||||
#[tokio::test]
|
||||
async fn responses_strips_max_output_tokens_and_retries_on_400() {
|
||||
let server = MockServer::start().await;
|
||||
let input = serde_json::json!([{
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "hello"}]
|
||||
}]);
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/responses"))
|
||||
.and(body_json(serde_json::json!({
|
||||
"model": "gpt-5.5",
|
||||
"input": input.clone(),
|
||||
"stream": false,
|
||||
"store": false,
|
||||
"max_output_tokens": 256
|
||||
})))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(400)
|
||||
.set_body_string(r#"{"detail":"Unsupported parameter: max_output_tokens"}"#),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/responses"))
|
||||
.and(body_json(serde_json::json!({
|
||||
"model": "gpt-5.5",
|
||||
"input": input.clone(),
|
||||
"stream": false,
|
||||
"store": false
|
||||
})))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(r#"{"output_text":"recovered","output":[]}"#),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let provider = OpenAiCompatibleProvider::new(
|
||||
"strict-responses",
|
||||
&format!("{}/v1", server.uri()),
|
||||
Some("sk-test"),
|
||||
AuthStyle::Bearer,
|
||||
)
|
||||
.with_responses_api_primary();
|
||||
|
||||
let messages = [ChatMessage::user("hello")];
|
||||
let request = crate::openhuman::inference::provider::traits::ChatRequest {
|
||||
messages: &messages,
|
||||
tools: None,
|
||||
stream: None,
|
||||
max_tokens: Some(256),
|
||||
};
|
||||
let resp = provider.chat(request, "gpt-5.5", 0.0).await.unwrap();
|
||||
assert_eq!(resp.text.as_deref(), Some("recovered"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn err_indicates_max_output_tokens_unsupported_matches_codex_body() {
|
||||
assert!(
|
||||
OpenAiCompatibleProvider::err_indicates_max_output_tokens_unsupported(
|
||||
r#"{"detail":"Unsupported parameter: max_output_tokens"}"#
|
||||
)
|
||||
);
|
||||
assert!(
|
||||
!OpenAiCompatibleProvider::err_indicates_max_output_tokens_unsupported(
|
||||
r#"{"detail":"rate limit exceeded"}"#
|
||||
)
|
||||
);
|
||||
// A different unsupported parameter must not trip the max_output_tokens strip.
|
||||
assert!(
|
||||
!OpenAiCompatibleProvider::err_indicates_max_output_tokens_unsupported(
|
||||
r#"{"detail":"Unsupported parameter: temperature"}"#
|
||||
)
|
||||
);
|
||||
// An invalid-*value* error (cap out of the model's allowed range) must NOT
|
||||
// strip the cap — that would silently uncap the request. Surface it instead.
|
||||
assert!(
|
||||
!OpenAiCompatibleProvider::err_indicates_max_output_tokens_unsupported(
|
||||
r#"{"detail":"Invalid value for max_output_tokens: must be <= 4096"}"#
|
||||
)
|
||||
);
|
||||
assert!(
|
||||
!OpenAiCompatibleProvider::err_indicates_max_output_tokens_unsupported(
|
||||
r#"{"detail":"max_output_tokens exceeds the maximum allowed for this model"}"#
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_required_key_counts_as_missing() {
|
||||
let p = OpenAiCompatibleProvider::new(
|
||||
|
||||
Reference in New Issue
Block a user