mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-13 08:06:41 +00:00
fix(openai): omit output token limits from Responses requests
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -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
@@ -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` 时,还会移除非空数组
|
||||
|
||||
Reference in New Issue
Block a user