mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
fix(openai): preserve ASCII encoding in scoped turn metadata
Keep turn metadata ASCII after account scoping by escaping Unicode as UTF-16 code units. Preserve metadata values and Unicode request input across HTTP headers, body aliases and WebSocket client metadata. Cover Unicode workspace paths, emoji, escaped literals and account changes with HTTP and WebSocket regression tests. Validation: provider-openai tests (528 passed) and workspace rustfmt. Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -1,9 +1,12 @@
|
||||
//! 核心 Generate operation 到 Codex Responses wire request 的严格编码。
|
||||
|
||||
use std::io;
|
||||
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
use gateway_core::operation::GenerateRequest;
|
||||
use gateway_protocol::openai::WS_REQUEST_HEADER_RESPONSES_LITE_CLIENT_METADATA_KEY;
|
||||
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
|
||||
use serde::Serialize as _;
|
||||
use serde_json::{Map, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
@@ -447,7 +450,8 @@ pub(crate) fn scope_turn_metadata(
|
||||
changed |= metadata.remove(*key).is_some();
|
||||
}
|
||||
}
|
||||
if !cross_account
|
||||
if raw.is_ascii()
|
||||
&& !cross_account
|
||||
&& !changed
|
||||
&& !INSTALLATION_ID_KEYS
|
||||
.iter()
|
||||
@@ -460,7 +464,40 @@ pub(crate) fn scope_turn_metadata(
|
||||
metadata.insert((*key).to_owned(), Value::String(installation_id.to_owned()));
|
||||
}
|
||||
}
|
||||
serde_json::to_string(&metadata).ok()
|
||||
// Codex 的 turn metadata 同时承载于 HTTP header 与 WS client_metadata。
|
||||
// 改写安装 ID 后仍须保持官方 to_ascii_json_string 的编码合同;普通
|
||||
// to_string 会把中文工作区路径还原成 UTF-8,触发上游 WS metadata 后 Close 1000。
|
||||
let mut bytes = Vec::new();
|
||||
metadata
|
||||
.serialize(&mut serde_json::Serializer::with_formatter(
|
||||
&mut bytes,
|
||||
AsciiTurnMetadataFormatter,
|
||||
))
|
||||
.ok()?;
|
||||
String::from_utf8(bytes).ok()
|
||||
}
|
||||
|
||||
struct AsciiTurnMetadataFormatter;
|
||||
|
||||
impl serde_json::ser::Formatter for AsciiTurnMetadataFormatter {
|
||||
fn write_string_fragment<W: ?Sized + io::Write>(
|
||||
&mut self,
|
||||
writer: &mut W,
|
||||
fragment: &str,
|
||||
) -> io::Result<()> {
|
||||
let mut start = 0;
|
||||
for (index, ch) in fragment.char_indices() {
|
||||
if ch.is_ascii() {
|
||||
continue;
|
||||
}
|
||||
writer.write_all(&fragment.as_bytes()[start..index])?;
|
||||
for unit in ch.encode_utf16(&mut [0; 2]) {
|
||||
write!(writer, "\\u{unit:04x}")?;
|
||||
}
|
||||
start = index + ch.len_utf8();
|
||||
}
|
||||
writer.write_all(&fragment.as_bytes()[start..])
|
||||
}
|
||||
}
|
||||
|
||||
fn replace_existing_body_string(
|
||||
|
||||
@@ -2942,6 +2942,108 @@ async fn cross_account_scope_sanitizes_only_known_turn_metadata_fields() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn websocket_account_scoping_preserves_ascii_turn_metadata_and_unicode_input() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
create_account(&store, "acct_scope_same").await;
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.expect("listener");
|
||||
let base_url = format!("http://{}", listener.local_addr().expect("address"));
|
||||
let server = tokio::spawn(async move {
|
||||
let (socket, _) = listener.accept().await.expect("accept websocket");
|
||||
let mut socket = accept_codex_test_websocket(socket).await;
|
||||
let message = socket.next().await.expect("request").expect("valid frame");
|
||||
let body: Value = serde_json::from_str(message.to_text().expect("text")).expect("JSON");
|
||||
socket.send(Message::Text(json!({
|
||||
"type": "response.completed",
|
||||
"response": {"id": "resp_ascii_metadata", "model": "gpt-5.4", "status": "completed", "output": []}
|
||||
}).to_string().into())).await.expect("complete response");
|
||||
body
|
||||
});
|
||||
// Official Codex keeps embedded turn metadata ASCII even for Unicode workspaces.
|
||||
let raw = r#"{"installation_id":"client-installation","workspaces":{"C:\\Users\\\u9879\u76ee\\\ud83d\ude80":{"label":"caf\u00e9","literal":"\\u4e2d","quoted":"\"line\n"}}}"#;
|
||||
let input = json!([{"role": "user", "content": "中文正文 🚀"}]);
|
||||
let payload = ProtocolPayload::json_object(
|
||||
"openai",
|
||||
Map::from_iter([
|
||||
("model".to_owned(), json!("gpt-5.4")),
|
||||
("input".to_owned(), input.clone()),
|
||||
(
|
||||
"client_metadata".to_owned(),
|
||||
json!({"x-codex-turn-metadata": raw}),
|
||||
),
|
||||
]),
|
||||
)
|
||||
.expect("payload")
|
||||
.with_context(Map::from_iter([("turn_metadata".to_owned(), json!(raw))]));
|
||||
let mut stream = provider_with_base_url(&store, base_url)
|
||||
.execute(
|
||||
planned_request(
|
||||
"openai",
|
||||
Operation::Generate(GenerateRequest::from_protocol_payload(payload)),
|
||||
),
|
||||
context_with_state_owner("req_ascii_metadata", "acct_scope_same"),
|
||||
)
|
||||
.await
|
||||
.expect("provider stream");
|
||||
while let Some(event) = stream.next().await {
|
||||
event.expect("successful websocket response");
|
||||
}
|
||||
let body = server.await.expect("server");
|
||||
let encoded = body
|
||||
.pointer("/client_metadata/x-codex-turn-metadata")
|
||||
.and_then(Value::as_str)
|
||||
.expect("turn metadata");
|
||||
assert!(encoded.is_ascii(), "embedded header JSON must remain ASCII");
|
||||
let mut expected: Value = serde_json::from_str(raw).expect("original metadata");
|
||||
expected["installation_id"] = body["client_metadata"]["installation_id"].clone();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Value>(encoded).expect("metadata JSON"),
|
||||
expected
|
||||
);
|
||||
assert_eq!(body["input"], input);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn http_account_scoping_keeps_unicode_metadata_ascii_in_headers_and_body() {
|
||||
for owner in ["acct_scope_same", "acct_scope_old"] {
|
||||
let raw = r#"{"installation_id":"client-installation","workspaces":{"/tmp/\u4e2d\u6587/\ud83d\ude80":{"label":"caf\u00e9"}}}"#;
|
||||
let request = capture_scoped_http_request(
|
||||
"req_ascii_http",
|
||||
"acct_scope_same",
|
||||
owner,
|
||||
Map::from_iter([
|
||||
("model".to_owned(), json!("gpt-5.4")),
|
||||
("input".to_owned(), json!("中文正文 🚀")),
|
||||
("turnMetadata".to_owned(), json!(raw)),
|
||||
(
|
||||
"client_metadata".to_owned(),
|
||||
json!({"x-codex-turn-metadata": raw}),
|
||||
),
|
||||
]),
|
||||
Map::from_iter([("turn_metadata".to_owned(), json!(raw))]),
|
||||
)
|
||||
.await;
|
||||
let body = captured_request_body(&request);
|
||||
let headers = captured_header_values(&request, "x-codex-turn-metadata");
|
||||
assert_eq!(headers.len(), 1);
|
||||
let mut expected: Value = serde_json::from_str(raw).expect("original metadata");
|
||||
expected["installation_id"] = body["client_metadata"]["installation_id"].clone();
|
||||
for encoded in [
|
||||
std::str::from_utf8(&headers[0]).expect("UTF-8 header"),
|
||||
body["turnMetadata"].as_str().expect("body turn metadata"),
|
||||
body["client_metadata"]["x-codex-turn-metadata"]
|
||||
.as_str()
|
||||
.expect("client metadata"),
|
||||
] {
|
||||
assert!(encoded.is_ascii(), "scoped header JSON must remain ASCII");
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Value>(encoded).expect("metadata JSON"),
|
||||
expected
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cross_account_continuation_should_require_client_replay_without_an_upstream_probe() {
|
||||
let store = Arc::new(MemoryAccountStore::default());
|
||||
|
||||
Reference in New Issue
Block a user