@askalf/dario 6.16.0 → 6.17.1

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
@@ -19,10 +19,10 @@ import { describeTemplate, detectDrift, checkCCCompat, probeInstalledCCVersion }
19
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';
20
20
  import { backfillIdentity } from './accounts.js';
21
21
  import { PoolSync, DEFAULT_POOL_SYNC_INTERVAL_MS } from './pool-sync.js';
22
- 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';
23
23
  import { Ledger, resolveLedgerPath, ledgerDisabledByEnv } from './ledger.js';
24
24
  import { renderPrometheus } from './metrics.js';
25
- import { renderSpendDonuts, renderAnalyticsView, ANALYTICS_UI_SHELL } from './donuts.js';
25
+ import { renderSpendDonuts, renderAnalyticsView, analyticsUiPage } from './donuts.js';
26
26
  import { KeyStore, keyAllowsModel, resolveKeysPath, looksLikeNamedKey, budgetVerdict, budgetHeaders, requestBudgetReservation, addReservation, subtractReservation, EMPTY_RESERVATION } from './keys.js';
27
27
  import { OverageGuard, buildHaltErrorBody } from './overage-guard.js';
28
28
  import { notify as osNotify } from './notify.js';
@@ -56,6 +56,7 @@ import { codexUsageView, resolveCodexUsagePollMs } from './codex-usage.js';
56
56
  import { seedStaleCodexUsage } from './codex-backend.js';
57
57
  import { route as routeProvider } from './provider-adapter.js';
58
58
  import { selectPoolFallbackModels } from './pool-fallback-tier.js';
59
+ import { ACCOUNT_KEY_CREDITS, creditsBeforePlain, creditsEntries, creditsFallbackMarker, creditsHeaders, describeCreditsRung, pickCreditsTarget, withoutCreditsEntries } from './credits-rung.js';
59
60
  import { parseFamilyFallback, familyFallbackModel, describeFamilyFallback } from './family-fallback.js';
60
61
  import { RequestQueue, QueueFullError, QueueTimeoutError, DEFAULT_MAX_CONCURRENT, DEFAULT_MAX_QUEUED, DEFAULT_QUEUE_TIMEOUT_MS, resolveMaxConcurrent } from './request-queue.js';
61
62
  import { waitForIdle, drainThenClose, shutdownTimers, DEFAULT_SHUTDOWN_GRACE_MS } from './shutdown-drain.js';
@@ -1349,6 +1350,9 @@ export async function startProxy(opts = {}) {
1349
1350
  const upstreamFetch = chaosCut && chaosState ? chaosCutFetch(opts.fetchImpl ?? fetch, chaosCut, chaosState) : (opts.fetchImpl ?? fetch);
1350
1351
  const codexFetch = chaosCut && chaosState ? chaosCutFetch(fetch, chaosCut, chaosState) : fetch;
1351
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();
1352
1356
  if (upstreamApiKey)
1353
1357
  console.error('[dario] upstream auth: per-token API key (x-api-key) — OAuth/Max + account pool bypassed');
1354
1358
  else if (ignoreCcCredentials())
@@ -1555,17 +1559,37 @@ export async function startProxy(opts = {}) {
1555
1559
  }
1556
1560
  };
1557
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;
1558
1569
  const targets = [];
1559
- if (startupCodexAliases.length > 0)
1560
- targets.push(`codex subscription (${startupCodexAliases.join(', ')}) — both wire shapes`);
1561
- if (openaiBackend)
1562
- 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);
1563
1582
  if (targets.length > 0) {
1564
- 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)`);
1565
1584
  }
1566
1585
  else {
1567
- 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.');
1568
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');
1569
1593
  }
1570
1594
  // Family fallback (strictly opt-in, family-fallback.ts). Bad entries are
1571
1595
  // dropped with a warning; the rest still apply.
@@ -2479,7 +2503,9 @@ export async function startProxy(opts = {}) {
2479
2503
  //
2480
2504
  // With NO next option and no peer, do not defer: the real upstream error is
2481
2505
  // more useful to the client than replacing it with a generic 503.
2482
- 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);
2483
2509
  // The next seat that could serve one of these fallback models, excluding
2484
2510
  // everything already tried. `peek` is the cached read, so the scan costs no
2485
2511
  // upstream call; a seat whose model list is unknown is still worth a try.
@@ -2577,21 +2603,37 @@ export async function startProxy(opts = {}) {
2577
2603
  * bug structurally impossible to reintroduce: a future third call site gets
2578
2604
  * this by construction, not by remembering to copy six lines correctly.
2579
2605
  */
2580
- const attemptPoolFallbackOn429 = async (req, res, body, isOpenAI, attempted, timing) => {
2581
- if (await tryCodexPoolFallback(req, res, body, selectPoolFallbackForBody(body), isOpenAI ? 'openai' : 'anthropic', 'pool exhausted mid-flight (429, no peer)', attempted, timing)) {
2582
- 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';
2583
2616
  }
2584
- const fallbackModel = selectPoolFallbackForBody(body)[0] ?? null;
2585
- if (isOpenAI && fallbackModel && openaiBackend) {
2586
- 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);
2587
2627
  if (fallbackBody) {
2588
- 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}`);
2589
2629
  requestCount++;
2590
- await forwardToOpenAI(req, res, fallbackBody, openaiBackend, corsOrigin, { ...SECURITY_HEADERS, 'x-dario-pool-fallback': fallbackModel }, upstreamTimeoutMs, verbose);
2591
- return true;
2630
+ await forwardToOpenAI(req, res, fallbackBody, openaiBackend, corsOrigin, { ...SECURITY_HEADERS, 'x-dario-pool-fallback': plainModel }, upstreamTimeoutMs, verbose);
2631
+ return 'served';
2592
2632
  }
2593
2633
  }
2594
- return false;
2634
+ if (!creditsFirst && tryCredits())
2635
+ return 'credits';
2636
+ return 'none';
2595
2637
  };
2596
2638
  const server = createServer(async (req, res) => {
2597
2639
  // The request's first stamp (src/timing.ts): every split below is
@@ -2829,11 +2871,12 @@ export async function startProxy(opts = {}) {
2829
2871
  if (handled)
2830
2872
  return;
2831
2873
  }
2832
- // The dashboard shell carries no data, so it needs no credential: it is
2833
- // 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).
2834
2877
  if (urlPath === '/analytics/ui' && req.method === 'GET') {
2835
2878
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', ...SECURITY_HEADERS });
2836
- res.end(ANALYTICS_UI_SHELL);
2879
+ res.end(analyticsUiPage());
2837
2880
  return;
2838
2881
  }
2839
2882
  // Read-only analytics token: accepted on the read-only surfaces only, and
@@ -3013,7 +3056,11 @@ export async function startProxy(opts = {}) {
3013
3056
  // `queue` rides along the summary (dario#905): request-queue.ts always
3014
3057
  // documented snapshot() as "exposed for /analytics", but it was never
3015
3058
  // actually wired in, so slot exhaustion was invisible from outside.
3016
- 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() }));
3017
3064
  return;
3018
3065
  }
3019
3066
  // Prometheus text exposition of the same state (dario#1341). A view, not
@@ -3052,7 +3099,9 @@ export async function startProxy(opts = {}) {
3052
3099
  return;
3053
3100
  }
3054
3101
  // The ledger's per-day table, for anyone charting it. `lifetime` on
3055
- // /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.
3056
3105
  if (urlPath === '/analytics/ledger' && req.method === 'GET') {
3057
3106
  if (!ledger) {
3058
3107
  res.writeHead(404, JSON_HEADERS);
@@ -3060,7 +3109,7 @@ export async function startProxy(opts = {}) {
3060
3109
  return;
3061
3110
  }
3062
3111
  res.writeHead(200, JSON_HEADERS);
3063
- res.end(JSON.stringify({ path: ledger.path, ...ledger.snapshot() }));
3112
+ res.end(JSON.stringify({ path: ledger.path, ...ledger.snapshot(), priced: ledger.priced() }));
3064
3113
  return;
3065
3114
  }
3066
3115
  // Analytics live stream — SSE of new RequestRecord JSON, one event
@@ -3372,6 +3421,17 @@ export async function startProxy(opts = {}) {
3372
3421
  // form gives the declared type without seeding a narrowing.
3373
3422
  let poolAccount = null;
3374
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;
3375
3435
  // Providers this ONE request has already asked. Paired with the
3376
3436
  // process-wide cool-downs: the set stops a revisit inside the request,
3377
3437
  // the cool-downs stop the next request repeating the discovery.
@@ -3575,7 +3635,11 @@ export async function startProxy(opts = {}) {
3575
3635
  //
3576
3636
  // An EMPTY pool still 503s: that's a setup error the operator needs to
3577
3637
  // see, not traffic to quietly re-bill somewhere else.
3578
- 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;
3579
3643
  if (!fallbackViable) {
3580
3644
  if (parkedUntil !== null)
3581
3645
  writePoolParked(parkedUntil);
@@ -4556,6 +4620,30 @@ export async function startProxy(opts = {}) {
4556
4620
  }
4557
4621
  if (!selectPoolAccount())
4558
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
+ };
4559
4647
  // Pool-exhausted fallback dispatch. In OAuth mode poolAccount can only
4560
4648
  // be null here when the selection above deferred to this path (armed
4561
4649
  // fallback + drained pool + OpenAI-shape request): swap the model and
@@ -4583,19 +4671,34 @@ export async function startProxy(opts = {}) {
4583
4671
  // that was ~2% of dario's own CPU on every request that never took the
4584
4672
  // branch (scripts/bench-overhead.mjs profile, v6.9.2). Resolved only
4585
4673
  // when the branch can be taken.
4586
- const fallbackModel = (!upstreamApiKey && !poolAccount && openaiBackend && isOpenAI)
4587
- ? (selectPoolFallbackForBody(body)[0] ?? null)
4588
- : null;
4589
- if (!upstreamApiKey && !poolAccount && fallbackModel && openaiBackend && isOpenAI) {
4590
- const fallbackBody = buildPoolFallbackBody(body, fallbackModel);
4591
- if (!fallbackBody) {
4592
- 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);
4593
4697
  return;
4594
4698
  }
4595
- console.log(`[dario] #${requestCount} pool exhausted — /v1/chat/completions → ${openaiBackend.name} as ${fallbackModel}`);
4596
- requestCount++;
4597
- await forwardToOpenAI(req, res, fallbackBody, openaiBackend, corsOrigin, { ...SECURITY_HEADERS, 'x-dario-pool-fallback': fallbackModel }, upstreamTimeoutMs, verbose);
4598
- return;
4699
+ else if (!creditsFirst) {
4700
+ armCreditsRung('pool exhausted');
4701
+ }
4599
4702
  }
4600
4703
  // Deferred at selection because a fallback was armed, but nothing could
4601
4704
  // actually serve it — no Codex account lists the model, and either there
@@ -4603,7 +4706,7 @@ export async function startProxy(opts = {}) {
4603
4706
  // with the truth the selector used to give. Without this the request
4604
4707
  // would fall through to the Claude path with no account and an empty
4605
4708
  // bearer token, turning a clean 503 into a confusing upstream 401.
4606
- if (!upstreamApiKey && !poolAccount) {
4709
+ if (!upstreamApiKey && !poolAccount && !creditsRung) {
4607
4710
  // A fallback was armed but nothing could serve, and the pool itself is
4608
4711
  // parked: the exact reset beats a cool-down estimate (dario#1244).
4609
4712
  const parkedNow = pool.parkedUntil(Date.now(), requestFamily);
@@ -4658,7 +4761,49 @@ export async function startProxy(opts = {}) {
4658
4761
  userId: req.headers['x-user-id'],
4659
4762
  timestamp: new Date().toISOString(),
4660
4763
  } : undefined;
4661
- 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) {
4662
4807
  try {
4663
4808
  // Reuse the object parsed for provider-prefix detection above — the same
4664
4809
  // bytes, so re-parsing is wasted work on large bodies (#642-audit). Falls
@@ -4978,7 +5123,12 @@ export async function startProxy(opts = {}) {
4978
5123
  // Beta headers
4979
5124
  const clientBeta = req.headers['anthropic-beta'];
4980
5125
  let beta;
4981
- 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) {
4982
5132
  // Passthrough (and thin count_tokens): only add oauth beta,
4983
5133
  // forward client betas as-is — no template beta set.
4984
5134
  beta = 'oauth-2025-04-20';
@@ -4992,7 +5142,7 @@ export async function startProxy(opts = {}) {
4992
5142
  // context-1m requires Extra Usage — if it 400s, we auto-retry without
4993
5143
  // it, and cache the rejection so subsequent requests on this account
4994
5144
  // skip context-1m entirely (dario#36).
4995
- const acctKey = poolAccount?.alias ?? ACCOUNT_KEY_APIKEY;
5145
+ const acctKey = accountKeyNow();
4996
5146
  const skipContext1m = context1mUnavailable.has(acctKey);
4997
5147
  // Model-conditional betas (fable fallback-credit; [1m] context-1m),
4998
5148
  // mirroring real CC — see betaForModel. betaWithoutContext1m still
@@ -5050,7 +5200,7 @@ export async function startProxy(opts = {}) {
5050
5200
  // (below) does not re-pace on the peer: the retry is the rare path, and
5051
5201
  // holding it for a floor would stack a second wait on a request that
5052
5202
  // has already been refused once.
5053
- const seatClock = pacingClocks.seat(poolAccount?.alias ?? ACCOUNT_KEY_APIKEY);
5203
+ const seatClock = pacingClocks.seat(accountKeyNow());
5054
5204
  const nowForPacing = Date.now();
5055
5205
  const pacingDelay = computePacingDelay(nowForPacing, seatClock.lastRequestTime, pacingCfg);
5056
5206
  const thinkDelay = thinkTimeEnabled
@@ -5096,12 +5246,16 @@ export async function startProxy(opts = {}) {
5096
5246
  const forwardedIdentity = (passthrough || genuineCCRequest)
5097
5247
  ? forwardClientCCIdentityHeaders(req.headers)
5098
5248
  : {};
5099
- 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) : {
5100
5254
  ...staticHeaders,
5101
5255
  ...forwardedIdentity,
5102
5256
  ...upstreamAuthHeaders(upstreamApiKey, accessToken),
5103
5257
  'x-claude-code-session-id': outboundSessionId,
5104
- 'anthropic-version': passthrough ? (req.headers['anthropic-version'] || '2023-06-01') : '2023-06-01',
5258
+ 'anthropic-version': clientAnthropicVersion,
5105
5259
  'anthropic-beta': beta,
5106
5260
  // Prefer the client's own when passthrough forwarded one: a genuine CC
5107
5261
  // request already carries a real request id, and synthesising over it
@@ -5192,12 +5346,37 @@ export async function startProxy(opts = {}) {
5192
5346
  pool.rebindSticky(stickyKey, next.alias);
5193
5347
  poolSync?.bindSticky(stickyKey, next.alias);
5194
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
+ };
5195
5373
  dispatchLoop: while (true) {
5196
5374
  // Reorder outbound headers to match CC's captured header sequence
5197
5375
  // when the live template recorded one. No-op on bundled-only installs.
5198
5376
  // Skipped in passthrough mode — passthrough means "don't shape the
5199
5377
  // request to look like CC," and reordering is a form of shaping.
5200
- 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);
5201
5380
  fetchStartedAt ??= Date.now();
5202
5381
  upstream = await upstreamFetch(targetBase, {
5203
5382
  method: req.method ?? 'POST',
@@ -5287,7 +5466,7 @@ export async function startProxy(opts = {}) {
5287
5466
  }
5288
5467
  }
5289
5468
  if (betaRejectedFlags.length > 0 && recoveryPasses < MAX_RECOVERY_PASSES) {
5290
- const acctKey = poolAccount?.alias ?? ACCOUNT_KEY_APIKEY;
5469
+ const acctKey = accountKeyNow();
5291
5470
  let set = unavailableBetas.get(acctKey);
5292
5471
  if (!set) {
5293
5472
  set = new Set();
@@ -5500,7 +5679,7 @@ export async function startProxy(opts = {}) {
5500
5679
  else if (isLongContextError && recoveryPasses < MAX_RECOVERY_PASSES) {
5501
5680
  // Cache the rejection so future requests on this account skip
5502
5681
  // context-1m up front instead of re-paying the 400/429 round-trip.
5503
- const acctKey = poolAccount?.alias ?? ACCOUNT_KEY_APIKEY;
5682
+ const acctKey = accountKeyNow();
5504
5683
  const firstRejection = !context1mUnavailable.has(acctKey);
5505
5684
  context1mUnavailable.add(acctKey);
5506
5685
  if (verbose && firstRejection)
@@ -5549,12 +5728,20 @@ export async function startProxy(opts = {}) {
5549
5728
  // The Claude half is out of quota for this request AND for the next
5550
5729
  // one — cool it here, honouring the upstream `retry-after` when it
5551
5730
  // sent one, so the following request does not re-walk the chain.
5552
- if (await familyFallbackOn429(upstream))
5731
+ if (!creditsRung && await familyFallbackOn429(upstream))
5553
5732
  return;
5554
5733
  attemptedProviders.add('claude');
5555
- providerCooldowns.note('claude', parseRetryAfterMs(upstream.headers.get('retry-after')));
5556
- if (!pinnedAccount && await attemptPoolFallbackOn429(req, res, body, isOpenAI, attemptedProviders, { queueMs, arrivedAt })) {
5557
- 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
+ }
5558
5745
  }
5559
5746
  if (allProvidersCooled(['codex', 'claude'], providerCooldowns)) {
5560
5747
  writeAllProvidersRateLimited(['codex', 'claude']);
@@ -5582,10 +5769,10 @@ export async function startProxy(opts = {}) {
5582
5769
  analytics.record({
5583
5770
  timestamp: Date.now(),
5584
5771
  consumer,
5585
- account: poolAccount?.alias ?? ACCOUNT_KEY_APIKEY,
5772
+ account: accountKeyNow(),
5586
5773
  model: requestModel,
5587
5774
  inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0, thinkingTokens: 0,
5588
- 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,
5589
5776
  latencyMs: Date.now() - startTime, status: 429, isStream: false, isOpenAI, timing: timingNow(),
5590
5777
  });
5591
5778
  }
@@ -5609,6 +5796,9 @@ export async function startProxy(opts = {}) {
5609
5796
  }
5610
5797
  const rejection = classifyUpstreamRejection(upstream.status, peekedBody);
5611
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
+ }
5612
5802
  requestCount++;
5613
5803
  res.writeHead(upstream.status, responseHeaders);
5614
5804
  res.end(peekedBody);
@@ -5622,6 +5812,12 @@ export async function startProxy(opts = {}) {
5622
5812
  // headers, so headroom math sees a healthy idle account. Mark the
5623
5813
  // cool-down here, try the next-best account, fall through to the
5624
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
+ }
5625
5821
  if (poolAccount && (upstream.status === 401 || upstream.status === 403)) {
5626
5822
  pool.markAuthFailure(poolAccount.alias);
5627
5823
  if (verbose) {
@@ -5667,12 +5863,21 @@ export async function startProxy(opts = {}) {
5667
5863
  // into body. See attemptPoolFallbackOn429's doc comment for why this
5668
5864
  // is one shared function rather than an inline copy.
5669
5865
  // Same bookkeeping as the other mid-flight site — see there.
5670
- if (await familyFallbackOn429(upstream))
5866
+ if (!creditsRung && await familyFallbackOn429(upstream))
5671
5867
  return;
5672
5868
  attemptedProviders.add('claude');
5673
- providerCooldowns.note('claude', parseRetryAfterMs(upstream.headers.get('retry-after')));
5674
- if (!pinnedAccount && await attemptPoolFallbackOn429(req, res, body, isOpenAI, attemptedProviders, { queueMs, arrivedAt })) {
5675
- 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
+ }
5676
5881
  }
5677
5882
  if (allProvidersCooled(['codex', 'claude'], providerCooldowns)) {
5678
5883
  writeAllProvidersRateLimited(['codex', 'claude']);
@@ -5696,10 +5901,10 @@ export async function startProxy(opts = {}) {
5696
5901
  analytics.record({
5697
5902
  timestamp: Date.now(),
5698
5903
  consumer,
5699
- account: poolAccount?.alias ?? ACCOUNT_KEY_APIKEY,
5904
+ account: accountKeyNow(),
5700
5905
  model: requestModel,
5701
5906
  inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0, thinkingTokens: 0,
5702
- 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,
5703
5908
  latencyMs: Date.now() - startTime, status: 429, isStream: false, isOpenAI, timing: timingNow(),
5704
5909
  });
5705
5910
  }
@@ -5733,6 +5938,11 @@ export async function startProxy(opts = {}) {
5733
5938
  responseHeaders[key] = value;
5734
5939
  }
5735
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
+ }
5736
5946
  requestCount++;
5737
5947
  // Log billing classification on first request or in verbose mode.
5738
5948
  //
@@ -5749,7 +5959,10 @@ export async function startProxy(opts = {}) {
5749
5959
  const billingClaim = upstream.headers.get('anthropic-ratelimit-unified-representative-claim');
5750
5960
  const overageUtil = upstream.headers.get('anthropic-ratelimit-unified-overage-utilization');
5751
5961
  if (requestCount === 1 || verbose) {
5752
- 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) {
5753
5966
  let overagePct;
5754
5967
  if (overageUtil !== null) {
5755
5968
  overagePct = `${Math.round(parseFloat(overageUtil) * 100)}%`;
@@ -5996,19 +6209,19 @@ export async function startProxy(opts = {}) {
5996
6209
  // would read, and using their (often zero) output_tokens would
5997
6210
  // pin think time to baseMs+jitter on the next request needlessly.
5998
6211
  if (upstream.status >= 200 && upstream.status < 300) {
5999
- pacingClocks.noteResponse(poolAccount?.alias ?? ACCOUNT_KEY_APIKEY, Date.now(), streamOutputTokens);
6212
+ pacingClocks.noteResponse(accountKeyNow(), Date.now(), streamOutputTokens);
6000
6213
  }
6001
6214
  {
6002
6215
  const rl = poolAccount?.rateLimit ?? parseRateLimits(upstream.headers);
6003
6216
  analytics.record({
6004
6217
  timestamp: Date.now(),
6005
6218
  consumer,
6006
- account: poolAccount?.alias ?? ACCOUNT_KEY_APIKEY,
6219
+ account: accountKeyNow(),
6007
6220
  model: requestModel,
6008
6221
  inputTokens: streamInputTokens, outputTokens: streamOutputTokens,
6009
6222
  cacheReadTokens: streamCacheReadTokens, cacheCreateTokens: streamCacheCreateTokens,
6010
6223
  thinkingTokens: Math.round(streamThinkingChars / 4),
6011
- 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,
6012
6225
  latencyMs: Date.now() - startTime, status: upstream.status, isStream: true, isOpenAI, timing: timingNow(),
6013
6226
  continuation: continuationOf(guard, requestDepth),
6014
6227
  });
@@ -6020,9 +6233,9 @@ export async function startProxy(opts = {}) {
6020
6233
  status: upstream.status, latency_ms: Date.now() - startTime, ...timingLogFields(timingNow()),
6021
6234
  in_tokens: streamInputTokens, out_tokens: streamOutputTokens,
6022
6235
  cache_read: streamCacheReadTokens, cache_create: streamCacheCreateTokens,
6023
- claim: poolAccount?.rateLimit.claim,
6024
- bucket: poolAccount ? billingBucketFromClaim(poolAccount.rateLimit.claim) : undefined,
6025
- 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,
6026
6239
  consumer,
6027
6240
  client: detectedClientForLog,
6028
6241
  preserve_tools: preserveToolsEffective,
@@ -6037,8 +6250,13 @@ export async function startProxy(opts = {}) {
6037
6250
  }, consumer));
6038
6251
  }
6039
6252
  else {
6040
- // Buffer and forward
6041
- 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();
6042
6260
  upstreamDoneAt = Date.now();
6043
6261
  // Reverse tool name mapping so client sees original names
6044
6262
  if (ccToolMap)
@@ -6066,7 +6284,7 @@ export async function startProxy(opts = {}) {
6066
6284
  // tokens when the body wasn't JSON or had no usage block — base +
6067
6285
  // jitter still apply but the per-token component is 0.
6068
6286
  if (upstream.status >= 200 && upstream.status < 300) {
6069
- pacingClocks.noteResponse(poolAccount?.alias ?? ACCOUNT_KEY_APIKEY, Date.now(), bufferedUsage?.outputTokens ?? 0);
6287
+ pacingClocks.noteResponse(accountKeyNow(), Date.now(), bufferedUsage?.outputTokens ?? 0);
6070
6288
  }
6071
6289
  if (bufferedUsage) {
6072
6290
  try {
@@ -6074,12 +6292,12 @@ export async function startProxy(opts = {}) {
6074
6292
  analytics.record({
6075
6293
  timestamp: Date.now(),
6076
6294
  consumer,
6077
- account: poolAccount?.alias ?? ACCOUNT_KEY_APIKEY,
6295
+ account: accountKeyNow(),
6078
6296
  model: bufferedUsage.model || requestModel,
6079
6297
  inputTokens: bufferedUsage.inputTokens, outputTokens: bufferedUsage.outputTokens,
6080
6298
  cacheReadTokens: bufferedUsage.cacheReadTokens, cacheCreateTokens: bufferedUsage.cacheCreateTokens,
6081
6299
  thinkingTokens: bufferedUsage.thinkingTokens,
6082
- 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,
6083
6301
  latencyMs: Date.now() - startTime, status: upstream.status, isStream: false, isOpenAI, timing: timingNow(),
6084
6302
  });
6085
6303
  }
@@ -6092,9 +6310,9 @@ export async function startProxy(opts = {}) {
6092
6310
  status: upstream.status, latency_ms: Date.now() - startTime, ...timingLogFields(timingNow()),
6093
6311
  in_tokens: bufferedUsage?.inputTokens, out_tokens: bufferedUsage?.outputTokens,
6094
6312
  cache_read: bufferedUsage?.cacheReadTokens, cache_create: bufferedUsage?.cacheCreateTokens,
6095
- claim: poolAccount?.rateLimit.claim,
6096
- bucket: poolAccount ? billingBucketFromClaim(poolAccount.rateLimit.claim) : undefined,
6097
- 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,
6098
6316
  consumer,
6099
6317
  client: detectedClientForLog,
6100
6318
  preserve_tools: preserveToolsEffective,