Skip to content

feat(pool): --pool-fallback opt-in openai-compat fallback for an exhausted pool#782

Merged
askalf merged 1 commit into
masterfrom
feat/pool-fallback
Jul 17, 2026
Merged

feat(pool): --pool-fallback opt-in openai-compat fallback for an exhausted pool#782
askalf merged 1 commit into
masterfrom
feat/pool-fallback

Conversation

@askalf

@askalf askalf commented Jul 17, 2026

Copy link
Copy Markdown
Owner

What

--pool-fallback=<model> (env DARIO_POOL_FALLBACK, config poolFallback.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)

  • OpenAI-shape only. Anthropic-shape requests (/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 on isOpenAI.
  • Never silent. Every substituted response carries x-dario-pool-fallback: <model>. Silently swapping the model a client asked for is precisely the surprise this project is built to avoid.
  • Empty pool still 503s. Zero accounts is a setup mistake (dario login never 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 (429 with selectExcluding exhausted). Both reuse the client's original OpenAI-shape body (the Anthropic translation only ever lands in finalBody), swap the model via buildPoolFallbackBody, and go through the existing forwardToOpenAI. Non-JSON bodies fall back to the normal 503.

Tests

test/pool-fallback.mjs — 14 checks on buildPoolFallbackBody (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.

@github-actions

github-actions Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Wire drift: ✅ CLEAN

Ran node scripts/check-wire-drift.mjs against the runner's claude for commit 4984592de068c6989a91d00a913789b883bda59f — per-model anthropic-beta (betaForModel) + billing-block cch gate vs the installed claude.

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": []
}

Full workflow run

@askalf
askalf enabled auto-merge (squash) July 17, 2026 00:31
@github-actions

github-actions Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Compat test: ✅ PASSED

Ran node test/compat.mjs against dario proxy --passthrough on the self-hosted runner for commit 4984592de068c6989a91d00a913789b883bda59f.

Output
============================================================
  dario Compatibility Validation (--passthrough via dario)
  2026-07-17T01:01:25.387Z
============================================================

--- Anthropic Messages API (Hermes) ---
✅ #1 Anthropic non-stream: "COMPAT OK" | thinking_injected=false | service_tier=-
✅ #2 Anthropic stream: 8 events | order=correct | "**STREAM COMPAT**"
✅ #3 SSE framing: event:/data: pairs correctly paired

--- Passthrough Verification ---
✅ #4 No thinking injection: No thinking block in response (passthrough clean)
✅ #5 Client betas preserved: Client-requested thinking honored (status=200)

--- Tool Use (OpenClaw) ---
✅ #6 Tool use: tool=get_weather | input={"location":"Tokyo"}
✅ #7 Tool use stream: 12 events | tool_use=true | input_json_delta=true

--- OpenAI Compat ---
✅ #8 OpenAI non-stream: "OPENAI COMPAT"
✅ #9 OpenAI stream: 3 chunks | [DONE]=true | "**STREAM OPENAI**"

--- Header Visibility ---
✅ #10 Header visibility: request-id=true | ratelimit=true (12 headers)

============================================================
  RESULTS: 10 passed, 0 failed, 0 warnings
============================================================

Full workflow run

…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).
@askalf
askalf force-pushed the feat/pool-fallback branch from fc67e99 to 4984592 Compare July 17, 2026 00:59

@sprayberry-reviewer sprayberry-reviewer left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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): fallbackViable requires pool.size > 0 correctly so an empty pool still 503s; deferred accessToken assignment (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 && openaiBackend is the right set of preconditions — api-key mode never sets poolAccount, so the same null could fire there if upstreamApiKey weren't excluded. The upstreamApiKey check correctly prevents that.
  • Mid-flight 429 fallback (src/proxy.ts:3299): body still holds the client's original OpenAI-shape bytes (the Anthropic translation goes into finalBody), so re-using body is correct.
  • Config sanitize round-trip: sanitize + mergeOver(defaultConfig(), typed) correctly propagates poolFallback.model from file, falls back to null when absent, and drops wrong-typed values.
  • Flag parsing: poolFallbackFromFlag ?? env ?? config ?? ''.trim() || undefined correctly maps an explicit empty flag (--pool-fallback=) to undefined, 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 isOpenAI gate at the selection-time check vs. the !upstreamApiKey gate at the post-body dispatch correctly handle all three credential modes (api-key, single-account OAuth, pool OAuth).
  • buildPoolFallbackBody returning null on 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-fallback is set without an OpenAI backend is a good operator UX touch.

@askalf
askalf merged commit aaa696d into master Jul 17, 2026
12 checks passed
@askalf
askalf deleted the feat/pool-fallback branch July 17, 2026 02:36
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