From 75be1287ef77a3e2947ae4d8d552a0af1ca28c5e Mon Sep 17 00:00:00 2001 From: Zyy Date: Thu, 12 Nov 2026 10:24:27 +0800 Subject: [PATCH] fix(openai): omit output token limits from Responses requests Co-authored-by: Codex --- .../providers/openai/src/compat/request.rs | 22 +++++----- .../providers/openai/tests/compat/request.rs | 2 - .../tests/provider/contract/messages.rs | 1 + .../tests/provider/contract/passthrough.rs | 41 ++++++++----------- docs/api.md | 10 ++--- 5 files changed, 33 insertions(+), 43 deletions(-) diff --git a/backend/crates/providers/openai/src/compat/request.rs b/backend/crates/providers/openai/src/compat/request.rs index 48f87f45f..8d40b3e74 100644 --- a/backend/crates/providers/openai/src/compat/request.rs +++ b/backend/crates/providers/openai/src/compat/request.rs @@ -72,15 +72,6 @@ fn normalize_codex_oauth_options(body: &mut Map) { { body.shift_remove("prompt_cache_options"); } - // 原生上限优先,Chat 别名只补缺,非法值仍交给上游校验 - for alias in ["max_completion_tokens", "max_tokens"] { - if let Some(limit) = body.shift_remove(alias) - && !limit.is_null() - && body.get("max_output_tokens").is_none_or(Value::is_null) - { - body.insert("max_output_tokens".to_owned(), limit); - } - } } /// 补齐 Codex 请求缺省字段并适配已确认不兼容的请求形状,不递归清洗业务正文 @@ -121,8 +112,15 @@ pub(crate) fn normalize_codex_request_body(body: &mut Map) { } } - // Codex 拒绝显式采样温度和缓存保留时长,独立的缓存键仍须保留 - for field in ["temperature", "prompt_cache_retention"] { - body.remove(field); + // Responses 上游统一忽略客户端输出上限,避免请求因上限低于模型下限被拒 + // Codex 也不接受显式采样温度和缓存保留时长 + for field in [ + "max_output_tokens", + "max_completion_tokens", + "max_tokens", + "temperature", + "prompt_cache_retention", + ] { + body.shift_remove(field); } } diff --git a/backend/crates/providers/openai/tests/compat/request.rs b/backend/crates/providers/openai/tests/compat/request.rs index 0a95492f1..1ca8ae252 100644 --- a/backend/crates/providers/openai/tests/compat/request.rs +++ b/backend/crates/providers/openai/tests/compat/request.rs @@ -69,7 +69,6 @@ fn encoder_should_adapt_pi_responses_parameters_without_losing_codex_fields() { "stream": true, "store": false, "prompt_cache_key": "client-session", - "max_output_tokens": 512, "reasoning": {"effort": "high", "summary": "auto"}, "include": ["reasoning.encrypted_content"] }) @@ -126,7 +125,6 @@ fn encoder_should_default_missing_store_without_changing_unknown_or_nested_field "input": [{"role": "user", "content": [{ "type": "input_text", "text": "temperature and max_output_tokens are tool parameter names" }]}], - "max_tokens": 256, "future_options": {"temperature": 0.7}, "client_metadata": {"prompt_cache_retention": "business-value", "store": true}, "tools": [{ diff --git a/backend/crates/providers/openai/tests/provider/contract/messages.rs b/backend/crates/providers/openai/tests/provider/contract/messages.rs index 669c098ed..cd05baa56 100644 --- a/backend/crates/providers/openai/tests/provider/contract/messages.rs +++ b/backend/crates/providers/openai/tests/provider/contract/messages.rs @@ -294,6 +294,7 @@ async fn messages_and_responses_translate_with_target_header_boundaries() { } else { let body: Value = serde_json::from_slice(&requests[0].body).unwrap(); assert_eq!(body["reasoning"]["effort"], "xhigh"); + assert!(body.get("max_output_tokens").is_none()); for name in [ "anthropic-version", "anthropic-beta", diff --git a/backend/crates/providers/openai/tests/provider/contract/passthrough.rs b/backend/crates/providers/openai/tests/provider/contract/passthrough.rs index 74cf2655b..9b14443fc 100644 --- a/backend/crates/providers/openai/tests/provider/contract/passthrough.rs +++ b/backend/crates/providers/openai/tests/provider/contract/passthrough.rs @@ -11,7 +11,8 @@ async fn responses_options_follow_the_selected_upstream_on_both_transports() { "truncation": "disabled", "stop_sequences": [], "prompt_cache_options": {} }); let mut body = json!({ - "model": "gpt-5.4", "input": "hello", "max_tokens": 16, + "model": "gpt-5.4", "input": "hello", + "max_output_tokens": 1, "max_completion_tokens": 32, "max_tokens": 64, "stream_options": {"include_usage": true, "reasoning_summary_delivery": "sequential_cutoff"}, "client_metadata": {"user": "keep"}, "tools": [{"type": "function", "name": "sample", "parameters": { @@ -30,17 +31,19 @@ async fn responses_options_follow_the_selected_upstream_on_both_transports() { "{field}, api_key={api_key}, websocket={websocket}" ); } + for field in ["max_output_tokens", "max_completion_tokens", "max_tokens"] { + assert!( + sent.get(field).is_none(), + "{field}, api_key={api_key}, websocket={websocket}" + ); + } if api_key { assert_eq!(sent["stream_options"], body["stream_options"]); - assert_eq!(sent["max_tokens"], 16); - assert!(sent.get("max_output_tokens").is_none()); } else { assert_eq!( sent["stream_options"], json!({"reasoning_summary_delivery": "sequential_cutoff"}) ); - assert_eq!(sent["max_output_tokens"], 16); - assert!(sent.get("max_tokens").is_none()); } assert_eq!(sent["client_metadata"]["user"], "keep"); assert_eq!(sent["tools"], body["tools"]); @@ -49,25 +52,15 @@ async fn responses_options_follow_the_selected_upstream_on_both_transports() { } #[tokio::test] -async fn codex_oauth_keeps_explicit_limits_and_context_options() { - for (native, completion, expected) in [ - (json!(16), json!(32), json!(16)), - (Value::Null, json!(32), json!(32)), - (Value::Null, Value::Null, json!(64)), - ] { - let body = json!({ - "model": "gpt-5.4", "input": "hello", "max_output_tokens": native, - "max_completion_tokens": completion, "max_tokens": 64, - "truncation": "auto", "stop_sequences": ["end"], - "prompt_cache_options": {"future_option": true} - }); - let sent = capture_downstream_request(body.clone(), false, false, false).await; - assert_eq!(sent["max_output_tokens"], expected); - assert!(sent.get("max_completion_tokens").is_none()); - assert!(sent.get("max_tokens").is_none()); - for key in ["truncation", "stop_sequences", "prompt_cache_options"] { - assert_eq!(sent[key], body[key]); - } +async fn codex_oauth_keeps_explicit_context_options() { + let body = json!({ + "model": "gpt-5.4", "input": "hello", + "truncation": "auto", "stop_sequences": ["end"], + "prompt_cache_options": {"future_option": true} + }); + let sent = capture_downstream_request(body.clone(), false, false, false).await; + for key in ["truncation", "stop_sequences", "prompt_cache_options"] { + assert_eq!(sent[key], body[key]); } } diff --git a/docs/api.md b/docs/api.md index 98582acfc..d7f8e03dd 100644 --- a/docs/api.md +++ b/docs/api.md @@ -343,12 +343,12 @@ Chat 沿用 [Client Key 鉴权](#数据面客户端接口)、[请求期限](#请 [请求头与身份](#请求头与身份)及[隐私策略](#隐私策略)。隐私策略的 `request_body` 面向目标上游正文, Responses 上游使用 `$.input`,Messages 上游使用 `$.messages` -转换后的请求按选中的 Provider 执行。OpenAI 的字段处理见[正文兼容](#正文兼容); -输出上限原值交给上游校验,低于模型下限时会收到上游错误 +转换后的请求按选中的 Provider 执行。OpenAI Responses 上游忽略客户端的输出上限,见[正文兼容](#正文兼容) ### Messages 请求 `POST /v1/messages` 接受非空字符串 `model`、非空 `messages` 数组和非负整数 `max_tokens`。 +转向 OpenAI Responses 上游时,`max_tokens` 仍为入站必填字段,但不限制实际输出。 消息角色为 `user`、`assistant` 或 `system`,`content` 为字符串或内容块数组;初始系统指令放在顶层 `system`, 会话中的 `system` 消息保留其位置,具体模型支持情况由上游校验。 `stream` 缺省或 `false` 返回 JSON,`true` 返回 SSE。每轮由客户端携带所需的完整上下文。Claude Code 用 `--resume` 恢复本地会话后,仍通过 `messages` 发送上下文;网关不缓存对话正文用于下一轮。 @@ -538,8 +538,9 @@ Images 的 `x-codex-image-turn-id` 和 Desktop 自动 Git 上下文的 `clientUs #### 正文兼容 -OpenAI Provider 的 Responses 上游编码会移除 Codex 不接受的顶层 `temperature` 和 `prompt_cache_retention`, -并保留调用方指定的 `max_output_tokens`。缺少顶层 `store` 时补齐 `false`,与官方 Codex 客户端一致; +发往 OpenAI Responses 上游(OAuth 或 API Key)的生成请求会省略顶层 `max_output_tokens`、`max_completion_tokens` +和 `max_tokens`,输出可能超过客户端指定的上限。编码时也会移除 Codex 不接受的顶层 `temperature` +和 `prompt_cache_retention`。缺少顶层 `store` 时补齐 `false`,与官方 Codex 客户端一致; 显式提供的值保持原样。顶层 `input` 为字符串时按公开 Responses API 的语义展开为一条 `user` 文本消息条目(`{"type": "message", "role": "user", "content": [{"type": "input_text", ...}]}`), 因为 Codex 后端只接受条目数组;数组及其他类型原样透传。`input` 数组中显式指定 `type: "message"` @@ -556,7 +557,6 @@ OpenAI Provider 的 Responses 上游编码会移除 Codex 不接受的顶层 `te | `top_p`、`frequency_penalty`、`presence_penalty`、`chat_template_kwargs`、`user`、`metadata`、`safety_identifier` | 省略;采样参数取默认值时也省略 | | `stream_options.include_usage` | 省略该成员,保留其他流式选项 | | `truncation`、`stop_sequences`、`prompt_cache_options` | 分别仅在值为 `"disabled"`、`[]`、`{}` 时省略;显式策略交给上游校验 | -| `max_completion_tokens`、`max_tokens` | 转为 `max_output_tokens`。原生字段优先,其次按左列顺序取首个非 `null` 值 | Codex/OAuth 上游的历史回填按字段形状兼容,不以 User-Agent 品牌区分:显式 `type: "reasoning"` 的 `input` 项移除顶层 `status`;该项具有非空字符串 `encrypted_content` 时,还会移除非空数组