fix(openai): omit output token limits from Responses requests

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-11-12 10:24:27 +08:00
co-authored by Codex
parent dad91fe9f7
commit 75be1287ef
5 changed files with 33 additions and 43 deletions
@@ -72,15 +72,6 @@ fn normalize_codex_oauth_options(body: &mut Map<String, Value>) {
{
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<String, Value>) {
}
}
// 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);
}
}
@@ -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": [{
@@ -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",
@@ -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]);
}
}
+5 -5
View File
@@ -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` 时,还会移除非空数组