[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
+4
View File
@@ -57,6 +57,8 @@ pub struct RequestContext {
pub app_type: AppType,
/// Session ID(从客户端请求提取或新生成)
pub session_id: String,
/// Session ID 是否由客户端提供。生成的 UUID 不能作为上游缓存 key,否则每个请求都会换 key。
pub session_client_provided: bool,
/// 整流器配置
pub rectifier_config: RectifierConfig,
/// 优化器配置
@@ -161,6 +163,7 @@ impl RequestContext {
app_type_str,
app_type,
session_id,
session_client_provided: session_result.client_provided,
rectifier_config,
optimizer_config,
copilot_optimizer_config,
@@ -223,6 +226,7 @@ impl RequestContext {
state.app_handle.clone(),
self.current_provider_id.clone(),
self.session_id.clone(),
self.session_client_provided,
first_byte_timeout,
idle_timeout,
self.rectifier_config.clone(),