Skip to content

fix(proxy): avoid 422 when Codex OAuth coerces Responses to SSE#2235

Merged
farion1231 merged 1 commit into
farion1231:mainfrom
xpfo-go:fix/2234-codex-streaming-response-mode
Apr 23, 2026
Merged

fix(proxy): avoid 422 when Codex OAuth coerces Responses to SSE#2235
farion1231 merged 1 commit into
farion1231:mainfrom
xpfo-go:fix/2234-codex-streaming-response-mode

Conversation

@xpfo-go

@xpfo-go xpfo-go commented Apr 22, 2026

Copy link
Copy Markdown
Contributor

Summary / 概述

  • fix the Claude-side Codex OAuth / openai_responses response handling so coerced SSE responses are processed through the streaming transform path even when the original Claude request had stream:false
  • add regression tests for the coerced-streaming case and for the upstream-SSE fallback path
  • prevent the intermittent 422 Failed to parse upstream response error caused by attempting to JSON-parse SSE payloads as non-stream responses

Related Issue / 关联 Issue

Fixes #2234
Related to #2216

Screenshots / 截图

Before / 修改前 After / 修改后
N/A (backend-only fix) N/A (backend-only fix)

Checklist / 检查清单

  • pnpm typecheck passes / 通过 TypeScript 类型检查 (node_modules is not installed in this checkout)
  • pnpm format:check passes / 通过代码格式检查 (node_modules is not installed in this checkout)
  • cargo clippy passes (if Rust code changed) / 通过 Clippy 检查(如修改了 Rust 代码) (cargo clippy --all-targets --all-features -D warnings currently fails on pre-existing warnings outside this change)
  • Updated i18n files if user-facing text changed / 如修改了用户可见文本,已更新国际化文件 (no user-facing text changed)

Root Cause / 根因

transform_responses::anthropic_to_responses(...) already forces Codex OAuth requests onto stream = true, but handle_claude_transform(...) was still branching on the original Claude request's stream flag. When Claude sent stream:false, the proxy could still receive an SSE response from the Codex backend and incorrectly run the non-streaming JSON parser against it, which produced the 422 reported in #2234.

This is also the same root cause described in #2216 (Codex OAuth non-streaming requests ending up on the wrong SSE/JSON parsing path), but fixed here with a narrower guard so ordinary non-streaming openai_responses traffic does not get forced onto the streaming path unless the upstream is actually SSE or the provider is codex_oauth.

Verification Notes / 验证说明

  • cargo test --manifest-path src-tauri/Cargo.toml handlers::tests -- --nocapture
  • cargo test --manifest-path src-tauri/Cargo.toml test_codex_oauth_forces_stream_true_even_when_client_sends_false -- --nocapture
  • cargo test --manifest-path src-tauri/Cargo.toml -- --skip update_current_claude_provider_syncs_live_when_proxy_takeover_detected_without_backup
  • full cargo test --manifest-path src-tauri/Cargo.toml is currently blocked by a pre-existing environment-specific failure: update_current_claude_provider_syncs_live_when_proxy_takeover_detected_without_backup tries to bind 127.0.0.1:15721, but a local cc-switch process is already listening on that port during validation

@zzl360

zzl360 commented Apr 22, 2026

Copy link
Copy Markdown

谢谢,等不及官方版本了,已本地合入代码编译了新版本,把这个问题给解决了

@farion1231 farion1231 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

感谢您的贡献,太及时了!

@farion1231
farion1231 merged commit 7ddf7ff into farion1231:main Apr 23, 2026
1 of 2 checks passed
@zzl360

zzl360 commented Apr 23, 2026

Copy link
Copy Markdown

感谢您的贡献,太及时了!

麻烦再出个新版本吧。

@farion1231

farion1231 commented Apr 23, 2026 via email

Copy link
Copy Markdown
Owner

farion1231 added a commit to majiayu000/cc-switch that referenced this pull request Apr 23, 2026
…ming clients

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

Codex OAuth always forces upstream openai_responses into SSE, even
when the original Claude request is stream:false. farion1231#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 farion1231#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.
farion1231 added a commit that referenced this pull request Apr 23, 2026
* 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 <[email protected]>
@midoujia

Copy link
Copy Markdown

等不及了

ManLOK-Chu pushed a commit to ManLOK-Chu/cc-switch that referenced this pull request May 11, 2026
ManLOK-Chu pushed a commit to ManLOK-Chu/cc-switch that referenced this pull request May 11, 2026
* 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: farion1231#2217

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

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

Codex OAuth always forces upstream openai_responses into SSE, even
when the original Claude request is stream:false. farion1231#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 farion1231#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 <[email protected]>
dfbb pushed a commit to dfbb/cc-switch that referenced this pull request May 20, 2026
dfbb pushed a commit to dfbb/cc-switch that referenced this pull request May 20, 2026
* 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: farion1231#2217

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

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

Codex OAuth always forces upstream openai_responses into SSE, even
when the original Claude request is stream:false. farion1231#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 farion1231#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 <[email protected]>
delta-lo pushed a commit to delta-lo/cc-switch-mod that referenced this pull request May 31, 2026
delta-lo pushed a commit to delta-lo/cc-switch-mod that referenced this pull request May 31, 2026
* 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: farion1231#2217

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

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

Codex OAuth always forces upstream openai_responses into SSE, even
when the original Claude request is stream:false. farion1231#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 farion1231#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 <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

claude code 通过 ccswitch 使用 gpt-5.4的时候,报错。

4 participants