mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
fix(openai): normalize rejected reasoning replay fields
Refs #194, #200. Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -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
@@ -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
|
||||
保持原有语义,可能包含客户端环境信息。
|
||||
|
||||
|
||||
@@ -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-数据面与模型目录)。提示词、工具及业务正文不做客户端品牌清洗。
|
||||
|
||||
Reference in New Issue
Block a user