@askalf/dario 5.5.89 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/proxy.js CHANGED
@@ -20,7 +20,8 @@ import { loadAllAccounts, loadAccount, saveAccount, refreshAccountToken, resyncL
20
20
  import { handleAdminRequest } from './admin-api.js';
21
21
  import { createTokenBucket } from './rate-limit.js';
22
22
  import { getOpenAIBackend, isOpenAIModel, forwardToOpenAI } from './openai-backend.js';
23
- import { forwardToCodex, getCodexModelSlugs, CODEX_BACKEND_BASE_URL } from './codex-backend.js';
23
+ import { forwardToCodex, getCodexModelSlugs, pickCodexFallback, pickClaudeFallback, CODEX_BACKEND_BASE_URL } from './codex-backend.js';
24
+ import { readCompareTarget, teeResponse, runCompare, writeCompareRecord, COMPARE_RESULT_HEADER } from './compare.js';
24
25
  import { listCodexAccountAliases, hasAnyCodexAccount, selectCodexAccount, getFreshCodexAccount } from './codex-accounts.js';
25
26
  import { route as routeProvider } from './provider-adapter.js';
26
27
  import { RequestQueue, QueueFullError, QueueTimeoutError, DEFAULT_MAX_CONCURRENT, DEFAULT_MAX_QUEUED, DEFAULT_QUEUE_TIMEOUT_MS } from './request-queue.js';
@@ -1234,20 +1235,40 @@ export async function startProxy(opts = {}) {
1234
1235
  console.log(` Codex accounts: ${startupCodexAliases.join(', ')} → ${CODEX_BACKEND_BASE_URL}`);
1235
1236
  }
1236
1237
  // Pool-exhausted fallback (strictly opt-in). When the Claude pool can't
1237
- // serve — every seat rate-limited or in auth cool-down — OpenAI-shape
1238
- // requests (/v1/chat/completions) are re-pointed at the configured
1239
- // openai-compat backend with the model swapped to `poolFallbackModel`,
1240
- // instead of surfacing the 429/503. Anthropic-shape requests keep the
1241
- // error: dario has no OpenAI→Anthropic response translation, and
1242
- // half-translating would corrupt streaming clients. Every substituted
1243
- // response carries `x-dario-pool-fallback: <model>` a silently swapped
1244
- // model is the kind of surprise this project exists to avoid.
1245
- const poolFallbackModel = (opts.poolFallbackModel ?? '').trim() || null;
1246
- if (poolFallbackModel && openaiBackend) {
1247
- console.log(` Pool fallback: exhausted-pool /v1/chat/completions requests ${openaiBackend.name} as ${poolFallbackModel} (marked x-dario-pool-fallback)`);
1248
- }
1249
- else if (poolFallbackModel && !openaiBackend) {
1250
- console.warn('[dario] --pool-fallback is set but no OpenAI-compat backend is configured (`dario backend add …`) fallback is inert.');
1238
+ // serve — every seat rate-limited or in auth cool-down — the request is
1239
+ // re-pointed at whichever provider can serve `poolFallbackModel` instead
1240
+ // of surfacing the 429/503:
1241
+ //
1242
+ // a stored Codex/ChatGPT subscription that LISTS that model, on
1243
+ // EITHER wire shape. This is the v6.0.0 change and the one that
1244
+ // matters for a deployment whose providers are both subscriptions:
1245
+ // failover now costs nothing per token, needs no API key, and covers
1246
+ // Anthropic-shape clients (Claude Code, agent runtimes) that used to
1247
+ // have nowhere to go and simply went dark when the pool filled.
1248
+ // • otherwise the openai-compat backend, OpenAI shape only that route
1249
+ // still has no Messages translation.
1250
+ //
1251
+ // Every substituted response carries `x-dario-pool-fallback: <model>`a
1252
+ // silently swapped model is the kind of surprise this project exists to
1253
+ // avoid.
1254
+ // The value may name a chain — see pickCodexFallback/pickClaudeFallback.
1255
+ // `poolFallbackModel` stays the FIRST entry so every pre-6.0 reference and
1256
+ // every single-value config keeps its exact previous meaning.
1257
+ const poolFallbackModels = ((opts.poolFallbackModel ?? '').trim() || '')
1258
+ .split(',').map((m) => m.trim()).filter(Boolean);
1259
+ const poolFallbackModel = poolFallbackModels[0] ?? null;
1260
+ if (poolFallbackModel) {
1261
+ const targets = [];
1262
+ if (startupCodexAliases.length > 0)
1263
+ targets.push(`codex subscription (${startupCodexAliases.join(', ')}) — both wire shapes`);
1264
+ if (openaiBackend)
1265
+ targets.push(`${openaiBackend.name} — OpenAI shape only`);
1266
+ if (targets.length > 0) {
1267
+ console.log(` Pool fallback: exhausted-pool requests → ${targets.join(', then ')} as ${poolFallbackModel} (marked x-dario-pool-fallback)`);
1268
+ }
1269
+ else {
1270
+ console.warn('[dario] --pool-fallback is set but there is nothing to fall back TO — add a Codex account (`dario codex add …`) or an OpenAI-compat backend (`dario backend add …`). Fallback is inert.');
1271
+ }
1251
1272
  }
1252
1273
  // User-defined model aliases (see parseModelAliasSpecs). Resolved by the
1253
1274
  // CLI (config < env < flags, per-key) and applied per request before
@@ -1787,6 +1808,58 @@ export async function startProxy(opts = {}) {
1787
1808
  function checkAuth(req) {
1788
1809
  return authenticateRequest(req.headers, apiKeyBuf);
1789
1810
  }
1811
+ /**
1812
+ * Serve a pool-exhausted request from the ChatGPT subscription (v6.0.0).
1813
+ *
1814
+ * Returns true when it answered, false when it declined — declining is
1815
+ * silent so the caller can fall through to the api-key backend and then to
1816
+ * the honest 429/503. It never manufactures a reason to fire: no codex
1817
+ * account, an unreadable one, or a `poolFallbackModel` that account does not
1818
+ * list all mean "not mine to serve".
1819
+ *
1820
+ * Works for BOTH wire shapes, which is the point: forge's agents and Claude
1821
+ * Code speak Anthropic, and before v5.5.87 they had nowhere to fail over to
1822
+ * and simply went dark when the Claude pool filled. The substituted model is
1823
+ * announced on `x-dario-pool-fallback` exactly as the api-key path does —
1824
+ * a silently swapped model family is precisely the surprise this project
1825
+ * exists to avoid.
1826
+ */
1827
+ const tryCodexPoolFallback = async (req, res, body, fallbackModels, shape, why) => {
1828
+ if (fallbackModels.length === 0)
1829
+ return false;
1830
+ if (!(await hasAnyCodexAccount().catch(() => false)))
1831
+ return false;
1832
+ const stored = await selectCodexAccount().catch(() => null);
1833
+ if (!stored)
1834
+ return false;
1835
+ let creds;
1836
+ try {
1837
+ creds = await getFreshCodexAccount(stored);
1838
+ }
1839
+ catch {
1840
+ return false;
1841
+ }
1842
+ const slugs = await getCodexModelSlugs(creds).catch(() => []);
1843
+ const fallbackModel = pickCodexFallback(fallbackModels, slugs);
1844
+ if (!fallbackModel)
1845
+ return false;
1846
+ const fallbackBody = buildPoolFallbackBody(body, fallbackModel);
1847
+ if (!fallbackBody)
1848
+ return false;
1849
+ console.log(`[dario] #${requestCount} ${why} → codex account ${creds.alias} as ${fallbackModel}`);
1850
+ requestCount++;
1851
+ // If an api-key backend could ALSO serve this request, let the subscription
1852
+ // decline a 429/5xx rather than answer with it, and report not-served so the
1853
+ // caller falls through to that backend. This helper's contract has always
1854
+ // said it declines so the caller can continue; it just never exercised the
1855
+ // mechanism it was built on, so a rate-limited subscription ended the chain
1856
+ // with a healthy backend sitting unused beside it.
1857
+ //
1858
+ // With NO next option, do not defer: the real upstream error is more useful
1859
+ // to the client than replacing it with a generic 503.
1860
+ const hasNextOption = openaiBackend !== null && shape === 'openai';
1861
+ return await forwardToCodex(req, res, fallbackBody, creds, corsOrigin, { ...SECURITY_HEADERS, 'x-dario-pool-fallback': fallbackModel }, upstreamTimeoutMs, verbose, shape, fetch, hasNextOption);
1862
+ };
1790
1863
  const server = createServer(async (req, res) => {
1791
1864
  if (req.method === 'OPTIONS') {
1792
1865
  res.writeHead(204, CORS_HEADERS);
@@ -2305,6 +2378,29 @@ export async function startProxy(opts = {}) {
2305
2378
  * null without answering in the pool-exhausted-fallback case, which the
2306
2379
  * fallback dispatch right below picks up.
2307
2380
  */
2381
+ // The pool-unavailable 503, in one place. Two distinct empty-selection
2382
+ // cases (#599): the pool has no accounts at all (headless admin
2383
+ // bootstrap — nothing added yet), vs. it has accounts but all are
2384
+ // rate-limited / in auth cool-down. Each gets a truthful, actionable
2385
+ // message so a headless operator isn't told "rate-limited" when they
2386
+ // simply haven't added an account.
2387
+ //
2388
+ // Shared because two callers must agree: the selector, and the dispatch
2389
+ // below for a request the selector DEFERRED but no provider could serve.
2390
+ const writePoolUnavailable = () => {
2391
+ res.writeHead(503, JSON_HEADERS);
2392
+ res.end(JSON.stringify(pool.size === 0
2393
+ ? {
2394
+ error: 'No account configured',
2395
+ message: adminEnabled
2396
+ ? 'dario is running in admin mode with no account yet. Add one via POST /admin/login/start, then retry.'
2397
+ : 'No accounts available. Run `dario login`, or add accounts with `dario accounts add`.',
2398
+ }
2399
+ : {
2400
+ error: 'No accounts available in pool',
2401
+ message: 'all accounts are rate-limited or in auth cool-down; retry shortly',
2402
+ }));
2403
+ };
2308
2404
  const selectPoolAccount = () => {
2309
2405
  if (upstreamApiKey) {
2310
2406
  // Per-token API-key mode: no OAuth, no pool selection. `poolAccount`
@@ -2322,26 +2418,25 @@ export async function startProxy(opts = {}) {
2322
2418
  // openai-compat backend. An EMPTY pool still 503s: that's a setup
2323
2419
  // error the operator needs to see, not traffic to quietly re-bill
2324
2420
  // somewhere else.
2325
- const fallbackViable = poolFallbackModel !== null && openaiBackend !== null
2326
- && isOpenAI && pool.size > 0;
2421
+ // Defer whenever a fallback is ARMED and the pool has seats that
2422
+ // could be drained. WHICH provider can serve it is decided at the
2423
+ // dispatch below, not here, because answering that needs an await —
2424
+ // a Codex account's model list — and this selector is synchronous.
2425
+ //
2426
+ // Before v6.0.0 this also demanded `openaiBackend !== null && isOpenAI`,
2427
+ // which silently made the entire subscription failover unreachable:
2428
+ // an Anthropic-shape request, or a box with a Codex account and no
2429
+ // api-key backend, 503'd HERE, before the dispatcher ever ran. That is
2430
+ // exactly the deployment this release is about, so the feature was
2431
+ // dead in the configuration it was written for. Caught in review on
2432
+ // dario#1145 — the routing tests all passed, because none of them went
2433
+ // through this selector.
2434
+ //
2435
+ // An EMPTY pool still 503s: that's a setup error the operator needs to
2436
+ // see, not traffic to quietly re-bill somewhere else.
2437
+ const fallbackViable = poolFallbackModels.length > 0 && pool.size > 0;
2327
2438
  if (!fallbackViable) {
2328
- // Two distinct empty-selection cases (#599): the pool has no accounts
2329
- // at all (headless admin bootstrap — nothing added yet), vs. it has
2330
- // accounts but all are rate-limited / in auth cool-down. Give each a
2331
- // truthful, actionable message so a headless operator isn't told
2332
- // "rate-limited" when they simply haven't added an account.
2333
- res.writeHead(503, JSON_HEADERS);
2334
- res.end(JSON.stringify(pool.size === 0
2335
- ? {
2336
- error: 'No account configured',
2337
- message: adminEnabled
2338
- ? 'dario is running in admin mode with no account yet. Add one via POST /admin/login/start, then retry.'
2339
- : 'No accounts available. Run `dario login`, or add accounts with `dario accounts add`.',
2340
- }
2341
- : {
2342
- error: 'No accounts available in pool',
2343
- message: 'all accounts are rate-limited or in auth cool-down; retry shortly',
2344
- }));
2439
+ writePoolUnavailable();
2345
2440
  return false;
2346
2441
  }
2347
2442
  }
@@ -2462,6 +2557,65 @@ export async function startProxy(opts = {}) {
2462
2557
  }
2463
2558
  catch { /* not JSON — fall through */ }
2464
2559
  }
2560
+ // Shadow compare (v6.0.0) — the full rationale is in compare.ts. Armed
2561
+ // per request by `x-dario-compare: <model>`, it runs the same prompt past
2562
+ // the other model family BESIDE the real answer and keeps both. Hooked in
2563
+ // here, ahead of routing, so it covers whichever provider ends up serving.
2564
+ //
2565
+ // Nothing below is allowed to depend on it: the tee only observes bytes
2566
+ // already on their way out, the comparison is never awaited by the
2567
+ // request, and every failure path resolves rather than throws.
2568
+ const compareTarget = readCompareTarget(req.headers);
2569
+ if (compareTarget) {
2570
+ const compareShape = isOpenAI ? 'openai' : 'anthropic';
2571
+ // Snapshot the body NOW. `body` is reassigned later by the codex→Claude
2572
+ // fall-through, and the finish callback below closes over it — so a
2573
+ // comparison on a request that failed over would record the SWAPPED
2574
+ // model as `primaryModel` and the swapped payload as the "verbatim"
2575
+ // request. That record would then quietly attribute the primary answer
2576
+ // to the wrong model family, which is worse than having no record: the
2577
+ // whole point of the log is deciding which family did better.
2578
+ const compareRequestBody = Buffer.from(body);
2579
+ const tee = teeResponse(res);
2580
+ res.setHeader(COMPARE_RESULT_HEADER, compareTarget);
2581
+ // Started before the primary is dispatched so the two overlap; holding
2582
+ // it until afterwards would double the wall-clock of a comparison
2583
+ // nobody is waiting on, for no benefit.
2584
+ const running = runCompare({
2585
+ body: compareRequestBody,
2586
+ shape: compareShape,
2587
+ targetModel: compareTarget,
2588
+ corsOrigin,
2589
+ timeoutMs: upstreamTimeoutMs,
2590
+ verbose,
2591
+ });
2592
+ res.on('finish', () => {
2593
+ void running.then((result) => {
2594
+ let request = null;
2595
+ try {
2596
+ request = JSON.parse(compareRequestBody.toString());
2597
+ }
2598
+ catch { /* recorded as null */ }
2599
+ const asObj = (request ?? {});
2600
+ const written = writeCompareRecord({
2601
+ ts: new Date().toISOString(),
2602
+ path: urlPath,
2603
+ shape: compareShape,
2604
+ streaming: asObj.stream === true,
2605
+ primaryModel: typeof asObj.model === 'string' ? asObj.model : '(unknown)',
2606
+ comparedModel: compareTarget,
2607
+ request,
2608
+ primary: tee.captured(),
2609
+ compare: result.side,
2610
+ ...(result.skipped ? { skipped: result.skipped } : {}),
2611
+ });
2612
+ if (written)
2613
+ console.log(`[dario] compare vs ${compareTarget} -> ${written}`);
2614
+ else if (result.skipped)
2615
+ console.log(`[dario] compare vs ${compareTarget} skipped: ${result.skipped}`);
2616
+ });
2617
+ });
2618
+ }
2465
2619
  // Multi-provider routing (v3.6.0+). When an OpenAI-compat backend is
2466
2620
  // configured and the request is on /v1/chat/completions with a
2467
2621
  // GPT-family model (or a forced `openai:` prefix), forward it straight
@@ -2520,8 +2674,36 @@ export async function startProxy(opts = {}) {
2520
2674
  console.log(`[dario] #${requestCount} ${req.method} ${urlPath} (model: ${rawModel}) → codex account ${codexCreds.alias}`);
2521
2675
  }
2522
2676
  requestCount++;
2523
- await forwardToCodex(req, res, body, codexCreds, corsOrigin, SECURITY_HEADERS, upstreamTimeoutMs, verbose, isOpenAI ? 'openai' : 'anthropic');
2524
- return;
2677
+ // Symmetric failover (v6.0.0). When the chain nominates a model the
2678
+ // Claude pool can serve and there are seats to serve it, let the
2679
+ // subscription DECLINE a 429/5xx rather than pass it to the client,
2680
+ // and pick the request back up on the Claude path below. Before
2681
+ // this, a rate-limited ChatGPT plan was terminal for a gpt-bound
2682
+ // request even with an idle Claude pool sitting right beside it.
2683
+ const claudeTarget = pickClaudeFallback(poolFallbackModels, codexModels);
2684
+ const canDefer = claudeTarget !== null && pool.size > 0 && !upstreamApiKey;
2685
+ const served = await forwardToCodex(req, res, body, codexCreds, corsOrigin, SECURITY_HEADERS, upstreamTimeoutMs, verbose, isOpenAI ? 'openai' : 'anthropic', fetch, canDefer);
2686
+ if (served)
2687
+ return;
2688
+ const swapped = buildPoolFallbackBody(body, claudeTarget);
2689
+ if (!swapped) {
2690
+ res.writeHead(503, { 'Content-Type': 'application/json', ...SECURITY_HEADERS });
2691
+ res.end(JSON.stringify({ error: { type: 'upstream_unavailable', message: 'Codex backend unavailable and the request body could not be re-pointed at the Claude pool.' } }));
2692
+ return;
2693
+ }
2694
+ console.log(`[dario] #${requestCount} codex unavailable -> claude pool as ${claudeTarget}`);
2695
+ // Copy so the type matches the ArrayBuffer-backed buffer this
2696
+ // handler threads through (Buffer.concat's), as the other in-place
2697
+ // body rewrites above already do.
2698
+ body = Buffer.from(swapped);
2699
+ // The Claude path prefers this cached parse over `body`. Leaving it
2700
+ // stale would send the OLD model upstream while every log line and
2701
+ // the response header claimed the swap happened — a failure that
2702
+ // reads as a success, which is the exact bug class this release
2703
+ // spent its whole review budget hunting.
2704
+ parsedBody = null;
2705
+ res.setHeader('x-dario-pool-fallback', claudeTarget);
2706
+ // fall through to Claude's turn below
2525
2707
  }
2526
2708
  if (rawModel && openaiBackend && decision.provider === 'openai') {
2527
2709
  if (verbose) {
@@ -2548,14 +2730,20 @@ export async function startProxy(opts = {}) {
2548
2730
  // response carries `x-dario-pool-fallback` — a substituted model must
2549
2731
  // never be silent. GPT-bound requests never reach here (the routing
2550
2732
  // block above already forwarded them; they don't need the pool).
2551
- if (!upstreamApiKey && !poolAccount && poolFallbackModel && openaiBackend) {
2733
+ if (!upstreamApiKey && !poolAccount && await tryCodexPoolFallback(req, res, body, poolFallbackModels, isOpenAI ? 'openai' : 'anthropic', 'pool exhausted')) {
2734
+ return;
2735
+ }
2736
+ // `isOpenAI` is REQUIRED here and was not, before v6.0.0 — the selector's
2737
+ // own isOpenAI check was the only thing keeping Anthropic-shape requests
2738
+ // out of this branch. Relaxing the selector without moving that guard down
2739
+ // would forward a /v1/messages request to an openai-compat backend and
2740
+ // hand the client an OpenAI-shaped response for a Messages request. This
2741
+ // route still has no reverse translation; the codex route above does,
2742
+ // which is why it takes both shapes and this one does not.
2743
+ if (!upstreamApiKey && !poolAccount && poolFallbackModel && openaiBackend && isOpenAI) {
2552
2744
  const fallbackBody = buildPoolFallbackBody(body, poolFallbackModel);
2553
2745
  if (!fallbackBody) {
2554
- res.writeHead(503, JSON_HEADERS);
2555
- res.end(JSON.stringify({
2556
- error: 'No accounts available in pool',
2557
- message: 'all accounts are rate-limited or in auth cool-down; retry shortly',
2558
- }));
2746
+ writePoolUnavailable();
2559
2747
  return;
2560
2748
  }
2561
2749
  console.log(`[dario] #${requestCount} pool exhausted — /v1/chat/completions → ${openaiBackend.name} as ${poolFallbackModel}`);
@@ -2563,6 +2751,17 @@ export async function startProxy(opts = {}) {
2563
2751
  await forwardToOpenAI(req, res, fallbackBody, openaiBackend, corsOrigin, { ...SECURITY_HEADERS, 'x-dario-pool-fallback': poolFallbackModel }, upstreamTimeoutMs, verbose);
2564
2752
  return;
2565
2753
  }
2754
+ // Deferred at selection because a fallback was armed, but nothing could
2755
+ // actually serve it — no Codex account lists the model, and either there
2756
+ // is no api-key backend or this is the wrong wire shape for one. Answer
2757
+ // with the truth the selector used to give. Without this the request
2758
+ // would fall through to the Claude path with no account and an empty
2759
+ // bearer token, turning a clean 503 into a confusing upstream 401.
2760
+ if (!upstreamApiKey && !poolAccount) {
2761
+ console.log(`[dario] #${requestCount} pool exhausted and no fallback provider could serve ${poolFallbackModels.join(', ') || '(none configured)'}`);
2762
+ writePoolUnavailable();
2763
+ return;
2764
+ }
2566
2765
  // Parse body once, apply OpenAI translation, model override, and sanitization
2567
2766
  let finalBody = body.length > 0 ? body : undefined;
2568
2767
  let ccToolMap = null;
@@ -3429,6 +3628,9 @@ export async function startProxy(opts = {}) {
3429
3628
  // bytes — the Anthropic translation went into finalBody, never
3430
3629
  // back into body. Marked via x-dario-pool-fallback, same as the
3431
3630
  // selection-time path.
3631
+ if (await tryCodexPoolFallback(req, res, body, poolFallbackModels, isOpenAI ? 'openai' : 'anthropic', 'pool exhausted mid-flight (429, no peer)')) {
3632
+ return;
3633
+ }
3432
3634
  if (isOpenAI && poolFallbackModel && openaiBackend) {
3433
3635
  const fallbackBody = buildPoolFallbackBody(body, poolFallbackModel);
3434
3636
  if (fallbackBody) {
package/docs/commands.md CHANGED
@@ -37,7 +37,7 @@ This page is the per-flag reference. For environment variables grouped by task
37
37
  | `--verbose` / `-v` | Log every request (one line per request — method + path + billing bucket) | off |
38
38
  | `--verbose=2` / `-vv` / `DARIO_LOG_BODIES=1` | Also dump the outbound request body (redacted: bearer tokens, `sk-ant-*` keys, JWTs stripped; capped at 8KB). For wire-level client-compat debugging. | off |
39
39
  | `--log-file=<path>` / `DARIO_LOG_FILE` | Append one JSON-ND record per completed request to PATH. Useful for backgrounded proxies where stdout is unobserved (where `--verbose` can't help). Field set: `ts`, `req`, `method`, `path`, `model`, `status`, `latency_ms`, `in_tokens`, `out_tokens`, `cache_read`, `cache_create`, `claim`, `bucket`, `account`, `client`, `preserve_tools`, `stream`, plus `reject` / `error` on failure paths. Secrets scrubbed via the same redactor that `--verbose-bodies` uses; no request bodies. | off |
40
- | `--pool-fallback=<model>` / `DARIO_POOL_FALLBACK` / config `poolFallback.model` | Strictly opt-in. When every pool seat is drained or in auth cool-down, forward OpenAI-shape requests (`/v1/chat/completions`) to the configured openai-compat backend as `<model>` instead of surfacing the 429/503. Every fallback response carries `x-dario-pool-fallback: <model>` — never silent. Anthropic-shape requests keep the error (no reverse response translation). Requires an openai-compat backend (`dario backend add …`); inert without one. Empty pool still 503s (setup error, not traffic to re-bill). Empty flag value disables, overriding env + config. See [Pool-exhausted fallback](./multi-account-pool.md#pool-exhausted-fallback). | off |
40
+ | `--pool-fallback=<models>` / `DARIO_POOL_FALLBACK` / config `poolFallback.model` | Strictly opt-in. When every pool seat is drained or in auth cool-down (at selection, or mid-flight on a 429 with no peer left), serve the request as `<model>` from whichever provider can, instead of surfacing the 429/503. Accepts a **chain** — `gpt-5.6-sol,claude-sonnet-5` — read left to right, each provider taking the first entry it can serve. A Codex/ChatGPT subscription that lists the model is preferred (no per-token cost) and works on **both** wire shapes; otherwise a configured openai-compat backend, which is still OpenAI-path only (no Messages translation on that route). A chain also makes failover **symmetric**: a rate-limited or failing subscription hands the request back to the Claude pool. Only a 429/5xx fails over — a 400 surfaces, since a bad request would just reproduce itself elsewhere. Every substituted response carries `x-dario-pool-fallback: <model>` — never silent. Needs a Codex account (`dario add altman`) or a backend (`dario backend add …`); `dario doctor` reports it as INERT with neither. Empty pool still 503s (setup error, not traffic to re-bill). Empty flag value disables, overriding env + config. See [Pool-exhausted fallback](./multi-account-pool.md#pool-exhausted-fallback). | off |
41
41
  | `--passthrough-betas=<csv>` / `DARIO_PASSTHROUGH_BETAS` | Beta flags ALWAYS forwarded upstream regardless of CC's captured set or the client's `anthropic-beta` header. Bypasses the billable-beta filter (so `extended-cache-ttl-*` survives if you opt in). Per-account rejection cache still applies — a pinned flag the upstream 400's gets dropped on retry rather than re-sent forever. Use when you know a beta works on your account but isn't in the captured template, or when client traffic should be force-augmented. Empty flag value (`--passthrough-betas=`) clears the env-default. | off |
42
42
  | `--strict-tls` / `DARIO_STRICT_TLS=1` | Refuse to start proxy mode unless runtime classifies as `bun-match` — i.e. the TLS ClientHello matches CC's. See [Wire-fidelity axes](./wire-fidelity.md). (v3.23) | off |
43
43
  | `--pace-min=<ms>` / `DARIO_PACE_MIN_MS` | Minimum inter-request gap in ms. Replaces the legacy hardcoded 500 ms. (v3.24) | `500` |
@@ -48,7 +48,7 @@ This page is the per-flag reference. For environment variables grouped by task
48
48
  | `--session-max-age=<ms>` / `DARIO_SESSION_MAX_AGE_MS` | Hard ceiling on a session-id's lifetime regardless of activity. (v3.28) | off |
49
49
  | `--session-per-client` / `DARIO_SESSION_PER_CLIENT=1` | Split session-id registry by a per-client header so multi-UI fan-out doesn't collapse onto one id. (v3.28) | off |
50
50
  | `--pool-strategy=<headroom\|fill-first>` / `DARIO_POOL_STRATEGY` | Where new conversations land in a multi-account pool. `headroom` spreads them to the seat with the most slack; `fill-first` concentrates them on the alphabetically-first eligible seat until it drains to the 2% floor, then spills to the next — primary/backup semantics, alias naming (`1-main`, `2-overflow`) picks the fill order. Sticky bindings behave identically under both. See [Multi-account pool](./multi-account-pool.md#routing-strategy). | `headroom` |
51
- | `--pool-fallback=<model>` / `DARIO_POOL_FALLBACK` / config `poolFallback.model` | When every pool seat is drained or in auth cool-down (at selection, or mid-flight on a 429 with no peer left), forward **OpenAI-shape** requests (`/v1/chat/completions`) to the configured openai-compat backend as `<model>` instead of surfacing the 429/503. Every substituted response carries `x-dario-pool-fallback: <model>` — a swapped model is never silent. Anthropic-shape requests (`/v1/messages`) keep the error: dario has no OpenAI→Anthropic response translation. Requires `dario backend add …`; inert otherwise. Empty flag value (`--pool-fallback=`) disables, overriding env + config. See [Multi-account pool](./multi-account-pool.md#pool-exhausted-fallback). | off |
51
+ | `--pool-fallback=<models>` / `DARIO_POOL_FALLBACK` / config `poolFallback.model` | Strictly opt-in. When every pool seat is drained or in auth cool-down (at selection, or mid-flight on a 429 with no peer left), serve the request as `<model>` from whichever provider can, instead of surfacing the 429/503. Accepts a **chain** `gpt-5.6-sol,claude-sonnet-5` — read left to right, each provider taking the first entry it can serve. A Codex/ChatGPT subscription that lists the model is preferred (no per-token cost) and works on **both** wire shapes; otherwise a configured openai-compat backend, which is still OpenAI-path only (no Messages translation on that route). A chain also makes failover **symmetric**: a rate-limited or failing subscription hands the request back to the Claude pool. Only a 429/5xx fails over — a 400 surfaces, since a bad request would just reproduce itself elsewhere. Every substituted response carries `x-dario-pool-fallback: <model>` — never silent. Needs a Codex account (`dario add altman`) or a backend (`dario backend add …`); `dario doctor` reports it as INERT with neither. Empty pool still 503s (setup error, not traffic to re-bill). Empty flag value disables, overriding env + config. See [Pool-exhausted fallback](./multi-account-pool.md#pool-exhausted-fallback). | off |
52
52
  | `--system-prompt=<verbatim\|partial\|aggressive\|filepath>` / `DARIO_SYSTEM_PROMPT` | System-prompt mode for outbound CC-shaped requests. `partial` strips behavioral constraints (Tone-and-style, Text-output, scope/verbosity/comment bullets) for ~1.2–2.8× output capability on open-ended work. `aggressive` adds prompt-level RLHF restatement removal (<3% over partial — alignment is RLHF-trained). `<filepath>` fully replaces the slot with file contents. Empirically validated as unfingerprinted by the billing classifier — see [`system-prompt.md`](./system-prompt.md) and [`research/system-prompt-classifier-study.md`](./research/system-prompt-classifier-study.md). (v3.34) | `verbatim` |
53
53
  | `--upstream-proxy=<url>` / `--via=<url>` / `DARIO_UPSTREAM_PROXY` | Route dario's outbound fetches (api.anthropic.com, OpenAI-compat backends, OAuth) through an HTTP/HTTPS proxy. Pair with the HTTP proxy mode of a VPN provider (Mullvad, AirVPN), a corporate proxy, privoxy/Tor, etc. Localhost calls bypass. Requires Bun runtime; SOCKS5 not supported. Full provider matrix + setup in [`vpn-routing.md`](./vpn-routing.md). (v3.35) | unset |
54
54
  | `DARIO_API_KEY` | If set, all endpoints (except `/health`) require a matching `x-api-key` or `Authorization: Bearer` header. Required when `--host` binds non-loopback. | unset (open) |
@@ -41,13 +41,39 @@ Multi-turn agent sessions pin to one account for the life of the conversation, s
41
41
 
42
42
  ## Pool-exhausted fallback
43
43
 
44
- `--pool-fallback=<model>` (env `DARIO_POOL_FALLBACK`, config `poolFallback.model`) is a strictly opt-in escape hatch for when the whole pool is drained or cooling. With it set **and** an openai-compat backend configured (`dario backend add …`), an OpenAI-shape request (`/v1/chat/completions`) that the pool can't serve — at selection time, or after a mid-flight 429 with no peer left — is forwarded to that backend with the model swapped to `<model>`, instead of returning the 429/503.
44
+ `--pool-fallback=<models>` (env `DARIO_POOL_FALLBACK`, config `poolFallback.model`) is a strictly opt-in escape hatch for when a provider can't serve. A request the Claude pool can't take — at selection time, or after a mid-flight 429 with no peer left — is served as the nominated model by whichever provider can, instead of returning the 429/503.
45
45
 
46
- Three deliberate limits:
46
+ The value may be a **chain**, read left to right, each provider taking the first entry it can actually serve:
47
47
 
48
- - **OpenAI-shape only.** Anthropic-shape requests (`/v1/messages`) keep the error. dario translates Anthropic→OpenAI on the way out but has no OpenAI→Anthropic *response* translator, so a fallback there would corrupt the client's stream. Point Anthropic-native clients that want this at `/v1/chat/completions`, or leave the fallback off.
49
- - **Never silent.** Every substituted response carries `x-dario-pool-fallback: <model>`. A quietly swapped model is exactly the surprise this project exists to avoid — check for the header if you need to know which requests fell back.
48
+ ```bash
49
+ dario proxy --pool-fallback=gpt-5.6-sol,claude-sonnet-5
50
+ ```
51
+
52
+ - Claude pool drained → served as `gpt-5.6-sol` from your ChatGPT subscription.
53
+ - Subscription rate-limited or down → handed back to the Claude pool as `claude-sonnet-5`.
54
+
55
+ Neither subscription hitting its ceiling can take the deployment down on its own. A single-entry chain is one-way and behaves exactly as it did before v6.0.0.
56
+
57
+ **Since v6.0.0 a subscription is a first-class failover target**, on both wire shapes. Before that the only target was an api-key backend on `/v1/chat/completions`, which made failover inert for anyone whose second provider is a ChatGPT plan — and left Anthropic-shape clients (Claude Code, the Anthropic SDKs, agent runtimes) with nowhere to go at all.
58
+
59
+ Deliberate limits:
60
+
61
+ - **Only a 429 or 5xx fails over.** A 400 surfaces to the client. A bad request that fails over just reproduces itself on the other provider and buries the real cause.
62
+ - **The Claude entry must be a real `claude-*` id.** "Not a codex slug" would also match a typo or a model meant for a third provider, and swapping that in trades a recoverable 429 for an unrecoverable 404. Anything that doesn't look like an Anthropic model is ignored — failing closed. A `--model-alias` is not accepted here; name the real id.
63
+ - **The api-key backend is still OpenAI-shape only.** There is no Messages translation on that route. A Codex account has one, which is why it is preferred.
64
+ - **Never silent.** Every substituted response carries `x-dario-pool-fallback: <model>`. A quietly swapped model is exactly the surprise this project exists to avoid.
50
65
  - **Empty pool still errors.** A pool with zero accounts is a setup mistake (`dario login` never ran); that returns the usual 503 rather than silently re-billing every request to another provider.
66
+ - **Strictly opt-in.** Without the flag, a drained pool returns its honest 429/503.
67
+
68
+ `dario doctor` reports which state you are actually in — including *armed but INERT*, meaning a fallback is configured with no provider able to serve it:
69
+
70
+ ```
71
+ [ OK ] Failover symmetric: gpt-5.6-sol → claude-sonnet-5, across 1 Codex account
72
+ [WARN] Failover armed (gpt-5.6-sol) but INERT — no Codex account and no backend
73
+ to fall back to. Add one: `dario add altman`
74
+ ```
75
+
76
+ An api-key backend still works as a target, on the OpenAI path:
51
77
 
52
78
  ```bash
53
79
  dario backend add openrouter --key=sk-or-... --base-url=https://openrouter.ai/api/v1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "5.5.89",
3
+ "version": "6.0.0",
4
4
  "description": "Use your Claude Pro/Max subscription in any tool — Cursor, Cline, Aider, the Agent SDK, your scripts — at subscription pricing, not per-token API bills. One local Anthropic + OpenAI-compatible endpoint.",
5
5
  "type": "module",
6
6
  "bin": {