[codex] Stabilize Codex OAuth cache routing (#2218)

* Stabilize Codex OAuth cache routing

Codex OAuth-backed Claude proxy requests now reuse a client-provided session identity for prompt cache routing and send Codex-like session headers when that identity exists. Generated proxy UUIDs are intentionally excluded so they do not fragment cache locality.\n\nThe same path exposed two runtime issues during validation: rustls needed an explicit process crypto provider, and Codex OAuth can return Responses SSE even when the original Claude request is non-streaming. Those are handled so cache-routed requests can complete instead of panicking or being parsed as JSON.\n\nConstraint: Official Codex uses conversation identity and Responses session headers for prompt cache routing.\nRejected: Always use generated proxy session IDs | generated IDs change per request and reduce cache reuse.\nConfidence: medium\nScope-risk: moderate\nDirective: Do not remove the client-provided-session guard unless generated session IDs become stable per conversation.\nTested: cargo test codex_oauth\nTested: Local dev app health check on 127.0.0.1:15721\nTested: Local proxy logs showed cache_read_tokens after restart\nNot-tested: Full cargo test without local cc-switch port conflict\nRelated: #2217

* feat(proxy): aggregate forced Codex OAuth SSE into JSON for non-streaming clients

Narrow override on top of #2235's streaming fallback.

Codex OAuth always forces upstream openai_responses into SSE, even
when the original Claude request is stream:false. #2235 handles this
by routing such responses through the streaming transform so the
client receives text/event-stream — that avoids the 422 that JSON
parsing would produce, and it also protects any other provider that
unexpectedly returns SSE (the response.is_sse() guard).

But for Claude SDK callers that sent stream:false, returning SSE
still violates the Anthropic non-streaming contract. This commit
adds an override on exactly one combination — non-streaming client
+ codex_oauth + openai_responses — to aggregate the upstream
Responses SSE into a synthetic Responses JSON and then run the
regular responses_to_anthropic non-streaming transform. All other
paths, including the generic response.is_sse() fallback, remain
on the streaming path from #2235.

The aggregator reuses proxy::sse::take_sse_block / strip_sse_field,
which support both \n\n and \r\n\r\n delimiters; a hand-rolled
split("\n\n") would silently fail on real HTTPS upstreams.

Tests cover the happy path, CRLF delimiters, response.failed
errors, and the missing response.completed defensive branch.

---------

Co-authored-by: Jason <farion1231@gmail.com>
This commit is contained in:
lif
2026-04-23 11:15:01 +08:00
committed by GitHub
parent 3ad7d0b1cc
commit 444c123ad0
6 changed files with 357 additions and 34 deletions
+122 -14
View File
@@ -89,6 +89,12 @@ pub fn transform_claude_request_for_api_format(
session_id: Option<&str>,
shadow_store: Option<&super::gemini_shadow::GeminiShadowStore>,
) -> Result<serde_json::Value, ProxyError> {
let is_codex_oauth = provider
.meta
.as_ref()
.and_then(|m| m.provider_type.as_deref())
== Some("codex_oauth");
// Copilot 场景:优先从 metadata.user_id 提取 session ID 作为 cache key
// 格式: "uuid_sessionId" → 提取 "_" 后面的部分作为 session 标识
// 同一会话的请求共享 cache key,提升 Copilot 缓存命中率
@@ -118,28 +124,33 @@ pub fn transform_claude_request_for_api_format(
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
})
} else if is_codex_oauth {
session_id
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToString::to_string)
} else {
None
};
let cache_key = session_cache_key
.as_deref()
.or_else(|| {
provider
.meta
.as_ref()
.and_then(|m| m.prompt_cache_key.as_deref())
})
.unwrap_or(&provider.id);
let explicit_cache_key = provider
.meta
.as_ref()
.and_then(|m| m.prompt_cache_key.as_deref());
let cache_key = if is_codex_oauth {
explicit_cache_key
.or(session_cache_key.as_deref())
.unwrap_or(&provider.id)
} else {
session_cache_key
.as_deref()
.or(explicit_cache_key)
.unwrap_or(&provider.id)
};
match api_format {
"openai_responses" => {
// Codex OAuth (ChatGPT Plus/Pro 反代) 需要在请求体里强制 store: false
// + include: ["reasoning.encrypted_content"],由 transform 层统一处理。
let is_codex_oauth = provider
.meta
.as_ref()
.and_then(|m| m.provider_type.as_deref())
== Some("codex_oauth");
super::transform_responses::anthropic_to_responses(
body,
Some(cache_key),
@@ -1222,6 +1233,103 @@ mod tests {
assert!(transformed.get("max_output_tokens").is_some());
}
#[test]
fn test_transform_claude_request_for_codex_oauth_uses_session_cache_key() {
let provider = create_provider_with_meta(
json!({
"env": {
"ANTHROPIC_BASE_URL": "https://chatgpt.com/backend-api/codex"
}
}),
ProviderMeta {
api_format: Some("openai_responses".to_string()),
provider_type: Some("codex_oauth".to_string()),
..ProviderMeta::default()
},
);
let body = json!({
"model": "gpt-5.4",
"messages": [{ "role": "user", "content": "hello" }],
"max_tokens": 128
});
let transformed = transform_claude_request_for_api_format(
body,
&provider,
"openai_responses",
Some("session-123"),
None,
)
.unwrap();
assert_eq!(transformed["prompt_cache_key"], "session-123");
}
#[test]
fn test_transform_claude_request_for_codex_oauth_without_session_falls_back_to_provider_id() {
let provider = create_provider_with_meta(
json!({
"env": {
"ANTHROPIC_BASE_URL": "https://chatgpt.com/backend-api/codex"
}
}),
ProviderMeta {
api_format: Some("openai_responses".to_string()),
provider_type: Some("codex_oauth".to_string()),
..ProviderMeta::default()
},
);
let body = json!({
"model": "gpt-5.4",
"messages": [{ "role": "user", "content": "hello" }],
"max_tokens": 128
});
let transformed = transform_claude_request_for_api_format(
body,
&provider,
"openai_responses",
None,
None,
)
.unwrap();
assert_eq!(transformed["prompt_cache_key"], provider.id);
}
#[test]
fn test_transform_claude_request_for_codex_oauth_keeps_explicit_cache_key() {
let provider = create_provider_with_meta(
json!({
"env": {
"ANTHROPIC_BASE_URL": "https://chatgpt.com/backend-api/codex"
}
}),
ProviderMeta {
api_format: Some("openai_responses".to_string()),
provider_type: Some("codex_oauth".to_string()),
prompt_cache_key: Some("explicit-cache-key".to_string()),
..ProviderMeta::default()
},
);
let body = json!({
"model": "gpt-5.4",
"messages": [{ "role": "user", "content": "hello" }],
"max_tokens": 128
});
let transformed = transform_claude_request_for_api_format(
body,
&provider,
"openai_responses",
Some("session-123"),
None,
)
.unwrap();
assert_eq!(transformed["prompt_cache_key"], "explicit-cache-key");
}
#[test]
fn test_transform_claude_request_for_api_format_gemini_native() {
let provider = create_provider_with_meta(