feat(pool): --pool-fallback opt-in openai-compat fallback for an exhausted pool#782
Conversation
Wire drift: ✅ CLEANRan Report{
"drift": false,
"checkedAt": "2026-07-17T01:05:26.473Z",
"ccVersion": "2.1.211",
"captured": {
"claude-opus-4-8": {
"beta": "claude-code-20250219,interleaved-thinking-2025-05-14,thinking-token-count-2026-05-13,context-management-2025-06-27,prompt-caching-scope-2026-01-05,mid-conversation-system-2026-04-07,advisor-tool-2026-03-01,effort-2025-11-24",
"cchEmitted": false,
"billing": "x-anthropic-billing-header: cc_version=2.1.211.678; cc_entrypoint=sdk-cli;"
},
"claude-sonnet-5": {
"beta": "claude-code-20250219,interleaved-thinking-2025-05-14,thinking-token-count-2026-05-13,context-management-2025-06-27,prompt-caching-scope-2026-01-05,mid-conversation-system-2026-04-07,advisor-tool-2026-03-01,effort-2025-11-24",
"cchEmitted": false,
"billing": "x-anthropic-billing-header: cc_version=2.1.211.678; cc_entrypoint=sdk-cli;"
},
"claude-haiku-4-5": {
"beta": "interleaved-thinking-2025-05-14,thinking-token-count-2026-05-13,context-management-2025-06-27,prompt-caching-scope-2026-01-05,claude-code-20250219,advisor-tool-2026-03-01",
"cchEmitted": false,
"billing": "x-anthropic-billing-header: cc_version=2.1.211.678; cc_entrypoint=sdk-cli;"
},
"claude-fable-5": {
"beta": "claude-code-20250219,interleaved-thinking-2025-05-14,thinking-token-count-2026-05-13,context-management-2025-06-27,prompt-caching-scope-2026-01-05,mid-conversation-system-2026-04-07,advisor-tool-2026-03-01,effort-2025-11-24,fallback-credit-2026-06-01",
"cchEmitted": false,
"billing": "x-anthropic-billing-header: cc_version=2.1.211.678; cc_entrypoint=sdk-cli;"
}
},
"findings": []
} |
Compat test: ✅ PASSEDRan Output |
…pool is exhausted Strictly opt-in escape hatch. When the Claude pool can't serve (every seat drained or in auth cool-down at selection time, or a mid-flight 429 with no peer left), OpenAI-shape requests (/v1/chat/completions) are forwarded to the configured openai-compat backend with the model swapped to --pool-fallback's value, instead of surfacing the 429/503. Three deliberate limits: - OpenAI-shape only. Anthropic-shape requests keep the error — dario has no OpenAI->Anthropic RESPONSE translator, so a fallback there would corrupt streaming clients. - Never silent. Every substituted response carries x-dario-pool-fallback: <model>. A quietly swapped model is exactly the surprise this project exists to avoid. - Empty pool still 503s. Zero accounts is a setup error (login never ran), not traffic to re-bill to another provider. Requires a configured openai-compat backend; inert and warns without one. Sourced from --pool-fallback / DARIO_POOL_FALLBACK / config poolFallback.model; empty flag value disables (overriding env + config).
fc67e99 to
4984592
Compare
sprayberry-reviewer
left a comment
There was a problem hiding this comment.
Automated review from the Sprayberry Labs fleet code reviewer (sprayberry-reviewer bot). This is a verdict-bearing automated review, not a human sign-off.
Verdict: 1 minor doc issue; logic is clean — approving.
What I checked
buildPoolFallbackBody— pure function, all null-guard branches correct; tests cover the full shape.- Selection-time fallback path (
src/proxy.ts:2259):fallbackViablerequirespool.size > 0correctly so an empty pool still 503s; deferredaccessTokenassignment (poolAccount?.accessToken ?? '') is safe because the fallback dispatch returns before any code that would dereference it with an empty token. - Post-body fallback dispatch (
src/proxy.ts:2413): guard!upstreamApiKey && !poolAccount && poolFallbackModel && openaiBackendis the right set of preconditions — api-key mode never setspoolAccount, so the same null could fire there ifupstreamApiKeyweren't excluded. TheupstreamApiKeycheck correctly prevents that. - Mid-flight 429 fallback (
src/proxy.ts:3299):bodystill holds the client's original OpenAI-shape bytes (the Anthropic translation goes intofinalBody), so re-usingbodyis correct. - Config sanitize round-trip:
sanitize+mergeOver(defaultConfig(), typed)correctly propagatespoolFallback.modelfrom file, falls back tonullwhen absent, and drops wrong-typed values. - Flag parsing:
poolFallbackFromFlag ?? env ?? config ?? ''→.trim() || undefinedcorrectly maps an explicit empty flag (--pool-fallback=) toundefined, overriding env and config. - Test coverage: 10 unit checks on
buildPoolFallbackBody+ 4 config round-trip checks; the dispatch paths are covered by the e2e harness per the PR description.
Finding
Minor / docs — duplicate --pool-fallback row in docs/commands.md
docs/commands.md has two entries for --pool-fallback=<model> — one at line 37 and one at line 48. The diff shows both were added in this PR (two separate + hunks in the same file). The two descriptions are slightly different in wording; the second one (line 48) is more complete (mentions the mid-flight 429 path and uses stronger emphasis).
docs/commands.md:37 | `--pool-fallback=<model>` … (first entry, concise wording)
docs/commands.md:48 | `--pool-fallback=<model>` … (second entry, fuller wording)
Suggested fix: remove the first entry (line 37) and keep the second one, which is more accurate (it mentions the mid-flight 429 case and matches the docs in multi-account-pool.md).
This doesn't affect runtime behaviour and isn't blocking, but it will confuse readers who scroll the reference table and see the same flag twice with different descriptions.
What's good
- The three deliberate limits (OpenAI-shape only, never silent, empty pool still errors) are well-reasoned and each is correctly enforced in the code.
- The
isOpenAIgate at the selection-time check vs. the!upstreamApiKeygate at the post-body dispatch correctly handle all three credential modes (api-key, single-account OAuth, pool OAuth). buildPoolFallbackBodyreturningnullon non-object/non-JSON bodies — and both call sites treating that as "surface the original error" — is the right defensive stance.- The startup warning when
--pool-fallbackis set without an OpenAI backend is a good operator UX touch.
What
--pool-fallback=<model>(envDARIO_POOL_FALLBACK, configpoolFallback.model) — a strictly opt-in escape hatch for a fully-exhausted pool. When the Claude pool can't serve an OpenAI-shape request (/v1/chat/completions) — every seat drained/cooling at selection time, or a mid-flight 429 with no peer left — the request is forwarded to the configured openai-compat backend with the model swapped to<model>, instead of returning the 429/503.Off by default. Inert (with a startup warning) unless an openai-compat backend is configured.
Three deliberate limits (the reviewable decisions)
/v1/messages) keep the error. dario translates Anthropic→OpenAI outbound but has no OpenAI→Anthropic response translator; falling back there would corrupt a streaming client. Enforced by gating onisOpenAI.x-dario-pool-fallback: <model>. Silently swapping the model a client asked for is precisely the surprise this project is built to avoid.dario loginnever ran) — that must surface, not quietly re-bill every request to another provider. Only a populated-but-exhausted pool falls back.Where it hooks
Two paths, both in the OAuth dispatch: selection-time (
pool.select()returned null with the pool non-empty) and mid-flight (429withselectExcludingexhausted). Both reuse the client's original OpenAI-shapebody(the Anthropic translation only ever lands infinalBody), swap the model viabuildPoolFallbackBody, and go through the existingforwardToOpenAI. Non-JSON bodies fall back to the normal 503.Tests
test/pool-fallback.mjs— 14 checks onbuildPoolFallbackBody(model swap, field preservation, null on non-object/non-JSON/array/string/empty) and config sanitize round-trip (model, null, wrong-type dropped, absent default). Full suite green locally (113 files). The two live dispatch paths are exercised by the e2e harness.