Skip to content

fix(auth): rotate on in-stream provider errors during bootstrap - #195

Open
warelik wants to merge 2 commits into
kaitranntt:mainfrom
warelik:fix/in-stream-error-failover
Open

fix(auth): rotate on in-stream provider errors during bootstrap#195
warelik wants to merge 2 commits into
kaitranntt:mainfrom
warelik:fix/in-stream-error-failover

Conversation

@warelik

@warelik warelik commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Summary

Detect in-stream provider error envelopes during stream bootstrap and fail over to rotate credentials, preventing dead auth keys from being recorded as successful completions.

Problem

  1. In-stream provider errors masquerading as HTTP 200:
    Upstream providers (e.g. Gemini, Claude, Antigravity) may return HTTP status 200 OK while embedding provider error envelopes directly inside the SSE stream body (e.g. Gemini data: {"error":{"code":429,"message":"Resource exhausted","status":"RESOURCE_EXHAUSTED"}} or Claude event: error\ndata: {"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}).
  2. False positive bootstrap and broken auth rotation:
    Because network transport succeeded (chunk.Err == nil), streamBootstrapState previously marked sawUnknownData = true. Consequently, hasMeaningfulOutput() returned true, the bootstrap phase completed without error, and upon stream termination wrapStreamResult recorded Success: true.
    • Exhausted credentials (429 / 503 / 401) were never marked unavailable or put into cooldown.
    • Downstream clients received provider error payloads without triggering proxy failover or account rotation.

Fix

  1. Stream bootstrap error detection (sdk/cliproxy/auth/empty_completion.go):
    • Added streamErr *Error and currentEvent string tracking to streamBootstrapState.
    • Introduced streamErrorEnvelope, inferHTTPStatus, parseStreamErrorFromEnvelope, and evalProviderError to identify provider error structures across Gemini, Claude, and OpenAI-compatible SSE streams.
    • Updated processSingleLine, flushData, and observe to capture error frames, suppress sawMetadataOnly / sawUnknownData, and set hasMeaningfulOutput() = false.
  2. Conductor failover and metrics recording (sdk/cliproxy/auth/conductor_stream.go):
    • readStreamBootstrap: checks bootstrap.streamError() on chunk observation or stream EOF; aborts bootstrap if an in-stream error occurs before meaningful output, initiating credential failover.
    • wrapStreamResult: inspects late stream chunks via detectStreamPayloadError to record Success: false and trigger account cooldown if a provider error arrives mid-stream.
  3. Preserved invariants:
    • Non-error unknown JSON payloads continue to be forwarded directly without triggering rotation (TestExecuteStreamInStreamUnknownJSONForwardedNotRotated).
    • 400-class client request faults remain terminal and are returned directly to the client without rotating accounts (TestExecuteStreamInStream400InvalidRequestNotRotated).
  4. Unit tests (sdk/cliproxy/auth/empty_completion_test.go):
    • TestExecuteStreamInStreamGemini429ErrorRotatesAuth: in-stream Gemini 429 rotates auth and marks quota exceeded.
    • TestExecuteStreamInStreamClaudeOverloadedErrorRotatesAuth: in-stream Claude overloaded_error rotates to fallback auth.
    • TestExecuteStreamInStream400InvalidRequestNotRotated: in-stream 400 invalid request returns error directly without auth rotation.
    • TestExecuteStreamInStreamUnknownJSONForwardedNotRotated: unrecognized non-error JSON forwarded without rotation.
    • TestExecuteStreamMidStreamInStreamErrorMarksAuthFailed: mid-stream error after initial tokens records Success: false.

Testing

  • TMPDIR=/Users/warelik/.cache/gotmp go build ./... — exit code 0
  • TMPDIR=/Users/warelik/.cache/gotmp go vet ./sdk/cliproxy/auth/... — exit code 0
  • TMPDIR=/Users/warelik/.cache/gotmp go test -count=1 ./sdk/cliproxy/auth/... — exit code 0
  • Comprehensive test suite covering SSE events, JSON classification, error envelope mapping, and rotation invariants.

Upstream Reference

  • This change is located entirely within sdk/cliproxy/auth/ and does not touch internal/translator/ (src/AGENTS.md translator policy does not apply).
  • Ported companion of upstream pull request router-for-me/CLIProxyAPI#4881.

Detect upstream HTTP 200 SSE/JSON error payloads (429, 503, 401, 403) during bootstrap before forwarding, allowing auth rotation instead of swallowing the error or forwarding broken streams.

Refs router-for-me/CLIProxyAPI#4881
@warelik

warelik commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

Scope note, so this PR is not mistaken for a full port.

This PR carries exactly one fix from the upstream work in router-for-me/CLIProxyAPI#4881 — rotating on in-stream provider errors during stream bootstrap — and nothing else from that series.

The upstream series contains a further ~17 commits touching the same subsystem, and it introduces sdk/cliproxy/auth/empty_completion.go as a new 2413-line file. This repository already carries its own earlier copy of that file: 1469 lines on main, 1730 lines with this PR applied. Measured line divergence between this PR's head and the upstream head is 881 lines.

Porting the rest of the upstream series wholesale onto that divergence would produce an unreviewable diff and would very likely delete or rewrite behavior this repository intentionally has. We are deliberately not doing that. If specific upstream fixes from that series are wanted here, they are better taken one at a time, each with its own test, against this repository's own copy of the file — and we are happy to prepare them individually on request.

Upstream tracking for the full series: router-for-me/CLIProxyAPI#4881.

…close

readStreamBootstrap consulted streamError() at channel close without finishing
the bootstrap state first. flushData() runs only on a blank separator line or
from finish(), so an SSE error event whose data line is newline-terminated but
never followed by that blank line stays buffered in dataLines: the provider
error is never evaluated, the bootstrap reports closed=true, and the caller
receives an empty stream instead of a routable failure it can fail over on.

hasMeaningfulOutput() already returns false once streamErr is set with no
content, so finalizing first cannot swallow a real completion.

Regression test: TestReadStreamBootstrapFinalizesDetectorAtEOF.
@warelik

warelik commented Aug 21, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up in b3f4bc1: finalize the bootstrap state at EOF before reporting a clean close.

readStreamBootstrap consulted streamError() at channel close without calling finish() first. flushData() runs only on a blank separator line or from finish(), so an SSE error event whose data line is newline-terminated but never followed by that blank line stays buffered in dataLines: the provider error is never evaluated, the bootstrap returns closed=true, and the caller receives an empty stream instead of a routable failure it can fail over on — which defeats the rotation this PR adds.

Reverse bite-check — with bootstrap.finish() removed, the new test fails:

--- FAIL: TestReadStreamBootstrapFinalizesDetectorAtEOF (0.00s)
    conductor_stream_eof_test.go:25: readStreamBootstrap() error = nil, want the in-band provider error (closed=true, buffered=2)
FAIL
FAIL	github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth	0.454s
FAIL

With the fix, go build ./... and go test -count=1 ./sdk/cliproxy/auth/... are green and gofmt -l is clean on both touched files.

Same fix upstream: router-for-me/CLIProxyAPI#4881 (commit 4a001bf1, thread router-for-me/CLIProxyAPI#4881 (comment)). The test file is byte-identical in both repositories.

@warelik

warelik commented Aug 21, 2026

Copy link
Copy Markdown
Contributor Author

Filed #201 for the plugin-host half of this defect: wrapStreamEmptyCompletion never consults the detected in-band provider error and, at stream close, judges emptiness before finalizing. The fix needs the streamErr state this PR adds, so it is held as an issue rather than a second branch that would duplicate ~313 lines of this one.

@warelik

warelik commented Aug 21, 2026

Copy link
Copy Markdown
Contributor Author

Upstream review follow-up note: router-for-me/CLIProxyAPI#4881 picked up a related fix — the empty-completion detector now recognizes bare Interactions finish events as terminal and reads metadata.total_usage (upstream commit baf43ba5). That fix does not mirror here: this tree has no Interactions branch in sdk/cliproxy/auth/empty_completion.go at all, so the whole protocol is unrecognized data to the detector. Filed as #202 with a repro and the upstream test cases to port.

warelik added a commit to warelik/CLIProxyAPIPlus that referenced this pull request Aug 21, 2026
After Plus kaitranntt#195 the conductor rotates within the same ExecuteStream
call when a provider error envelope is detected in the bootstrap,
so the test should assert err == nil and fallback content.

Keep the cooldown assertions on the first-picked auth and sort the
auth IDs so the error auth is deterministic.
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.

1 participant