fix(openai): normalize system messages for Codex (#172)

Normalize explicit system messages to developer at the shared Codex request boundary while preserving message content and other fields. Cover HTTP and WebSocket request bodies and document the compatibility behavior.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
helen
2026-09-19 22:43:49 +08:00
committed by GitHub
co-authored by Codex
parent a1ba6c0b61
commit a18f0a2e11
3 changed files with 101 additions and 7 deletions
@@ -2,7 +2,7 @@
use serde_json::{Map, Value};
/// 补齐 Codex 请求缺省字段并移除已确认不适用的顶层参数,不递归清洗业务正文。
/// 补齐 Codex 请求缺省字段并适配已确认不兼容的请求形状,不递归清洗业务正文。
///
/// 兼容基准是 Codex Core/Desktop 的模型请求,不是公开 OpenAI Responses API。
/// 未知字段继续透传,不能因官方请求结构中没有某个字段就将其列入过滤规则。
@@ -10,6 +10,21 @@ pub(in crate::transport) fn normalize_codex_request_body(body: &mut Map<String,
// 官方 Core/Desktop 显式发送 store=false;仅为缺字段的下游请求补齐,保留显式值。
body.entry("store").or_insert(Value::Bool(false));
// Codex 上游拒绝显式 message 的 system role;沿用官方客户端的 developer
// role 承载指令,只转换已确认的消息形状,保留内容与其他字段。
if let Some(input) = body.get_mut("input").and_then(Value::as_array_mut) {
for item in input {
let Some(item) = item.as_object_mut() else {
continue;
};
if item.get("type").and_then(Value::as_str) == Some("message")
&& item.get("role").and_then(Value::as_str) == Some("system")
{
item.insert("role".to_owned(), Value::String("developer".to_owned()));
}
}
}
for field in [
// Pi 普通 Responses 适配将 maxTokens 映射为 max_output_tokens,
// temperature 则原样写入;Pi 的 Codex 适配也可能发送 temperature。
@@ -87,8 +87,72 @@ fn encoder_should_preserve_explicit_store_values() {
}
}
#[test]
fn encoder_should_convert_system_messages_to_developer_without_changing_content() {
let body = json!({
"model": "client-model",
"instructions": "Keep the existing instructions.",
"input": [
{
"type": "message",
"role": "system",
"content": "You are a pirate.",
"future_item_field": {"keep": true}
},
{
"type": "message",
"role": "system",
"content": [{"type": "input_text", "text": "Be concise.", "role": "system"}]
},
{
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "Say hello."}]
},
{"type": "message", "role": "developer", "content": "Existing guidance."},
{"role": "system", "content": "Shorthand message."},
{"type": "computer_screenshot", "role": "system"},
"opaque-item"
],
"future_top_level_field": {"keep": true}
});
let encoded = encode_downstream_request(body);
assert_eq!(
Value::Object(encoded.body().clone()),
json!({
"model": "gpt-test",
"instructions": "Keep the existing instructions.",
"input": [
{
"type": "message",
"role": "developer",
"content": "You are a pirate.",
"future_item_field": {"keep": true}
},
{
"type": "message",
"role": "developer",
"content": [{"type": "input_text", "text": "Be concise.", "role": "system"}]
},
{
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "Say hello."}]
},
{"type": "message", "role": "developer", "content": "Existing guidance."},
{"role": "system", "content": "Shorthand message."},
{"type": "computer_screenshot", "role": "system"},
"opaque-item"
],
"store": false,
"future_top_level_field": {"keep": true}
})
);
}
#[tokio::test]
async fn backend_http_should_send_default_store_for_downstream_request() {
async fn backend_http_should_send_default_store_and_normalized_system_role() {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("bind HTTP server");
@@ -105,7 +169,10 @@ async fn backend_http_should_send_default_store_for_downstream_request() {
let body = zstd::stream::decode_all(&raw[head_end..]).expect("decode request body");
serde_json::from_slice::<Value>(&body).expect("HTTP request JSON")
});
let mut request = encode_downstream_request(json!({"model": "gpt-test", "input": "hello"}));
let mut request = encode_downstream_request(json!({
"model": "gpt-test",
"input": [{"type": "message", "role": "system", "content": "Be concise."}]
}));
request.force_http_sse = true;
let client = CodexBackendClient::new(
reqwest::Client::builder()
@@ -127,13 +194,16 @@ async fn backend_http_should_send_default_store_for_downstream_request() {
assert_eq!(
server.await.expect("HTTP server task"),
json!({
"model": "gpt-test", "input": "hello", "store": false, "stream": true
"model": "gpt-test",
"input": [{"type": "message", "role": "developer", "content": "Be concise."}],
"store": false,
"stream": true
})
);
}
#[tokio::test]
async fn backend_websocket_should_send_default_store_for_downstream_request() {
async fn backend_websocket_should_send_default_store_and_normalized_system_role() {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("bind WebSocket server");
@@ -156,7 +226,10 @@ async fn backend_websocket_should_send_default_store_for_downstream_request() {
.expect("send terminal event");
body
});
let mut request = encode_downstream_request(json!({"model": "gpt-test", "input": "hello"}));
let mut request = encode_downstream_request(json!({
"model": "gpt-test",
"input": [{"type": "message", "role": "system", "content": "Be concise."}]
}));
request.use_websocket = true;
let client = CodexBackendClient::new(
reqwest::Client::builder()
@@ -178,6 +251,10 @@ async fn backend_websocket_should_send_default_store_for_downstream_request() {
let body = server.await.expect("WebSocket server task");
assert_eq!(body.get("store"), Some(&json!(false)));
assert_eq!(
body["input"],
json!([{"type": "message", "role": "developer", "content": "Be concise."}])
);
}
fn encode_downstream_request(body: Value) -> CodexResponsesRequest {
+3 -1
View File
@@ -190,7 +190,9 @@ Responses 也不透传 `x-stainless-*`、`Origin`、`Referer`、`sec-ch-ua*` 和
Responses 上游编码会移除 Codex 不接受的顶层 `temperature`、`max_output_tokens` 和
`prompt_cache_retention`。缺少顶层 `store` 时补齐 `false`,与官方 Codex 客户端一致;
显式提供的值保持原样。HTTP/SSE 与 WebSocket 共用这条正文兼容规则。
显式提供的值保持原样。`input` 数组中显式指定 `type: "message"` 且 `role: "system"` 的消息,
其角色转换为 Codex 接受的 `developer`;消息内容、顺序、其他字段和顶层 `instructions` 保持不变。
HTTP/SSE 与 WebSocket 共用这条正文兼容规则。
`prompt_cache_key`、`reasoning`、`include` 等 Codex 参数继续保留。过滤只作用于顶层,
不删除工具参数 schema、输入内容或 `client_metadata` 内的同名业务字段;其他未知字段继续透传。