Skip to content

Mid-session backend switching: stateless redesign + subscription-friendly wrapper#26

Open
aattaran wants to merge 4 commits into
mainfrom
fix/audit-shared-bugs
Open

Mid-session backend switching: stateless redesign + subscription-friendly wrapper#26
aattaran wants to merge 4 commits into
mainfrom
fix/audit-shared-bugs

Conversation

@aattaran
Copy link
Copy Markdown
Owner

@aattaran aattaran commented May 9, 2026

Summary

Fixes the content[].thinking ... must be passed back to the API 400-error class that previously fired on every anthropic → deepseek round-trip in mid-session switching. Adds subscription-friendly wrapper mode so Claude Max users can route cheap-backend traffic through the proxy without giving up their subscription auth.

Validated end-to-end on a Linux GCE VM:

  • 33/33 unit tests pass (cross-switch + deferred-set + stateless suites)
  • 28/28 live TUI steps clean across 6 cross-mode round-trips, zero 400 errors

Commits (4 ahead of main)

  • 8543afc fix: handle proxyRes mid-stream errors + SSE-safe error response
  • def324e feat: two strictly isolated request paths for model-call dispatch (PATH A anthropic, B deepseek, C other — no shared sanitization branches)
  • a6f5ff6 feat: anthropic mode auth substitution via ANTHROPIC_API_KEY env var (proxy substitutes the right backend key per request, so client auth is irrelevant)
  • a7814ba fix: the core fix — stateless thinking-block classification + post-strip invariant + subscription-friendly wrapper

Key design decisions

1. Stateless body normalization (replaces the prior state.hadXSession flags). Each per-path processor inspects parsed.messages per request and classifies thinking blocks by signature shape. The proxy is a stateless router; trying to track cross-request flags in it diverged from messages[] whenever the proxy restarted, was resumed, or saw a turn from the bridge.

2. Post-strip invariant (processDeepseekRequest):

If any assistant turn lacks a thinking block, DeepSeek cannot maintain thinking continuity. Set parsed.thinking = {type: 'disabled'} and remove parsed.output_config (DS rejects disabled + reasoning_effort combo).

This catches the case where Anthropic's adaptive thinking-mode emitted a tool-use turn without a thinking block, then conversation comes back to DeepSeek which enforces per-turn continuity (per DeepSeek docs).

3. Subscription-friendly wrapper path 2: stop forcing ANTHROPIC_AUTH_TOKEN to the backend's API key. Let the user's existing auth (subscription OAuth or ANTHROPIC_API_KEY) flow through; the proxy substitutes the right backend key per request.

4. Wrapper port-extraction fix: longstanding bug where head -1/Select-Object -First 1 grabbed the proxy banner instead of the port number. Now matches numeric-only line.

Test plan

  • Unit tests: 15 + 4 + 14 = 33 assertions across cross-switch, deferred-set, stateless suites
  • Live TUI: 28-step comprehensive scenario with 6 cross-mode round-trips, 4 backends (DS / Anthropic), tool-use chains, rapid-flips, long-history accumulation
  • Math correctness verified end-to-end (363468×2535=921,391,380 etc.)
  • Both API-key and Claude Max subscription auth modes
  • PowerShell + Bash wrappers in lockstep

Files changed in latest commit

  • proxy/model-proxy.js (stateless redesign, +178/-46)
  • deepclaude.sh (subscription-friendly path 2, port-fix, auto-mode-set, +66/-19)
  • deepclaude.ps1 (parity with .sh, +153/-74)
  • README.md (env-vars table, mid-session prerequisites, +30/-6)

🤖 Generated with Claude Code

aliyarattaran-debug and others added 4 commits May 9, 2026 00:02
Two related crash fixes in the model-proxy:

1. proxyRes error handler. When the backend drops the connection
   mid-stream (its own timeout, restart, network glitch), Node's
   EventEmitter throws 'Unhandled error' on the response stream and
   crashes the proxy. Add an explicit handler that destroys the client
   socket cleanly.

2. SSE-safe error response path in proxyReq.on('error'). The previous
   logic always wrote 'Upstream connection error' as JSON, even when
   headers had already been sent for an SSE response. Appending JSON to
   an SSE stream corrupts it and crashes Claude Code's SSE parser. Now:
   - if !headersSent: write a clean 502 JSON response
   - else (likely SSE in progress): destroy the socket without writing

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The proxy's /v1/messages handler now dispatches requests to ONE of three
per-path processor functions based on state.mode. Each processor owns the
full request-side transformation for its path; there is no shared
sanitization logic and no scattered if (state.mode === ...) branches.

  processAnthropicRequest   (PATH A, state.mode === 'anthropic')
    Existing pre-patch behavior, isolated:
    - hadNonAnthropicSession=true: strip ALL thinking blocks
    - hadNonAnthropicSession=false: strip only UNSIGNED thinking blocks
    No model remap. No response-side touching.

  processDeepseekRequest    (PATH B, state.mode === 'deepseek')
    Apply MODEL_REMAP['deepseek'] to request `model` field. Pass everything
    else through unchanged — including prior thinking blocks in the messages
    array. DeepSeek's Anthropic-compat endpoint REQUIRES prior thinking
    blocks on multi-turn thinking continuations (returns 400 with
    "content[].thinking ... must be passed back to the API" otherwise).

  processOtherBackendRequest (PATH C, openrouter | fireworks | _single)
    Apply MODEL_REMAP[mode] if defined. Strip ALL thinking blocks
    (existing pre-patch behavior — these backends are assumed to reject
    foreign-signed thinking). If a future backend turns out to need
    pass-through like DeepSeek does, promote it to its own dedicated path
    rather than weakening this one.

Trade-off
---------
The JSONL Claude Code persists during a DeepSeek session contains
foreign-signed thinking blocks and the deepseek-v4-pro model name. By
design — a DeepSeek session is a DeepSeek session. To resume that session
under Anthropic, route through the deepclaude proxy in 'anthropic' mode;
the existing hadNonAnthropicSession strip in PATH A scrubs the request
body cleanly before forwarding to api.anthropic.com. Plain 'claude --resume'
of a DeepSeek-touched session bypassing the proxy will 400 against
Anthropic — that is intentional path isolation, not a bug.

Validation
----------
- Director-mode review (fresh pr-reviewer agent, no shared context):
  SHIP IT. Confirmed three paths are pure functions, dispatcher reads
  state.mode once, all infrastructure preserved (UsageNormalizer,
  normalizeJsonBody, proxyRes/proxyReq error handlers, /_proxy/* control
  endpoints, switchMode, cost tracking, port retry, Origin check,
  path-overlap stripping), no cross-path state coupling.
- Multi-turn DeepSeek-thinking VM test (api.deepseek.com via proxy):
    Turn 1: 200 OK, DeepSeek emitted 1 thinking content_block + 99 deltas
    Turn 2: 200 OK with prior assistant turn including thinking + text
            in messages array. Zero "must be passed back" errors. 89
            text deltas. Reproduces the user's TUI scenario.
- node --check: clean parse, ESM.

Net change: +142 / -37 vs commit 8543afc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Completes the path-isolation story for auth headers. Previously the proxy
substituted client auth on outbound requests for non-anthropic modes
(injecting the per-backend key) but left anthropic-mode requests with the
client's auth header passed through unchanged. That meant Claude Code had
to be started with ANTHROPIC_AUTH_TOKEN already set to a valid Anthropic
key — and the deepclaude wrappers always set it to the DeepSeek key
instead. Result: switching to anthropic mode mid-session 401'd because
api.anthropic.com received the DeepSeek key.

This commit reads ANTHROPIC_API_KEY from process.env at proxy startup.
When in anthropic mode, the proxy substitutes that key as `x-api-key`
on outbound requests (Anthropic's auth header convention; NOT Bearer,
which is reserved for OAuth bridge tokens). Same auth-substitution path
that already exists for DeepSeek/OpenRouter/Fireworks now applies to
Anthropic too — hence "true" path isolation.

If ANTHROPIC_API_KEY is unset, the proxy falls back to passthrough
(client's auth header forwarded unchanged). This preserves the original
OAuth-bridge mode where Claude Code holds a session token via
`claude /login` and the proxy doesn't need to hold an Anthropic key.

The auth-substitution gate is now `MODEL_PATHS.includes(urlPath) &&
state.apiKey` — uniform across all modes — replacing the previous
`isModelCall` gate which excluded anthropic mode by design.

Validation
----------
End-to-end VM test with auth substitution:
  - deepseek mode + deepseek client auth          → 200 (substituted: same)
  - anthropic mode + wrong (deepseek) client auth → 200 (substituted: anthropic)
  - deepseek mode + wrong (anthropic) client auth → 200 (substituted: deepseek)
  - anthropic mode + garbage client auth          → 200 (substituted: anthropic)

All four scenarios return 200. Claude Code can start with ANY auth token
(or none) and seamlessly switch backends via /_proxy/mode without
restarting.

Operational note
----------------
For seamless mid-session switching, both keys must be in the proxy's
process env at startup. Recommended setup:

  export DEEPSEEK_API_KEY=sk-...
  export ANTHROPIC_API_KEY=sk-ant-api03-...
  node proxy/start-proxy.js https://api.deepseek.com/anthropic "$DEEPSEEK_API_KEY"

The wrappers (deepclaude.ps1 / deepclaude.sh) currently only export
DEEPSEEK_API_KEY. A follow-up should teach them to also export
ANTHROPIC_API_KEY when present, so anthropic mode "just works" in
remote-control flows too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…pper

PROXY (proxy/model-proxy.js):
- Replace stateful hadXSession flags with stateless per-request body inspection
- Per-path processors (PATH A anthropic, B deepseek, C other) classify thinking
  blocks by signature shape and strip foreign blocks per request
- Add post-strip invariant: if any assistant turn lacks a thinking block,
  set thinking={type:'disabled'} and remove output_config (DS rejects
  disabled+effort combo). Eliminates the "content[].thinking must be
  passed back" 400 class on cross-mode round-trips.
- Add [1m] tier-suffix strip for Claude Max under PATH A
- Strip accept-encoding upstream to prevent UsageNormalizer corrupting gzip
- switchMode refuses anthropic when no ANTHROPIC_API_KEY (clean error msg)

WRAPPERS (deepclaude.sh + deepclaude.ps1):
- Subscription-friendly path 2: spawn proxy + ANTHROPIC_BASE_URL=proxy, but
  do NOT force ANTHROPIC_AUTH_TOKEN. Proxy substitutes the backend key per
  request, so subscription OAuth flows through unchanged.
- Fix port-extraction bug (longstanding): old code's head -1 / Select-Object
  -First 1 grabbed the proxy banner instead of the port number. Now matches
  numeric-only line.
- Auto-mode-set after spawn: legacy startup defaults to anthropic; wrapper
  now curls /_proxy/mode to switch to chosen backend.
- Use canonical Claude model names (claude-opus-4-7 etc) instead of
  backend-native; proxy MODEL_REMAP forward-maps. Makes mid-session
  /anthropic /deepseek /openrouter /fireworks switches all work.
- Persistent /tmp/proxy.log (or %TEMP%\deepclaude-proxy.log) for diagnostics.
- Resolve ANTHROPIC_API_KEY from User scope + warn if missing.

README.md:
- Document new env vars table (auth-substitution model)
- Add ANTHROPIC_API_KEY prerequisite for /anthropic mid-session switching
- Note path-isolation rule and continuity-gap fix

Validated on Linux VM:
- 33/33 unit tests pass (test-cross-switch + test-deferred-set + test-stateless)
- 28/28 live TUI steps clean (6 cross-switches, 0x 400 errors)
- Mid-session DS<->Anthropic round-trips work under both API-key and
  Claude Max subscription auth (with ANTHROPIC_API_KEY set)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

2 participants