mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-07 00:05:49 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2922b410e | ||
|
|
d303efc6f7 |
@@ -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());
|
||||
|
||||
@@ -1 +1 @@
|
||||
version: "3.3.25"
|
||||
version: "3.3.26"
|
||||
|
||||
Reference in New Issue
Block a user