@askalf/dario 6.15.0 → 6.17.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.
Files changed (44) hide show
  1. package/README.md +19 -21
  2. package/dist/analytics-ui.html +835 -0
  3. package/dist/analytics.d.ts +26 -1
  4. package/dist/analytics.js +51 -3
  5. package/dist/body-identity.d.ts +25 -0
  6. package/dist/body-identity.js +68 -0
  7. package/dist/cc-template.js +3 -14
  8. package/dist/cli.d.ts +7 -0
  9. package/dist/cli.js +44 -7
  10. package/dist/codex-backend.js +12 -4
  11. package/dist/config-file.d.ts +1 -1
  12. package/dist/config-file.js +2 -2
  13. package/dist/credits-rung.d.ts +72 -0
  14. package/dist/credits-rung.js +133 -0
  15. package/dist/doctor-core.d.ts +2 -0
  16. package/dist/doctor-core.js +47 -10
  17. package/dist/donuts.d.ts +18 -17
  18. package/dist/donuts.js +46 -54
  19. package/dist/ledger.d.ts +20 -0
  20. package/dist/ledger.js +40 -1
  21. package/dist/metrics.js +2 -1
  22. package/dist/midstream.js +6 -2
  23. package/dist/openai-backend.js +8 -3
  24. package/dist/pool.d.ts +18 -1
  25. package/dist/pool.js +31 -10
  26. package/dist/proxy.d.ts +14 -1
  27. package/dist/proxy.js +351 -105
  28. package/dist/relaunch-signals.d.ts +35 -0
  29. package/dist/relaunch-signals.js +41 -0
  30. package/dist/stream-drain.d.ts +4 -3
  31. package/dist/stream-drain.js +4 -3
  32. package/dist/tui/tabs/config.js +2 -2
  33. package/dist/upstream-deadline.d.ts +25 -0
  34. package/dist/upstream-deadline.js +76 -0
  35. package/docs/analytics.md +10 -8
  36. package/docs/api-equivalent-spend.md +9 -2
  37. package/docs/commands.md +1 -1
  38. package/docs/configuration.md +1 -0
  39. package/docs/faq.md +5 -0
  40. package/docs/multi-account-pool.md +3 -1
  41. package/docs/pool-keys-and-analytics.md +2 -0
  42. package/docs/two-plans.md +2 -0
  43. package/docs/who-its-for.md +2 -2
  44. package/package.json +8 -4
package/dist/proxy.js CHANGED
@@ -13,15 +13,16 @@ import { getServingProbe } from './serving-probe.js';
13
13
  import { darioVersion } from './version.js';
14
14
  import { CC_TOOL_DEFINITIONS_UNADVERTISABLE, CC_TEMPLATE_PROMPT_BYTES, resolveMaxTokens, buildCCRequest, applyCcPromptCaching, isGenuineCCClient, parseEffortSuffix, reverseMapResponse, createStreamingReverseMapper, orderHeadersForOutbound, overlayTemplateHeaderValues, forwardClientCCIdentityHeaders, isMcpToolName, UnsupportedClientToolError, CLIENT_TOOL_MCP_PREFIX, CC_TEMPLATE, effectiveCacheControl, withForced1hBeta } from './cc-template.js';
15
15
  import { stampCch, hasCchSeed } from './cch.js';
16
+ import { rebindBodyIdentity } from './body-identity.js';
16
17
  import { foldTiming, timingHeaders, timingLogFields } from './timing.js';
17
18
  import { describeTemplate, detectDrift, checkCCCompat, probeInstalledCCVersion } from './live-fingerprint.js';
18
19
  import { AccountPool, computeStickyKey, parseRateLimits, modelFamily, isInAuthCooldown, authCooldownMs, accountIneligibility, reportedAccountStatus, activeParkedBuckets, reconcilePoolAccounts, resolvePoolStrategy, resolvePoolHeadroomFloor, parsePoolHeadroomFloor, describePoolStrategy, utilFreshness, rateLimitWindow, accountAction, accountPeers, distinctAccounts, describeRejection, maskEmail } from './pool.js';
19
20
  import { backfillIdentity } from './accounts.js';
20
21
  import { PoolSync, DEFAULT_POOL_SYNC_INTERVAL_MS } from './pool-sync.js';
21
- import { Analytics, billingBucketFromClaim, costOfTokensFailClosed, formatUsageLogLine, SUBSCRIPTION_CLAIMS, consumerFromHeader, consumerFromBody, CONSUMER_HEADER, CODEX_CLAIM } from './analytics.js';
22
+ import { Analytics, billingBucketFromClaim, costOfTokensFailClosed, formatUsageLogLine, SUBSCRIPTION_CLAIMS, consumerFromHeader, consumerFromBody, CONSUMER_HEADER, CODEX_CLAIM, CREDITS_CLAIM } from './analytics.js';
22
23
  import { Ledger, resolveLedgerPath, ledgerDisabledByEnv } from './ledger.js';
23
24
  import { renderPrometheus } from './metrics.js';
24
- import { renderSpendDonuts, renderAnalyticsView, ANALYTICS_UI_SHELL } from './donuts.js';
25
+ import { renderSpendDonuts, renderAnalyticsView, analyticsUiPage } from './donuts.js';
25
26
  import { KeyStore, keyAllowsModel, resolveKeysPath, looksLikeNamedKey, budgetVerdict, budgetHeaders, requestBudgetReservation, addReservation, subtractReservation, EMPTY_RESERVATION } from './keys.js';
26
27
  import { OverageGuard, buildHaltErrorBody } from './overage-guard.js';
27
28
  import { notify as osNotify } from './notify.js';
@@ -55,10 +56,12 @@ import { codexUsageView, resolveCodexUsagePollMs } from './codex-usage.js';
55
56
  import { seedStaleCodexUsage } from './codex-backend.js';
56
57
  import { route as routeProvider } from './provider-adapter.js';
57
58
  import { selectPoolFallbackModels } from './pool-fallback-tier.js';
59
+ import { ACCOUNT_KEY_CREDITS, creditsBeforePlain, creditsEntries, creditsFallbackMarker, creditsHeaders, describeCreditsRung, pickCreditsTarget, withoutCreditsEntries } from './credits-rung.js';
58
60
  import { parseFamilyFallback, familyFallbackModel, describeFamilyFallback } from './family-fallback.js';
59
61
  import { RequestQueue, QueueFullError, QueueTimeoutError, DEFAULT_MAX_CONCURRENT, DEFAULT_MAX_QUEUED, DEFAULT_QUEUE_TIMEOUT_MS, resolveMaxConcurrent } from './request-queue.js';
60
62
  import { waitForIdle, drainThenClose, shutdownTimers, DEFAULT_SHUTDOWN_GRACE_MS } from './shutdown-drain.js';
61
63
  import { clampTimerMs } from './timer-ms.js';
64
+ import { upstreamDeadline } from './upstream-deadline.js';
62
65
  import { redactSecrets } from './redact.js';
63
66
  import { BAKED_BASE_MODELS, withLongContextVariants, buildOpenAIModelsList, getModelCatalog, getCachedBases, resolveAliasAgainst, prewarmModelCatalog, retryModelCatalogNow, isSuspendedModel } from './model-catalog.js';
64
67
  import { classifyUpstreamRejection, diagnosticSnippet, POOL_PARKED } from './upstream-rejection.js';
@@ -66,7 +69,9 @@ import { ProviderCooldowns, canAttempt, allProvidersCooled, cooldownRetryAfterMs
66
69
  const ANTHROPIC_API = 'https://api.anthropic.com';
67
70
  const DEFAULT_PORT = 3456;
68
71
  const MAX_BODY_BYTES = 10 * 1024 * 1024; // 10 MB — generous for large prompts, prevents abuse
69
- const UPSTREAM_TIMEOUT_MS = 300_000; // 5 min — matches Anthropic SDK default
72
+ // Until the response streams: the whole wait. While a 2xx streams: the longest
73
+ // gap between two chunks (src/upstream-deadline.ts). 5 min, as in the Anthropic SDK.
74
+ const UPSTREAM_TIMEOUT_MS = 300_000;
70
75
  const BODY_READ_TIMEOUT_MS = 30_000; // 30s — prevents slow-loris on body reads
71
76
  const DEFAULT_HOST = '127.0.0.1';
72
77
  // A host is "loopback" if it's one of the well-known localhost literals.
@@ -1345,6 +1350,9 @@ export async function startProxy(opts = {}) {
1345
1350
  const upstreamFetch = chaosCut && chaosState ? chaosCutFetch(opts.fetchImpl ?? fetch, chaosCut, chaosState) : (opts.fetchImpl ?? fetch);
1346
1351
  const codexFetch = chaosCut && chaosState ? chaosCutFetch(fetch, chaosCut, chaosState) : fetch;
1347
1352
  const upstreamApiKey = (opts.upstreamApiKey ?? process.env.ANTHROPIC_UPSTREAM_API_KEY ?? '').trim();
1353
+ // The credits rung's key (credits-rung.ts). Read once here; whether it is
1354
+ // ever used depends on the chain naming a `credits:<model>` entry.
1355
+ const creditsApiKey = (opts.creditsApiKey ?? process.env.DARIO_CREDITS_API_KEY ?? '').trim();
1348
1356
  if (upstreamApiKey)
1349
1357
  console.error('[dario] upstream auth: per-token API key (x-api-key) — OAuth/Max + account pool bypassed');
1350
1358
  else if (ignoreCcCredentials())
@@ -1551,17 +1559,37 @@ export async function startProxy(opts = {}) {
1551
1559
  }
1552
1560
  };
1553
1561
  if (poolFallbackModel) {
1562
+ // The rungs in the order they are asked: the subscription first, then the
1563
+ // metered rungs (the credits key and the openai-compat backend) in the
1564
+ // chain's own order. A `credits:` entry is not a model the other rungs
1565
+ // can serve, so their model is the first plain entry.
1566
+ const plainFallbackModel = withoutCreditsEntries(poolFallbackModels)[0] ?? null;
1567
+ const creditsLine = describeCreditsRung(poolFallbackModels, creditsApiKey.length > 0);
1568
+ const creditsArmed = creditsLine !== null && creditsApiKey.length > 0;
1554
1569
  const targets = [];
1555
- if (startupCodexAliases.length > 0)
1556
- targets.push(`codex subscription (${startupCodexAliases.join(', ')}) — both wire shapes`);
1557
- if (openaiBackend)
1558
- targets.push(`${openaiBackend.name} — OpenAI shape only`);
1570
+ if (startupCodexAliases.length > 0 && plainFallbackModel)
1571
+ targets.push(`codex subscription (${startupCodexAliases.join(', ')}) as ${plainFallbackModel}, both wire shapes`);
1572
+ const metered = [];
1573
+ if (openaiBackend && plainFallbackModel)
1574
+ metered.push(`${openaiBackend.name} as ${plainFallbackModel}, OpenAI shape only`);
1575
+ if (creditsArmed) {
1576
+ if (creditsBeforePlain(poolFallbackModels))
1577
+ metered.unshift(creditsLine);
1578
+ else
1579
+ metered.push(creditsLine);
1580
+ }
1581
+ targets.push(...metered);
1559
1582
  if (targets.length > 0) {
1560
- console.log(` Pool fallback: exhausted-pool requests → ${targets.join(', then ')} as ${poolFallbackModel} (marked x-dario-pool-fallback)`);
1583
+ console.log(` Pool fallback: exhausted-pool requests → ${targets.join(', then ')} (marked x-dario-pool-fallback)`);
1561
1584
  }
1562
1585
  else {
1563
- 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.');
1586
+ console.warn('[dario] --pool-fallback is set but there is nothing to fall back TO: add a Codex account (`dario codex add …`), an OpenAI-compat backend (`dario backend add …`), or a credits:<model> entry with DARIO_CREDITS_API_KEY set. Fallback is inert.');
1564
1587
  }
1588
+ if (creditsLine && !creditsArmed)
1589
+ console.warn(`[dario] --pool-fallback ${creditsLine}`);
1590
+ }
1591
+ else if (creditsApiKey) {
1592
+ console.warn('[dario] DARIO_CREDITS_API_KEY is set but no --pool-fallback entry names credits:<model>, so nothing spends it');
1565
1593
  }
1566
1594
  // Family fallback (strictly opt-in, family-fallback.ts). Bad entries are
1567
1595
  // dropped with a warning; the rest still apply.
@@ -2475,7 +2503,9 @@ export async function startProxy(opts = {}) {
2475
2503
  //
2476
2504
  // With NO next option and no peer, do not defer: the real upstream error is
2477
2505
  // more useful to the client than replacing it with a generic 503.
2478
- const hasNextOption = openaiBackend !== null && shape === 'openai';
2506
+ // An armed credits rung is a next option on both wire shapes.
2507
+ const hasNextOption = (openaiBackend !== null && shape === 'openai')
2508
+ || (creditsApiKey.length > 0 && creditsEntries(fallbackModels).length > 0);
2479
2509
  // The next seat that could serve one of these fallback models, excluding
2480
2510
  // everything already tried. `peek` is the cached read, so the scan costs no
2481
2511
  // upstream call; a seat whose model list is unknown is still worth a try.
@@ -2573,21 +2603,37 @@ export async function startProxy(opts = {}) {
2573
2603
  * bug structurally impossible to reintroduce: a future third call site gets
2574
2604
  * this by construction, not by remembering to copy six lines correctly.
2575
2605
  */
2576
- const attemptPoolFallbackOn429 = async (req, res, body, isOpenAI, attempted, timing) => {
2577
- if (await tryCodexPoolFallback(req, res, body, selectPoolFallbackForBody(body), isOpenAI ? 'openai' : 'anthropic', 'pool exhausted mid-flight (429, no peer)', attempted, timing)) {
2578
- return true;
2606
+ const attemptPoolFallbackOn429 = async (req, res, body, isOpenAI, attempted, timing,
2607
+ /**
2608
+ * Re-arm THIS request on the credits rung (credits-rung.ts): returns true
2609
+ * once the request is re-pointed at the key, and the caller re-enters its
2610
+ * dispatch loop. Absent on a site that cannot re-dispatch.
2611
+ */
2612
+ armCredits) => {
2613
+ const chain = selectPoolFallbackForBody(body);
2614
+ if (await tryCodexPoolFallback(req, res, body, chain, isOpenAI ? 'openai' : 'anthropic', 'pool exhausted mid-flight (429, no peer)', attempted, timing)) {
2615
+ return 'served';
2579
2616
  }
2580
- const fallbackModel = selectPoolFallbackForBody(body)[0] ?? null;
2581
- if (isOpenAI && fallbackModel && openaiBackend) {
2582
- const fallbackBody = buildPoolFallbackBody(body, fallbackModel);
2617
+ // The metered rungs, in the chain's own order. The credits rung is never
2618
+ // asked twice in one request: a 429 from the key falls through to the
2619
+ // openai-compat backend or to the honest 429.
2620
+ const plainModel = withoutCreditsEntries(chain)[0] ?? null;
2621
+ const creditsFirst = creditsBeforePlain(chain);
2622
+ const tryCredits = () => armCredits !== undefined && !attempted.has('credits') && armCredits();
2623
+ if (creditsFirst && tryCredits())
2624
+ return 'credits';
2625
+ if (isOpenAI && plainModel && openaiBackend) {
2626
+ const fallbackBody = buildPoolFallbackBody(body, plainModel);
2583
2627
  if (fallbackBody) {
2584
- console.log(`[dario] #${requestCount} pool exhausted mid-flight (429, no peer) → ${openaiBackend.name} as ${fallbackModel}`);
2628
+ console.log(`[dario] #${requestCount} pool exhausted mid-flight (429, no peer) → ${openaiBackend.name} as ${plainModel}`);
2585
2629
  requestCount++;
2586
- await forwardToOpenAI(req, res, fallbackBody, openaiBackend, corsOrigin, { ...SECURITY_HEADERS, 'x-dario-pool-fallback': fallbackModel }, upstreamTimeoutMs, verbose);
2587
- return true;
2630
+ await forwardToOpenAI(req, res, fallbackBody, openaiBackend, corsOrigin, { ...SECURITY_HEADERS, 'x-dario-pool-fallback': plainModel }, upstreamTimeoutMs, verbose);
2631
+ return 'served';
2588
2632
  }
2589
2633
  }
2590
- return false;
2634
+ if (!creditsFirst && tryCredits())
2635
+ return 'credits';
2636
+ return 'none';
2591
2637
  };
2592
2638
  const server = createServer(async (req, res) => {
2593
2639
  // The request's first stamp (src/timing.ts): every split below is
@@ -2825,11 +2871,12 @@ export async function startProxy(opts = {}) {
2825
2871
  if (handled)
2826
2872
  return;
2827
2873
  }
2828
- // The dashboard shell carries no data, so it needs no credential: it is
2829
- // the page that ASKS for the token and then fetches /analytics/view.
2874
+ // The dashboard page carries no data, so it needs no credential: it asks
2875
+ // for the token, then reads /analytics, /accounts, /codex, /health, the
2876
+ // ledger and the keys with it and tails /analytics/stream (donuts.ts).
2830
2877
  if (urlPath === '/analytics/ui' && req.method === 'GET') {
2831
2878
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', ...SECURITY_HEADERS });
2832
- res.end(ANALYTICS_UI_SHELL);
2879
+ res.end(analyticsUiPage());
2833
2880
  return;
2834
2881
  }
2835
2882
  // Read-only analytics token: accepted on the read-only surfaces only, and
@@ -3009,7 +3056,11 @@ export async function startProxy(opts = {}) {
3009
3056
  // `queue` rides along the summary (dario#905): request-queue.ts always
3010
3057
  // documented snapshot() as "exposed for /analytics", but it was never
3011
3058
  // actually wired in, so slot exhaustion was invisible from outside.
3012
- res.end(JSON.stringify({ ...analytics.summary(), queue: queue.snapshot(), lifetime: ledger ? ledger.summary() : null, budgets: keyBudgetsSnapshot() }));
3059
+ // `overage` is the halt state for every reader of this route (the
3060
+ // dashboard, dario#1734): the stream replays a halt only while one is
3061
+ // in force and the analytics token cannot read /admin/resume, so a
3062
+ // viewer that saw the halt and missed the resume resyncs from here.
3063
+ res.end(JSON.stringify({ ...analytics.summary(), queue: queue.snapshot(), overage: { halted: overageGuard.isHalted(), state: overageGuard.state() }, lifetime: ledger ? ledger.summary() : null, budgets: keyBudgetsSnapshot() }));
3013
3064
  return;
3014
3065
  }
3015
3066
  // Prometheus text exposition of the same state (dario#1341). A view, not
@@ -3048,7 +3099,9 @@ export async function startProxy(opts = {}) {
3048
3099
  return;
3049
3100
  }
3050
3101
  // The ledger's per-day table, for anyone charting it. `lifetime` on
3051
- // /analytics is the summary; this is the data behind it.
3102
+ // /analytics is the summary; this is the data behind it. `priced` is the
3103
+ // same rows as USD at each day's rate (ledger.ts), so a reader, the
3104
+ // dashboard first, never needs a price list of its own.
3052
3105
  if (urlPath === '/analytics/ledger' && req.method === 'GET') {
3053
3106
  if (!ledger) {
3054
3107
  res.writeHead(404, JSON_HEADERS);
@@ -3056,7 +3109,7 @@ export async function startProxy(opts = {}) {
3056
3109
  return;
3057
3110
  }
3058
3111
  res.writeHead(200, JSON_HEADERS);
3059
- res.end(JSON.stringify({ path: ledger.path, ...ledger.snapshot() }));
3112
+ res.end(JSON.stringify({ path: ledger.path, ...ledger.snapshot(), priced: ledger.priced() }));
3060
3113
  return;
3061
3114
  }
3062
3115
  // Analytics live stream — SSE of new RequestRecord JSON, one event
@@ -3368,6 +3421,17 @@ export async function startProxy(opts = {}) {
3368
3421
  // form gives the declared type without seeding a narrowing.
3369
3422
  let poolAccount = null;
3370
3423
  let accessToken = '';
3424
+ // Set when the credits rung (credits-rung.ts) is serving this request:
3425
+ // the Claude path then runs on the key instead of a seat. The same `as`
3426
+ // form as poolAccount, for the same narrowing reason.
3427
+ let creditsRung = null;
3428
+ // The account an analytics record names: the seat, the credits rung, or
3429
+ // the api-key slot. A function because a mid-flight 429 can move the
3430
+ // request from a seat to the credits rung after the first attempt.
3431
+ const accountKeyNow = () => creditsRung ? ACCOUNT_KEY_CREDITS : (poolAccount?.alias ?? ACCOUNT_KEY_APIKEY);
3432
+ // The claim a record carries: Anthropic sends none on an API key
3433
+ // request, so the credits rung stamps its own, as the codex engine does.
3434
+ const claimNow = (rl) => creditsRung ? CREDITS_CLAIM : rl.claim;
3371
3435
  // Providers this ONE request has already asked. Paired with the
3372
3436
  // process-wide cool-downs: the set stops a revisit inside the request,
3373
3437
  // the cool-downs stop the next request repeating the discovery.
@@ -3571,7 +3635,11 @@ export async function startProxy(opts = {}) {
3571
3635
  //
3572
3636
  // An EMPTY pool still 503s: that's a setup error the operator needs to
3573
3637
  // see, not traffic to quietly re-bill somewhere else.
3574
- const fallbackViable = poolFallbackModels.length > 0 && pool.size > 0;
3638
+ // A chain whose only entries are credits rungs without the key is
3639
+ // inert and defers nothing: the honest 429/503 stands.
3640
+ const chainCanServe = withoutCreditsEntries(poolFallbackModels).length > 0
3641
+ || (creditsApiKey.length > 0 && creditsEntries(poolFallbackModels).length > 0);
3642
+ const fallbackViable = chainCanServe && pool.size > 0;
3575
3643
  if (!fallbackViable) {
3576
3644
  if (parkedUntil !== null)
3577
3645
  writePoolParked(parkedUntil);
@@ -3697,7 +3765,9 @@ export async function startProxy(opts = {}) {
3697
3765
  const onClose = () => { if (!res.writableEnded)
3698
3766
  abort.abort(); };
3699
3767
  res.on('close', onClose);
3700
- const timer = setTimeout(() => abort.abort(), upstreamTimeoutMs);
3768
+ // The loopback leg is timed like the request it replaces: until
3769
+ // headers, then by the gap between chunks of a 2xx body.
3770
+ const timer = upstreamDeadline(upstreamTimeoutMs, () => abort.abort());
3701
3771
  try {
3702
3772
  const leg = await fetch(`${loopbackBase}${isOpenAI ? '/v1/chat/completions' : '/v1/messages'}`, {
3703
3773
  method: 'POST',
@@ -3714,9 +3784,12 @@ export async function startProxy(opts = {}) {
3714
3784
  responseHeaders['x-dario-family-fallback'] = fromModel;
3715
3785
  res.writeHead(leg.status, responseHeaders);
3716
3786
  if (leg.body) {
3787
+ if (leg.ok)
3788
+ timer.streaming();
3717
3789
  const reader = leg.body.getReader();
3718
3790
  while (true) {
3719
3791
  const { done, value } = await reader.read();
3792
+ timer.touch();
3720
3793
  if (done)
3721
3794
  break;
3722
3795
  if (!res.write(value))
@@ -3737,7 +3810,7 @@ export async function startProxy(opts = {}) {
3737
3810
  }
3738
3811
  }
3739
3812
  finally {
3740
- clearTimeout(timer);
3813
+ timer.clear();
3741
3814
  res.off('close', onClose);
3742
3815
  }
3743
3816
  return true;
@@ -4547,6 +4620,30 @@ export async function startProxy(opts = {}) {
4547
4620
  }
4548
4621
  if (!selectPoolAccount())
4549
4622
  return;
4623
+ // The credits rung (credits-rung.ts): re-point THIS request at the key
4624
+ // as the first `credits:<model>` entry the catalog can serve. False when
4625
+ // the chain names no such entry, the key is unset, the request is pinned
4626
+ // to a seat, or the rung already answered this request. The response is
4627
+ // marked here, before any byte of the answer, and the Claude path below
4628
+ // then runs on the key: `x-api-key`, the client's own body, no template.
4629
+ const armCreditsRung = (why) => {
4630
+ if (!creditsApiKey || creditsRung || pinnedAccount || attemptedProviders.has('credits'))
4631
+ return false;
4632
+ const target = pickCreditsTarget(selectPoolFallbackForBody(body), getCachedBases(), (m) => resolveClaudeAlias(applyModelAlias(m, modelAliases) ?? m));
4633
+ if (!target)
4634
+ return false;
4635
+ attemptedProviders.add('credits');
4636
+ creditsRung = target;
4637
+ poolAccount = null;
4638
+ accessToken = '';
4639
+ requestModel = target.model;
4640
+ requestFamily = modelFamily(target.model);
4641
+ if (target.effort)
4642
+ requestEffort = target.effort;
4643
+ res.setHeader('x-dario-pool-fallback', creditsFallbackMarker(target.model));
4644
+ console.log(`[dario] #${requestCount} ${why} → API credits as ${target.model}${target.effort ? ` (effort: ${target.effort})` : ''} (x-api-key)`);
4645
+ return true;
4646
+ };
4550
4647
  // Pool-exhausted fallback dispatch. In OAuth mode poolAccount can only
4551
4648
  // be null here when the selection above deferred to this path (armed
4552
4649
  // fallback + drained pool + OpenAI-shape request): swap the model and
@@ -4574,19 +4671,34 @@ export async function startProxy(opts = {}) {
4574
4671
  // that was ~2% of dario's own CPU on every request that never took the
4575
4672
  // branch (scripts/bench-overhead.mjs profile, v6.9.2). Resolved only
4576
4673
  // when the branch can be taken.
4577
- const fallbackModel = (!upstreamApiKey && !poolAccount && openaiBackend && isOpenAI)
4578
- ? (selectPoolFallbackForBody(body)[0] ?? null)
4579
- : null;
4580
- if (!upstreamApiKey && !poolAccount && fallbackModel && openaiBackend && isOpenAI) {
4581
- const fallbackBody = buildPoolFallbackBody(body, fallbackModel);
4582
- if (!fallbackBody) {
4583
- writePoolUnavailable();
4674
+ //
4675
+ // The metered rungs come after the subscription, in the chain's own
4676
+ // order: the credits rung (both wire shapes, since it rides the Claude
4677
+ // path) and the openai-compat backend (OpenAI shape only). A `credits:`
4678
+ // entry is no model for that backend, so its model is the first plain
4679
+ // entry.
4680
+ if (!upstreamApiKey && !poolAccount && !pinnedAccount) {
4681
+ const chainForBody = selectPoolFallbackForBody(body);
4682
+ const plainModel = withoutCreditsEntries(chainForBody)[0] ?? null;
4683
+ const openaiCanServe = openaiBackend !== null && isOpenAI && plainModel !== null;
4684
+ const creditsFirst = creditsBeforePlain(chainForBody);
4685
+ if (creditsFirst && armCreditsRung('pool exhausted')) {
4686
+ // Served below, on the key.
4687
+ }
4688
+ else if (openaiCanServe) {
4689
+ const fallbackBody = buildPoolFallbackBody(body, plainModel);
4690
+ if (!fallbackBody) {
4691
+ writePoolUnavailable();
4692
+ return;
4693
+ }
4694
+ console.log(`[dario] #${requestCount} pool exhausted, /v1/chat/completions → ${openaiBackend.name} as ${plainModel}`);
4695
+ requestCount++;
4696
+ await forwardToOpenAI(req, res, fallbackBody, openaiBackend, corsOrigin, { ...SECURITY_HEADERS, 'x-dario-pool-fallback': plainModel }, upstreamTimeoutMs, verbose);
4584
4697
  return;
4585
4698
  }
4586
- console.log(`[dario] #${requestCount} pool exhausted — /v1/chat/completions → ${openaiBackend.name} as ${fallbackModel}`);
4587
- requestCount++;
4588
- await forwardToOpenAI(req, res, fallbackBody, openaiBackend, corsOrigin, { ...SECURITY_HEADERS, 'x-dario-pool-fallback': fallbackModel }, upstreamTimeoutMs, verbose);
4589
- return;
4699
+ else if (!creditsFirst) {
4700
+ armCreditsRung('pool exhausted');
4701
+ }
4590
4702
  }
4591
4703
  // Deferred at selection because a fallback was armed, but nothing could
4592
4704
  // actually serve it — no Codex account lists the model, and either there
@@ -4594,7 +4706,7 @@ export async function startProxy(opts = {}) {
4594
4706
  // with the truth the selector used to give. Without this the request
4595
4707
  // would fall through to the Claude path with no account and an empty
4596
4708
  // bearer token, turning a clean 503 into a confusing upstream 401.
4597
- if (!upstreamApiKey && !poolAccount) {
4709
+ if (!upstreamApiKey && !poolAccount && !creditsRung) {
4598
4710
  // A fallback was armed but nothing could serve, and the pool itself is
4599
4711
  // parked: the exact reset beats a cool-down estimate (dario#1244).
4600
4712
  const parkedNow = pool.parkedUntil(Date.now(), requestFamily);
@@ -4631,6 +4743,10 @@ export async function startProxy(opts = {}) {
4631
4743
  // Outbound session id resolved once — either inside the template build
4632
4744
  // (so body metadata matches) or below for passthrough (no body build).
4633
4745
  let preBodySessionId;
4746
+ // True once the template build has written dario's metadata.user_id
4747
+ // into finalBody. Only such a body names the selected account, so only
4748
+ // it is rebound when a failover switches accounts (see switchAccount).
4749
+ let bodyIdentityOwned = false;
4634
4750
  // Request context for hybrid-mode field injection (#33). Built once
4635
4751
  // per request from incoming headers so the reverse mapper can fill
4636
4752
  // client-declared fields like `sessionId` that CC's schema doesn't
@@ -4645,7 +4761,49 @@ export async function startProxy(opts = {}) {
4645
4761
  userId: req.headers['x-user-id'],
4646
4762
  timestamp: new Date().toISOString(),
4647
4763
  } : undefined;
4648
- if (body.length > 0) {
4764
+ // The credits rung sends the client's own body as a plain API request:
4765
+ // sanitized, translated when OpenAI-shaped, the model swapped for the
4766
+ // rung's, the effort the entry named applied when the client set none.
4767
+ // No Claude Code template, identity or cch stamp: those make a request
4768
+ // look like Claude Code's for the OAuth gate, and an API key request
4769
+ // would be billed for every byte of them for nothing.
4770
+ const thinCreditsBody = (target) => {
4771
+ try {
4772
+ const parsed = JSON.parse(body.toString());
4773
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
4774
+ return null;
4775
+ const client = parsed;
4776
+ sanitizeMessages(client, opts.preserveOrchestrationTags);
4777
+ const r = (isOpenAI ? openaiToAnthropic(client, target.model) : { ...client, model: target.model });
4778
+ r.model = stripContext1mTag(typeof r.model === 'string' ? r.model : target.model);
4779
+ const effort = requestEffort ?? opts.effort;
4780
+ if (effort) {
4781
+ const oc = (r.output_config && typeof r.output_config === 'object' && !Array.isArray(r.output_config))
4782
+ ? { ...r.output_config }
4783
+ : {};
4784
+ if (typeof oc.effort !== 'string') {
4785
+ oc.effort = effort;
4786
+ r.output_config = oc;
4787
+ }
4788
+ }
4789
+ return Buffer.from(JSON.stringify(r));
4790
+ }
4791
+ catch {
4792
+ return null;
4793
+ }
4794
+ };
4795
+ if (creditsRung) {
4796
+ const thin = thinCreditsBody(creditsRung);
4797
+ if (!thin) {
4798
+ console.log(`[dario] #${requestCount} credits rung: the request body could not be re-pointed at ${creditsRung.model}`);
4799
+ writePoolUnavailable();
4800
+ return;
4801
+ }
4802
+ finalBody = thin;
4803
+ // The client's own tool names go out and come back untouched.
4804
+ preserveToolsEffective = true;
4805
+ }
4806
+ else if (body.length > 0) {
4649
4807
  try {
4650
4808
  // Reuse the object parsed for provider-prefix detection above — the same
4651
4809
  // bytes, so re-parsing is wasted work on large bodies (#642-audit). Falls
@@ -4876,6 +5034,7 @@ export async function startProxy(opts = {}) {
4876
5034
  for (const key of Object.keys(r))
4877
5035
  delete r[key];
4878
5036
  Object.assign(r, ccBody);
5037
+ bodyIdentityOwned = true;
4879
5038
  // `X[1m]` is a client-side LABEL, never a valid wire id — real CC
4880
5039
  // sends the BASE model id plus the `context-1m-2025-08-07` beta
4881
5040
  // (live capture 2026-06-09, CC v2.1.170 with --model
@@ -4964,7 +5123,12 @@ export async function startProxy(opts = {}) {
4964
5123
  // Beta headers
4965
5124
  const clientBeta = req.headers['anthropic-beta'];
4966
5125
  let beta;
4967
- if (passthrough || isCountTokens) {
5126
+ if (creditsRung) {
5127
+ // A plain API request: the client's betas minus the OAuth flag go out
5128
+ // in creditsHeaders below; nothing of the template's set applies.
5129
+ beta = '';
5130
+ }
5131
+ else if (passthrough || isCountTokens) {
4968
5132
  // Passthrough (and thin count_tokens): only add oauth beta,
4969
5133
  // forward client betas as-is — no template beta set.
4970
5134
  beta = 'oauth-2025-04-20';
@@ -4978,7 +5142,7 @@ export async function startProxy(opts = {}) {
4978
5142
  // context-1m requires Extra Usage — if it 400s, we auto-retry without
4979
5143
  // it, and cache the rejection so subsequent requests on this account
4980
5144
  // skip context-1m entirely (dario#36).
4981
- const acctKey = poolAccount?.alias ?? ACCOUNT_KEY_APIKEY;
5145
+ const acctKey = accountKeyNow();
4982
5146
  const skipContext1m = context1mUnavailable.has(acctKey);
4983
5147
  // Model-conditional betas (fable fallback-credit; [1m] context-1m),
4984
5148
  // mirroring real CC — see betaForModel. betaWithoutContext1m still
@@ -5036,7 +5200,7 @@ export async function startProxy(opts = {}) {
5036
5200
  // (below) does not re-pace on the peer: the retry is the rare path, and
5037
5201
  // holding it for a floor would stack a second wait on a request that
5038
5202
  // has already been refused once.
5039
- const seatClock = pacingClocks.seat(poolAccount?.alias ?? ACCOUNT_KEY_APIKEY);
5203
+ const seatClock = pacingClocks.seat(accountKeyNow());
5040
5204
  const nowForPacing = Date.now();
5041
5205
  const pacingDelay = computePacingDelay(nowForPacing, seatClock.lastRequestTime, pacingCfg);
5042
5206
  const thinkDelay = thinkTimeEnabled
@@ -5082,12 +5246,16 @@ export async function startProxy(opts = {}) {
5082
5246
  const forwardedIdentity = (passthrough || genuineCCRequest)
5083
5247
  ? forwardClientCCIdentityHeaders(req.headers)
5084
5248
  : {};
5085
- const headers = {
5249
+ const clientAnthropicVersion = passthrough ? (req.headers['anthropic-version'] || '2023-06-01') : '2023-06-01';
5250
+ // On the credits rung the outbound set is a plain API client's
5251
+ // (credits-rung.ts): the key, the version, the client's betas minus the
5252
+ // OAuth flag. Nothing of Claude Code's identity or session.
5253
+ const headers = creditsRung ? creditsHeaders(creditsApiKey, clientBeta, clientAnthropicVersion) : {
5086
5254
  ...staticHeaders,
5087
5255
  ...forwardedIdentity,
5088
5256
  ...upstreamAuthHeaders(upstreamApiKey, accessToken),
5089
5257
  'x-claude-code-session-id': outboundSessionId,
5090
- 'anthropic-version': passthrough ? (req.headers['anthropic-version'] || '2023-06-01') : '2023-06-01',
5258
+ 'anthropic-version': clientAnthropicVersion,
5091
5259
  'anthropic-beta': beta,
5092
5260
  // Prefer the client's own when passthrough forwarded one: a genuine CC
5093
5261
  // request already carries a real request id, and synthesising over it
@@ -5101,17 +5269,19 @@ export async function startProxy(opts = {}) {
5101
5269
  // upstream fetch so Anthropic stops generating (and billing) a
5102
5270
  // response nobody will read. With `--drain-on-close` set, we
5103
5271
  // instead keep the reader spinning to consume the full SSE — see
5104
- // src/stream-drain.ts for the fingerprint rationale. The 5-minute
5105
- // upstream timeout shares the same controller, so a hung upstream
5106
- // still gets cut off regardless of drain mode.
5272
+ // src/stream-drain.ts for the fingerprint rationale. The upstream
5273
+ // timeout shares the same controller, so a hung upstream still gets
5274
+ // cut off regardless of drain mode: it caps the wait until headers,
5275
+ // then the gap between chunks once a 2xx streams.
5107
5276
  const upstreamAbort = new AbortController();
5108
5277
  let clientDisconnected = false;
5109
- upstreamTimeout = setTimeout(() => {
5278
+ const deadline = upstreamDeadline(upstreamTimeoutMs, () => {
5110
5279
  if (!upstreamAbort.signal.aborted) {
5111
5280
  upstreamAbortReason = 'timeout';
5112
5281
  upstreamAbort.abort();
5113
5282
  }
5114
- }, upstreamTimeoutMs);
5283
+ });
5284
+ upstreamTimeout = deadline;
5115
5285
  onClientClose = () => {
5116
5286
  const action = decideOnClientClose(res.writableEnded, upstreamAbort.signal.aborted, drainOnClose);
5117
5287
  if (action === 'abort') {
@@ -5157,12 +5327,56 @@ export async function startProxy(opts = {}) {
5157
5327
  // combination with headroom -- opus-4-1 needs 2.
5158
5328
  const MAX_RECOVERY_PASSES = 4;
5159
5329
  let recoveryPasses = 0;
5330
+ // Move the request to a peer seat: auth, session header, the body's
5331
+ // metadata.user_id and the sticky binding all name `next`. The session
5332
+ // id is resolved once so the header and the body carry the same value
5333
+ // (the same rule as the first build), and the body's device_id and
5334
+ // account_uuid are next's, not the rejected seat's. A body dario did not
5335
+ // build (passthrough, count_tokens) is the client's and is left alone.
5336
+ const switchAccount = (next) => {
5337
+ triedAliases.add(next.alias);
5338
+ poolAccount = next;
5339
+ accessToken = next.accessToken;
5340
+ headers['Authorization'] = `Bearer ${accessToken}`;
5341
+ const sessionId = resolveOutboundSession(next, clientSessionKey).sessionId;
5342
+ headers['x-claude-code-session-id'] = sessionId;
5343
+ if (bodyIdentityOwned) {
5344
+ finalBody = rebindBodyIdentity(finalBody, { deviceId: next.identity.deviceId, accountUuid: next.identity.accountUuid, sessionId }, process.env.DARIO_CCH !== 'random' ? cliVersion : null);
5345
+ }
5346
+ pool.rebindSticky(stickyKey, next.alias);
5347
+ poolSync?.bindSticky(stickyKey, next.alias);
5348
+ };
5349
+ // Mid-flight move to the credits rung (credits-rung.ts): a 429 with no
5350
+ // peer seat left, and the chain names a credits entry the key can serve.
5351
+ // The request is rebuilt as a plain API request and the headers are
5352
+ // replaced in place, because the loop head re-derives the outbound set
5353
+ // from `headers` on every pass; the caller then re-enters the loop.
5354
+ const rearmOnCredits = () => {
5355
+ if (!armCreditsRung('pool exhausted mid-flight (429, no peer)'))
5356
+ return false;
5357
+ const thin = thinCreditsBody(creditsRung);
5358
+ if (!thin) {
5359
+ console.log(`[dario] #${requestCount} credits rung: the request body could not be re-pointed at ${creditsRung.model}`);
5360
+ creditsRung = null;
5361
+ res.removeHeader('x-dario-pool-fallback');
5362
+ return false;
5363
+ }
5364
+ finalBody = thin;
5365
+ ccToolMap = null;
5366
+ preserveToolsEffective = true;
5367
+ bodyIdentityOwned = false;
5368
+ for (const k of Object.keys(headers))
5369
+ delete headers[k];
5370
+ Object.assign(headers, creditsHeaders(creditsApiKey, clientBeta, clientAnthropicVersion));
5371
+ return true;
5372
+ };
5160
5373
  dispatchLoop: while (true) {
5161
5374
  // Reorder outbound headers to match CC's captured header sequence
5162
5375
  // when the live template recorded one. No-op on bundled-only installs.
5163
5376
  // Skipped in passthrough mode — passthrough means "don't shape the
5164
5377
  // request to look like CC," and reordering is a form of shaping.
5165
- const outboundHeaders = passthrough ? headers : orderHeadersForOutbound(headers);
5378
+ // Skipped on the credits rung too: a plain API request has no CC order.
5379
+ const outboundHeaders = (passthrough || creditsRung) ? headers : orderHeadersForOutbound(headers);
5166
5380
  fetchStartedAt ??= Date.now();
5167
5381
  upstream = await upstreamFetch(targetBase, {
5168
5382
  method: req.method ?? 'POST',
@@ -5252,7 +5466,7 @@ export async function startProxy(opts = {}) {
5252
5466
  }
5253
5467
  }
5254
5468
  if (betaRejectedFlags.length > 0 && recoveryPasses < MAX_RECOVERY_PASSES) {
5255
- const acctKey = poolAccount?.alias ?? ACCOUNT_KEY_APIKEY;
5469
+ const acctKey = accountKeyNow();
5256
5470
  let set = unavailableBetas.get(acctKey);
5257
5471
  if (!set) {
5258
5472
  set = new Set();
@@ -5465,7 +5679,7 @@ export async function startProxy(opts = {}) {
5465
5679
  else if (isLongContextError && recoveryPasses < MAX_RECOVERY_PASSES) {
5466
5680
  // Cache the rejection so future requests on this account skip
5467
5681
  // context-1m up front instead of re-paying the 400/429 round-trip.
5468
- const acctKey = poolAccount?.alias ?? ACCOUNT_KEY_APIKEY;
5682
+ const acctKey = accountKeyNow();
5469
5683
  const firstRejection = !context1mUnavailable.has(acctKey);
5470
5684
  context1mUnavailable.add(acctKey);
5471
5685
  if (verbose && firstRejection)
@@ -5496,13 +5710,7 @@ export async function startProxy(opts = {}) {
5496
5710
  if (poolAccount) {
5497
5711
  const nextAccount = pool.selectExcluding(triedAliases, modelFamily(requestModel));
5498
5712
  if (nextAccount) {
5499
- triedAliases.add(nextAccount.alias);
5500
- poolAccount = nextAccount;
5501
- accessToken = nextAccount.accessToken;
5502
- headers['Authorization'] = `Bearer ${accessToken}`;
5503
- headers['x-claude-code-session-id'] = resolveOutboundSession(nextAccount, clientSessionKey).sessionId;
5504
- pool.rebindSticky(stickyKey, nextAccount.alias);
5505
- poolSync?.bindSticky(stickyKey, nextAccount.alias);
5713
+ switchAccount(nextAccount);
5506
5714
  peekedBody = null;
5507
5715
  continue dispatchLoop;
5508
5716
  }
@@ -5520,12 +5728,20 @@ export async function startProxy(opts = {}) {
5520
5728
  // The Claude half is out of quota for this request AND for the next
5521
5729
  // one — cool it here, honouring the upstream `retry-after` when it
5522
5730
  // sent one, so the following request does not re-walk the chain.
5523
- if (await familyFallbackOn429(upstream))
5731
+ if (!creditsRung && await familyFallbackOn429(upstream))
5524
5732
  return;
5525
5733
  attemptedProviders.add('claude');
5526
- providerCooldowns.note('claude', parseRetryAfterMs(upstream.headers.get('retry-after')));
5527
- if (!pinnedAccount && await attemptPoolFallbackOn429(req, res, body, isOpenAI, attemptedProviders, { queueMs, arrivedAt })) {
5528
- return;
5734
+ // A 429 from the credits key is the key's limit, not the pool's.
5735
+ if (!creditsRung)
5736
+ providerCooldowns.note('claude', parseRetryAfterMs(upstream.headers.get('retry-after')));
5737
+ if (!pinnedAccount) {
5738
+ const outcome = await attemptPoolFallbackOn429(req, res, body, isOpenAI, attemptedProviders, { queueMs, arrivedAt }, rearmOnCredits);
5739
+ if (outcome === 'served')
5740
+ return;
5741
+ if (outcome === 'credits') {
5742
+ peekedBody = null;
5743
+ continue dispatchLoop;
5744
+ }
5529
5745
  }
5530
5746
  if (allProvidersCooled(['codex', 'claude'], providerCooldowns)) {
5531
5747
  writeAllProvidersRateLimited(['codex', 'claude']);
@@ -5553,10 +5769,10 @@ export async function startProxy(opts = {}) {
5553
5769
  analytics.record({
5554
5770
  timestamp: Date.now(),
5555
5771
  consumer,
5556
- account: poolAccount?.alias ?? ACCOUNT_KEY_APIKEY,
5772
+ account: accountKeyNow(),
5557
5773
  model: requestModel,
5558
5774
  inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0, thinkingTokens: 0,
5559
- claim: rl.claim, util5h: rl.util5h, util7d: rl.util7d, overageUtil: rl.overageUtil,
5775
+ claim: claimNow(rl), util5h: rl.util5h, util7d: rl.util7d, overageUtil: rl.overageUtil,
5560
5776
  latencyMs: Date.now() - startTime, status: 429, isStream: false, isOpenAI, timing: timingNow(),
5561
5777
  });
5562
5778
  }
@@ -5580,6 +5796,9 @@ export async function startProxy(opts = {}) {
5580
5796
  }
5581
5797
  const rejection = classifyUpstreamRejection(upstream.status, peekedBody);
5582
5798
  responseHeaders['x-dario-upstream-rejection'] = rejection.marker;
5799
+ if (creditsRung) {
5800
+ console.error(`[dario] #${requestCount} credits rung refused (${upstream.status}, ${rejection.marker}) as ${creditsRung.model}: the key may be out of credits or not linked to the plan's organization; the upstream error is forwarded`);
5801
+ }
5583
5802
  requestCount++;
5584
5803
  res.writeHead(upstream.status, responseHeaders);
5585
5804
  res.end(peekedBody);
@@ -5593,6 +5812,12 @@ export async function startProxy(opts = {}) {
5593
5812
  // headers, so headroom math sees a healthy idle account. Mark the
5594
5813
  // cool-down here, try the next-best account, fall through to the
5595
5814
  // normal forwarding only if no peer is available.
5815
+ // On the credits rung there is no seat to cool and no peer to try: a
5816
+ // 401/403 from the key is the key's own answer and is forwarded as is
5817
+ // (the forwarder below reuses the body the classifier read).
5818
+ if (creditsRung && (upstream.status === 401 || upstream.status === 403)) {
5819
+ console.error(`[dario] #${requestCount} credits rung refused (${upstream.status}) as ${creditsRung.model}: the key was not accepted (invalid, revoked, or without permission for the model); the upstream error is forwarded`);
5820
+ }
5596
5821
  if (poolAccount && (upstream.status === 401 || upstream.status === 403)) {
5597
5822
  pool.markAuthFailure(poolAccount.alias);
5598
5823
  if (verbose) {
@@ -5600,13 +5825,7 @@ export async function startProxy(opts = {}) {
5600
5825
  }
5601
5826
  const nextAccount = pool.selectExcluding(triedAliases, modelFamily(requestModel));
5602
5827
  if (nextAccount) {
5603
- triedAliases.add(nextAccount.alias);
5604
- poolAccount = nextAccount;
5605
- accessToken = nextAccount.accessToken;
5606
- headers['Authorization'] = `Bearer ${accessToken}`;
5607
- headers['x-claude-code-session-id'] = resolveOutboundSession(nextAccount, clientSessionKey).sessionId;
5608
- pool.rebindSticky(stickyKey, nextAccount.alias);
5609
- poolSync?.bindSticky(stickyKey, nextAccount.alias);
5828
+ switchAccount(nextAccount);
5610
5829
  continue dispatchLoop;
5611
5830
  }
5612
5831
  // No peer available — forward the saved generic-403 bytes when the
@@ -5634,13 +5853,7 @@ export async function startProxy(opts = {}) {
5634
5853
  if (poolAccount) {
5635
5854
  const nextAccount = pool.selectExcluding(triedAliases, modelFamily(requestModel));
5636
5855
  if (nextAccount) {
5637
- triedAliases.add(nextAccount.alias);
5638
- poolAccount = nextAccount;
5639
- accessToken = nextAccount.accessToken;
5640
- headers['Authorization'] = `Bearer ${accessToken}`;
5641
- headers['x-claude-code-session-id'] = resolveOutboundSession(nextAccount, clientSessionKey).sessionId;
5642
- pool.rebindSticky(stickyKey, nextAccount.alias);
5643
- poolSync?.bindSticky(stickyKey, nextAccount.alias);
5856
+ switchAccount(nextAccount);
5644
5857
  continue dispatchLoop;
5645
5858
  }
5646
5859
  }
@@ -5650,12 +5863,21 @@ export async function startProxy(opts = {}) {
5650
5863
  // into body. See attemptPoolFallbackOn429's doc comment for why this
5651
5864
  // is one shared function rather than an inline copy.
5652
5865
  // Same bookkeeping as the other mid-flight site — see there.
5653
- if (await familyFallbackOn429(upstream))
5866
+ if (!creditsRung && await familyFallbackOn429(upstream))
5654
5867
  return;
5655
5868
  attemptedProviders.add('claude');
5656
- providerCooldowns.note('claude', parseRetryAfterMs(upstream.headers.get('retry-after')));
5657
- if (!pinnedAccount && await attemptPoolFallbackOn429(req, res, body, isOpenAI, attemptedProviders, { queueMs, arrivedAt })) {
5658
- return;
5869
+ // A 429 from the credits key is the key's limit, not the pool's.
5870
+ if (!creditsRung)
5871
+ providerCooldowns.note('claude', parseRetryAfterMs(upstream.headers.get('retry-after')));
5872
+ if (!pinnedAccount) {
5873
+ const outcome = await attemptPoolFallbackOn429(req, res, body, isOpenAI, attemptedProviders, { queueMs, arrivedAt }, rearmOnCredits);
5874
+ if (outcome === 'served')
5875
+ return;
5876
+ if (outcome === 'credits') {
5877
+ // This site has not read the 429's body; release it before the retry.
5878
+ void upstream.body?.cancel().catch(() => { });
5879
+ continue dispatchLoop;
5880
+ }
5659
5881
  }
5660
5882
  if (allProvidersCooled(['codex', 'claude'], providerCooldowns)) {
5661
5883
  writeAllProvidersRateLimited(['codex', 'claude']);
@@ -5679,10 +5901,10 @@ export async function startProxy(opts = {}) {
5679
5901
  analytics.record({
5680
5902
  timestamp: Date.now(),
5681
5903
  consumer,
5682
- account: poolAccount?.alias ?? ACCOUNT_KEY_APIKEY,
5904
+ account: accountKeyNow(),
5683
5905
  model: requestModel,
5684
5906
  inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0, thinkingTokens: 0,
5685
- claim: rl.claim, util5h: rl.util5h, util7d: rl.util7d, overageUtil: rl.overageUtil,
5907
+ claim: claimNow(rl), util5h: rl.util5h, util7d: rl.util7d, overageUtil: rl.overageUtil,
5686
5908
  latencyMs: Date.now() - startTime, status: 429, isStream: false, isOpenAI, timing: timingNow(),
5687
5909
  });
5688
5910
  }
@@ -5716,6 +5938,11 @@ export async function startProxy(opts = {}) {
5716
5938
  responseHeaders[key] = value;
5717
5939
  }
5718
5940
  }
5941
+ // A classified rejection that reached the plain forwarder (no seat to
5942
+ // fail over from) is marked the way the seat branches mark theirs.
5943
+ if (peekedBody !== null && upstream.status >= 400) {
5944
+ responseHeaders['x-dario-upstream-rejection'] = classifyUpstreamRejection(upstream.status, peekedBody).marker;
5945
+ }
5719
5946
  requestCount++;
5720
5947
  // Log billing classification on first request or in verbose mode.
5721
5948
  //
@@ -5732,7 +5959,10 @@ export async function startProxy(opts = {}) {
5732
5959
  const billingClaim = upstream.headers.get('anthropic-ratelimit-unified-representative-claim');
5733
5960
  const overageUtil = upstream.headers.get('anthropic-ratelimit-unified-overage-utilization');
5734
5961
  if (requestCount === 1 || verbose) {
5735
- if (billingClaim) {
5962
+ if (creditsRung) {
5963
+ console.log(`[dario] #${requestCount} billing: credits (${CREDITS_CLAIM}, x-api-key as ${creditsRung.model})`);
5964
+ }
5965
+ else if (billingClaim) {
5736
5966
  let overagePct;
5737
5967
  if (overageUtil !== null) {
5738
5968
  overagePct = `${Math.round(parseFloat(overageUtil) * 100)}%`;
@@ -5839,11 +6069,17 @@ export async function startProxy(opts = {}) {
5839
6069
  // parking this handler forever and leaking its queue slot — the
5840
6070
  // dario#905 wedge. See waitForClientDrain in src/stream-drain.ts.
5841
6071
  const waitForDrain = () => waitForClientDrain(res, upstreamAbort.signal);
6072
+ // A 2xx stream is timed by the gap between chunks from here, so a
6073
+ // long answer is not cut while it still streams. An error body keeps
6074
+ // the headers-phase cap.
6075
+ if (upstream.status >= 200 && upstream.status < 300)
6076
+ deadline.streaming();
5842
6077
  try {
5843
6078
  let buffer = '';
5844
6079
  const MAX_LINE_LENGTH = 1_000_000; // 1MB max per SSE line
5845
6080
  while (true) {
5846
6081
  const { done, value } = await reader.read();
6082
+ deadline.touch();
5847
6083
  if (done) {
5848
6084
  upstreamDoneAt = Date.now();
5849
6085
  break;
@@ -5973,19 +6209,19 @@ export async function startProxy(opts = {}) {
5973
6209
  // would read, and using their (often zero) output_tokens would
5974
6210
  // pin think time to baseMs+jitter on the next request needlessly.
5975
6211
  if (upstream.status >= 200 && upstream.status < 300) {
5976
- pacingClocks.noteResponse(poolAccount?.alias ?? ACCOUNT_KEY_APIKEY, Date.now(), streamOutputTokens);
6212
+ pacingClocks.noteResponse(accountKeyNow(), Date.now(), streamOutputTokens);
5977
6213
  }
5978
6214
  {
5979
6215
  const rl = poolAccount?.rateLimit ?? parseRateLimits(upstream.headers);
5980
6216
  analytics.record({
5981
6217
  timestamp: Date.now(),
5982
6218
  consumer,
5983
- account: poolAccount?.alias ?? ACCOUNT_KEY_APIKEY,
6219
+ account: accountKeyNow(),
5984
6220
  model: requestModel,
5985
6221
  inputTokens: streamInputTokens, outputTokens: streamOutputTokens,
5986
6222
  cacheReadTokens: streamCacheReadTokens, cacheCreateTokens: streamCacheCreateTokens,
5987
6223
  thinkingTokens: Math.round(streamThinkingChars / 4),
5988
- claim: rl.claim, util5h: rl.util5h, util7d: rl.util7d, overageUtil: rl.overageUtil,
6224
+ claim: claimNow(rl), util5h: rl.util5h, util7d: rl.util7d, overageUtil: rl.overageUtil,
5989
6225
  latencyMs: Date.now() - startTime, status: upstream.status, isStream: true, isOpenAI, timing: timingNow(),
5990
6226
  continuation: continuationOf(guard, requestDepth),
5991
6227
  });
@@ -5997,9 +6233,9 @@ export async function startProxy(opts = {}) {
5997
6233
  status: upstream.status, latency_ms: Date.now() - startTime, ...timingLogFields(timingNow()),
5998
6234
  in_tokens: streamInputTokens, out_tokens: streamOutputTokens,
5999
6235
  cache_read: streamCacheReadTokens, cache_create: streamCacheCreateTokens,
6000
- claim: poolAccount?.rateLimit.claim,
6001
- bucket: poolAccount ? billingBucketFromClaim(poolAccount.rateLimit.claim) : undefined,
6002
- account: poolAccount?.alias,
6236
+ claim: creditsRung ? CREDITS_CLAIM : poolAccount?.rateLimit.claim,
6237
+ bucket: creditsRung ? 'credits' : (poolAccount ? billingBucketFromClaim(poolAccount.rateLimit.claim) : undefined),
6238
+ account: creditsRung ? ACCOUNT_KEY_CREDITS : poolAccount?.alias,
6003
6239
  consumer,
6004
6240
  client: detectedClientForLog,
6005
6241
  preserve_tools: preserveToolsEffective,
@@ -6014,8 +6250,13 @@ export async function startProxy(opts = {}) {
6014
6250
  }, consumer));
6015
6251
  }
6016
6252
  else {
6017
- // Buffer and forward
6018
- let responseBody = await upstream.text();
6253
+ // Buffer and forward. A 400/402/403 body the rejection classifier
6254
+ // already read is in peekedBody and the stream is consumed; reading
6255
+ // it again would hand the client an empty body with the right
6256
+ // status. Only responses no branch above handled reach here: a
6257
+ // non-billing 403 with no seat to fail over from (the credits rung,
6258
+ // ANTHROPIC_UPSTREAM_API_KEY), a generic 400 or 402.
6259
+ let responseBody = peekedBody ?? await upstream.text();
6019
6260
  upstreamDoneAt = Date.now();
6020
6261
  // Reverse tool name mapping so client sees original names
6021
6262
  if (ccToolMap)
@@ -6043,7 +6284,7 @@ export async function startProxy(opts = {}) {
6043
6284
  // tokens when the body wasn't JSON or had no usage block — base +
6044
6285
  // jitter still apply but the per-token component is 0.
6045
6286
  if (upstream.status >= 200 && upstream.status < 300) {
6046
- pacingClocks.noteResponse(poolAccount?.alias ?? ACCOUNT_KEY_APIKEY, Date.now(), bufferedUsage?.outputTokens ?? 0);
6287
+ pacingClocks.noteResponse(accountKeyNow(), Date.now(), bufferedUsage?.outputTokens ?? 0);
6047
6288
  }
6048
6289
  if (bufferedUsage) {
6049
6290
  try {
@@ -6051,12 +6292,12 @@ export async function startProxy(opts = {}) {
6051
6292
  analytics.record({
6052
6293
  timestamp: Date.now(),
6053
6294
  consumer,
6054
- account: poolAccount?.alias ?? ACCOUNT_KEY_APIKEY,
6295
+ account: accountKeyNow(),
6055
6296
  model: bufferedUsage.model || requestModel,
6056
6297
  inputTokens: bufferedUsage.inputTokens, outputTokens: bufferedUsage.outputTokens,
6057
6298
  cacheReadTokens: bufferedUsage.cacheReadTokens, cacheCreateTokens: bufferedUsage.cacheCreateTokens,
6058
6299
  thinkingTokens: bufferedUsage.thinkingTokens,
6059
- claim: rl.claim, util5h: rl.util5h, util7d: rl.util7d, overageUtil: rl.overageUtil,
6300
+ claim: claimNow(rl), util5h: rl.util5h, util7d: rl.util7d, overageUtil: rl.overageUtil,
6060
6301
  latencyMs: Date.now() - startTime, status: upstream.status, isStream: false, isOpenAI, timing: timingNow(),
6061
6302
  });
6062
6303
  }
@@ -6069,9 +6310,9 @@ export async function startProxy(opts = {}) {
6069
6310
  status: upstream.status, latency_ms: Date.now() - startTime, ...timingLogFields(timingNow()),
6070
6311
  in_tokens: bufferedUsage?.inputTokens, out_tokens: bufferedUsage?.outputTokens,
6071
6312
  cache_read: bufferedUsage?.cacheReadTokens, cache_create: bufferedUsage?.cacheCreateTokens,
6072
- claim: poolAccount?.rateLimit.claim,
6073
- bucket: poolAccount ? billingBucketFromClaim(poolAccount.rateLimit.claim) : undefined,
6074
- account: poolAccount?.alias,
6313
+ claim: creditsRung ? CREDITS_CLAIM : poolAccount?.rateLimit.claim,
6314
+ bucket: creditsRung ? 'credits' : (poolAccount ? billingBucketFromClaim(poolAccount.rateLimit.claim) : undefined),
6315
+ account: creditsRung ? ACCOUNT_KEY_CREDITS : poolAccount?.alias,
6075
6316
  consumer,
6076
6317
  client: detectedClientForLog,
6077
6318
  preserve_tools: preserveToolsEffective,
@@ -6128,7 +6369,7 @@ export async function startProxy(opts = {}) {
6128
6369
  // setup happens after the body-read phase, so on fast-path errors
6129
6370
  // (413, body read timeout) these may still be null — guard accordingly.
6130
6371
  if (upstreamTimeout !== null)
6131
- clearTimeout(upstreamTimeout);
6372
+ upstreamTimeout.clear();
6132
6373
  if (onClientClose !== null)
6133
6374
  req.off('close', onClientClose);
6134
6375
  releaseQueueSlot();
@@ -6421,8 +6662,13 @@ export async function startProxy(opts = {}) {
6421
6662
  catch { /* best-effort flush — never block shutdown on it */ }
6422
6663
  }));
6423
6664
  };
6424
- // Graceful shutdown
6665
+ // Graceful shutdown. Runs once: a Ctrl+C in a terminal reaches the Bun child
6666
+ // directly and again through the relaunch wrapper's forwarding.
6667
+ let shuttingDown = false;
6425
6668
  const shutdown = () => {
6669
+ if (shuttingDown)
6670
+ return;
6671
+ shuttingDown = true;
6426
6672
  console.log('\n[dario] Shutting down...');
6427
6673
  clearInterval(presenceInterval);
6428
6674
  clearInterval(refreshInterval);