fix(openai): normalize rejected reasoning replay fields

Refs #194, #200.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-09-20 23:09:23 +08:00
committed by GitHub
co-authored by Codex
parent d76bb57b8c
commit dc0cdf2930
7 changed files with 263 additions and 4 deletions
@@ -92,7 +92,7 @@ use crate::transport::{
CodexBackendJsonResponse, CodexBackendStreamingResponse, CodexBackendTransport,
CodexClientError, CodexRateLimitUpdates, CodexRequestContext, CodexResponseMetadata,
CodexResponseMetadataUpdates, CodexTransportMetrics, CodexUpstreamDiagnostics,
CodexWebSocketPool, endpoint_url,
CodexWebSocketPool, endpoint_url, normalize_non_codex_request_body,
};
mod execution;
@@ -549,6 +549,12 @@ impl Provider for CodexProvider {
lease.installation_id(),
account_scope,
);
if matches!(
lease.authentication(),
crate::credential::CodexRuntimeAuthentication::OAuth(_)
) {
normalize_non_codex_request_body(upstream_request.body_mut());
}
// 每次执行从原始请求编码,选定出口后再覆盖,避免换号时携带上次位置。
if let Some(location) = lease
.account()
@@ -2,6 +2,37 @@
use serde_json::{Map, Value, json};
/// 非官方客户端及跨客户端历史回填的兼容入口;调用方必须已选定 Codex/OAuth 上游。
/// 只处理已验证被拒绝的字段,不根据客户端品牌推断整份历史是否合法。
pub(crate) fn normalize_non_codex_request_body(body: &mut Map<String, Value>) {
let Some(input) = body.get_mut("input").and_then(Value::as_array_mut) else {
return;
};
for item in input {
let Some(item) = item.as_object_mut() else {
continue;
};
if item.get("type").and_then(Value::as_str) != Some("reasoning") {
continue;
}
// SDK 输出项的 status 不属于 Codex reasoning 输入合同;其他项的 status 可能合法。
item.shift_remove("status");
// Codex 只接受空 content 数组;仅在加密历史仍可回填时去掉冗余明文,
// 没有加密内容的历史不自动丢弃,保留给上游明确拒绝。
if item
.get("encrypted_content")
.and_then(Value::as_str)
.is_some_and(|content| !content.trim().is_empty())
&& item
.get("content")
.and_then(Value::as_array)
.is_some_and(|content| !content.is_empty())
{
item.shift_remove("content");
}
}
}
/// 补齐 Codex 请求缺省字段并适配已确认不兼容的请求形状,不递归清洗业务正文。
///
/// 兼容基准是 Codex Core/Desktop 的模型请求,不是公开 OpenAI Responses API。
@@ -5,4 +5,5 @@ mod body;
mod headers;
pub(super) use body::normalize_codex_request_body;
pub(crate) use body::normalize_non_codex_request_body;
pub(super) use headers::is_non_codex_request_header;
@@ -19,6 +19,7 @@ mod response_meta;
pub(crate) mod session;
pub mod subscription;
mod time;
pub(crate) use downstream::normalize_non_codex_request_body;
pub(crate) use endpoints::valid_upstream_base_url;
pub mod tls;
pub mod usage;
@@ -267,6 +267,220 @@ async fn selected_proxy_location_overrides_global_and_reloads_without_mutating_c
const OFFICIAL_FIXTURE: &[u8] =
include_bytes!("../transport/fixtures/official_models_snapshot.json");
#[tokio::test]
async fn replay_compatibility_should_remove_only_reasoning_status_on_both_transports() {
let input = json!([
{"type":"message","role":"user","status":"completed","content":[{"type":"input_text","text":"hello"}]},
{"type":"reasoning","id":"rs_replay","status":"completed","summary":[],"content":[],"encrypted_content":"test-cipher","extension":{"status":"keep","content":[1]}},
{"type":"function_call","call_id":"call_replay","name":"echo","arguments":"{}","status":"completed"},
{"type":"function_call_output","call_id":"call_replay","output":{"status":"keep","content":[1]},"status":"completed"},
{"type":"tool_search_output","call_id":"call_search","status":"completed","tools":[]},
{"type":"reasoning","status":"in_progress","summary":[],"encrypted_content":"second-test-cipher"},
{"type":"future_item","status":"keep","content":[1]},
"opaque-item"
]);
let mut expected = input.clone();
for index in [1, 5] {
expected[index]
.as_object_mut()
.unwrap()
.shift_remove("status");
}
for websocket in [false, true] {
let actual = capture_replay_compatibility_request(input.clone(), false, websocket).await;
// 比较序列化结果,同时保护未修改字段的顺序。
assert_eq!(actual["input"].to_string(), expected.to_string());
}
}
#[tokio::test]
async fn replay_compatibility_should_keep_encrypted_history_when_removing_nonempty_content() {
let input = json!([
{"type":"reasoning","id":"rs_replay","summary":[{"type":"summary_text","text":"keep summary"}],"content":[{"type":"reasoning_text","text":"synthetic replay"}],"encrypted_content":"test-cipher","extension":9007199254740993_u64},
{"type":"compaction","id":"cmp_replay","content":[{"type":"text","text":"keep compaction"}],"encrypted_content":"compaction-test-cipher"},
{"type":"message","role":"assistant","content":[{"type":"output_text","text":"keep answer"}]}
]);
let mut expected = input.clone();
expected[0].as_object_mut().unwrap().shift_remove("content");
for websocket in [false, true] {
let actual = capture_replay_compatibility_request(input.clone(), false, websocket).await;
assert_eq!(actual["input"].to_string(), expected.to_string());
}
}
#[tokio::test]
async fn replay_compatibility_should_preserve_plaintext_only_and_unrecognized_content_shapes() {
let input = json!([
{"type":"reasoning","summary":[],"content":[{"type":"reasoning_text","text":"only history"}]},
{"type":"reasoning","summary":[],"content":[1],"encrypted_content":""},
{"type":"reasoning","summary":[],"content":[1],"encrypted_content":" "},
{"type":"reasoning","summary":[],"content":[1],"encrypted_content":null},
{"type":"reasoning","summary":[],"content":[1],"encrypted_content":42},
{"type":"reasoning","summary":[],"content":{"status":"keep"},"encrypted_content":"test-cipher"},
{"type":"reasoning","summary":[],"content":"keep","encrypted_content":"test-cipher"}
]);
for websocket in [false, true] {
let actual = capture_replay_compatibility_request(input.clone(), false, websocket).await;
assert_eq!(actual["input"].to_string(), input.to_string());
}
}
#[tokio::test]
async fn replay_compatibility_should_leave_normal_official_history_unchanged() {
let input = json!([
{"type":"reasoning","id":"rs_empty","summary":[],"content":[],"encrypted_content":"test-cipher"},
{"type":"reasoning","id":"rs_null","summary":[],"content":null,"encrypted_content":"test-cipher"},
{"type":"reasoning","id":"rs_absent","summary":[],"encrypted_content":"test-cipher"},
{"type":"message","role":"user","content":[{"type":"input_text","text":"do not change status/content"}]},
{"type":"custom_tool_call","call_id":"call_custom","status":"completed","name":"custom","input":"keep"},
{"type":"tool_search_call","call_id":"call_search","status":"completed","arguments":{}},
{"type":"mcp_call","status":"completed","output":"keep"},
{"summary":[],"content":[1],"status":"keep"},
{"type":"future_item","content":[1],"status":"keep"},
null,
"opaque-item"
]);
for websocket in [false, true] {
let actual = capture_replay_compatibility_request(input.clone(), false, websocket).await;
assert_eq!(actual["input"].to_string(), input.to_string());
}
}
#[tokio::test]
async fn replay_compatibility_should_not_apply_codex_rules_to_api_key_accounts() {
let input = json!([{
"type":"reasoning","id":"rs_api","status":"completed","summary":[],
"content":[{"type":"reasoning_text","text":"API-specific history"}],
"encrypted_content":"test-cipher"
}]);
for websocket in [false, true] {
let actual = capture_replay_compatibility_request(input.clone(), true, websocket).await;
assert_eq!(actual["input"].to_string(), input.to_string());
}
}
#[tokio::test]
async fn replay_compatibility_should_preserve_non_array_input_without_guessing() {
for input in [
Value::Null,
json!({"type":"reasoning","status":"keep"}),
json!(7),
] {
let actual = capture_replay_compatibility_request(input.clone(), false, false).await;
assert_eq!(actual["input"], input);
}
}
async fn capture_replay_compatibility_request(
input: Value,
api_key: bool,
websocket: bool,
) -> Value {
let http = MockServer::start().await;
let (base_url, websocket_server) = if websocket {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let base_url = format!("http://{}", listener.local_addr().unwrap());
let task =
tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut websocket = accept_codex_test_websocket(stream).await;
let frame = websocket.next().await.unwrap().unwrap();
let body: Value = serde_json::from_str(frame.to_text().unwrap()).unwrap();
websocket.send(Message::Text(json!({
"type":"response.completed","response":{
"id":"resp_replay","model":"gpt-5.4","status":"completed","output":[],
"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}
}
}).to_string().into())).await.unwrap();
body
});
(base_url, Some(task))
} else {
Mock::given(method("POST"))
.and(path(if api_key {
"/responses"
} else {
"/codex/responses"
}))
.respond_with(
ResponseTemplate::new(200).set_body_raw(CAPTURE_COMPLETED_SSE, "text/event-stream"),
)
.expect(1)
.mount(&http)
.await;
(http.uri(), None)
};
let store = Arc::new(MemoryAccountStore::default());
if api_key {
store
.seed_api_key(
"acct_provider_contract",
base_url.clone(),
if websocket {
provider_openai::credential::ApiKeyTransport::PreferWebsocket
} else {
provider_openai::credential::ApiKeyTransport::Http
},
)
.await;
} else {
create_account(&store, "acct_provider_contract").await;
}
let original = json!({
"model":"gpt-5.4","store":false,"stream":true,"input":input,
"instructions":"Preserve the original instructions.",
"tools":[{"type":"function","name":"echo","parameters":{"type":"object","properties":{"status":{"type":"string"},"content":{"type":"array"}}}}],
"future_field":{"status":"keep","content":[1]},
"client_metadata":{"extension":{"status":"keep","content":[1]}}
});
let operation = Operation::Generate(GenerateRequest::from_protocol_payload(
ProtocolPayload::json_object("openai", original.as_object().unwrap().clone())
.unwrap()
.with_context(Map::from_iter([(
"use_websocket".to_owned(),
json!(websocket),
)])),
));
let provider = provider_with_base_url(&store, base_url);
let mut stream = provider
.execute(
planned_request("openai", operation),
context("req_replay_compatibility", CancellationToken::new()),
)
.await
.unwrap();
while let Some(event) = stream.next().await {
event.expect("successful upstream completion");
}
let actual = if let Some(server) = websocket_server {
timeout(Duration::from_secs(5), server)
.await
.unwrap()
.unwrap()
} else {
let requests = http.received_requests().await.unwrap();
assert_eq!(
requests.len(),
1,
"compatibility must run before the first send, without retry"
);
captured_request_body(&requests[0])
};
for field in ["model", "store", "instructions", "tools", "future_field"] {
assert_eq!(
actual[field].to_string(),
original[field].to_string(),
"unexpected change in {field}"
);
}
assert_eq!(
actual["client_metadata"]["extension"],
original["client_metadata"]["extension"]
);
actual
}
const CAPTURE_COMPLETED_SSE: &str = concat!(
"event: response.completed\n",
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_scope_capture\",\"model\":\"gpt-5.4\",\"status\":\"completed\",\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":1,\"total_tokens\":2}}}\n\n"
+6 -1
View File
@@ -195,9 +195,14 @@ Responses 上游编码会移除 Codex 不接受的顶层 `temperature`、`max_ou
因为 Codex 后端只接受条目数组;数组及其他类型原样透传。`input` 数组中显式指定 `type: "message"`
且 `role: "system"` 的消息,其角色转换为 Codex 接受的 `developer`;消息内容、顺序、其他字段和顶层
`instructions` 保持不变。HTTP/SSE 与 WebSocket 共用这条正文兼容规则。
`prompt_cache_key`、`reasoning`、`include` 等 Codex 参数继续保留。过滤只作用于顶层,
`prompt_cache_key`、`reasoning`、`include` 等 Codex 参数继续保留。上述参数过滤只作用于顶层,
不删除工具参数 schema、输入内容或 `client_metadata` 内的同名业务字段;其他未知字段继续透传。
Codex/OAuth 上游的历史回填按字段形状兼容,不以 User-Agent 品牌区分:显式 `type: "reasoning"`
的 `input` 项移除顶层 `status`;该项具有非空字符串 `encrypted_content` 时,还会移除非空数组
`content`。其他字段及顺序保持不变,缺少非空加密载荷的明文历史由上游判定。
普通消息、工具项及未知类型不受此规则影响,API Key 上游不应用此规则。
请求头过滤不提供客户端匿名化;系统提示词、工具定义、工具结果、工作目录及其他业务 metadata
保持原有语义,可能包含客户端环境信息。
+3 -2
View File
@@ -207,7 +207,7 @@ OpenAI 的 OAuth 与 API Key 共用现有账号和事务。API Key 的 Base URL
通过通用画像输出客户端目录,OAuth 原生对象保留。通用账号层按 Provider 提交的 credential state 调度,不以是否存在
上游用户 ID 推断可用性;OAuth 未完成身份投影时由 Provider 保持 `unknown`。状态恢复和未补齐身份的凭据轮换保留 `unknown`。
API Key 默认 HTTP/SSE,可选 WS 优先;选号先验证传输资格,WS pool 与 continuation 按凭据版本隔离。
OAuth 与 API Key 共用业务请求、响应和能力透传链路,差异限定在上游地址、认证与传输配置。
OAuth 与 API Key 共用业务请求、响应和能力透传链路,差异限定在上游地址、认证、传输配置及明确的上游请求合同适配。
OpenAI 模型目录用于发现,不因目录缺项拒绝请求;管理员配置的模型权限仍由 Core 与选号链路执行。
- OpenAI 是透明边界。Responses 请求保留未知字段和字段顺序;SSE、WebSocket、Images 与 standalone
@@ -219,7 +219,8 @@ OpenAI 模型目录用于发现,不因目录缺项拒绝请求;管理员配
`body.rs` 管理已知顶层参数的过滤、缺省值补齐和已确认不兼容的 `input` 形状适配;兼容基准为 Codex Core/Desktop 请求协议,
不持有账号身份保护或会话规范化逻辑。
Provider 在 `transport/request.rs` 解码不透明头时组合兼容、身份与 HTTP 规则,
同时调用正文兼容规则;HTTP/SSE 与 WebSocket 共用此边界。
同时调用不依赖账号的正文兼容规则。`body.rs` 中非官方客户端及跨客户端历史回填兼容的入口,
由 Provider 在选定 Codex/OAuth 账号后、HTTP/WS 分流前调用;API Key 上游跳过该入口。
`transport/headers.rs` 负责上游身份保护和官方头组装。
会话别名只规范化请求头,不清除正文身份字段;未知业务扩展与响应诊断头不受影响,字段见
[Responses 合同](api.md#3-openai-数据面与模型目录)。提示词、工具及业务正文不做客户端品牌清洗。