@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
@@ -92,11 +92,17 @@ export declare function continuationStats(records: readonly RequestRecord[]): Co
92
92
  * - `api` — pure API key billing, no subscription involved
93
93
  * - `unknown` — header absent or unparseable (non-200 responses, stream aborts)
94
94
  *
95
+ * A sixth, `credits`, is stamped by the proxy rather than read from a header:
96
+ * a request the credits rung of the failover chain served on the Console key
97
+ * in DARIO_CREDITS_API_KEY (credits-rung.ts). Anthropic sends no claim header
98
+ * on an API key request, and "the operator's own credits rung" is a different
99
+ * fact from "an unexpected API bill", so it gets its own bucket.
100
+ *
95
101
  * Exposed in `/analytics` summaries and in verbose per-request logs so
96
102
  * users can see at a glance which bucket their traffic is actually hitting.
97
103
  * See #34 for background.
98
104
  */
99
- export type BillingBucket = 'subscription' | 'subscription_fallback' | 'extra_usage' | 'api' | 'unknown';
105
+ export type BillingBucket = 'subscription' | 'subscription_fallback' | 'extra_usage' | 'api' | 'credits' | 'unknown';
100
106
  /**
101
107
  * Map the raw `representative-claim` header value to a human-friendly
102
108
  * billing bucket. Pure function; no state; safe to call from any context.
@@ -112,6 +118,14 @@ export declare function billingBucketFromClaim(claim: string | null | undefined)
112
118
  export declare const SUBSCRIPTION_CLAIMS: ReadonlySet<string>;
113
119
  /** The claim the proxy stamps on codex-engine requests (see above). */
114
120
  export declare const CODEX_CLAIM = "chatgpt_subscription";
121
+ /**
122
+ * The claim the proxy stamps on a request the credits rung served
123
+ * (credits-rung.ts). Not a subscription claim: the request was billed at API
124
+ * rates against the monthly credit balance, so it lands in its own bucket and
125
+ * the ledger files it as metered. The overage guard leaves it alone, because
126
+ * the rung is a chain entry the operator wrote, not a billing surprise.
127
+ */
128
+ export declare const CREDITS_CLAIM = "anthropic_credits";
115
129
  /**
116
130
  * One-line per-request usage summary for verbose (-v / -vv) logs.
117
131
  *
@@ -282,6 +296,12 @@ export declare function costOfTokens(model: string, atMs: number, t: {
282
296
  cacheReadTokens: number;
283
297
  cacheCreateTokens: number;
284
298
  }): number;
299
+ /**
300
+ * Request counts per five-minute bin over the window, oldest bin first; the
301
+ * newest bin is the one `now` falls in. Records outside the window are left
302
+ * out, so the bins sum to the window's request count.
303
+ */
304
+ export declare function binByFiveMinutes(records: RequestRecord[], now: number, windowMinutes: number): number[];
285
305
  export declare class Analytics extends EventEmitter {
286
306
  private records;
287
307
  private maxRecords;
@@ -408,6 +428,8 @@ export interface AnalyticsSummary {
408
428
  window: WindowStats & {
409
429
  minutes: number;
410
430
  requests: number;
431
+ /** Requests per five-minute bin across the window, oldest first (twelve for the default hour). */
432
+ perFiveMinutes: number[];
411
433
  };
412
434
  allTime: WindowStats & {
413
435
  requests: number;
@@ -423,7 +445,10 @@ export interface AnalyticsSummary {
423
445
  };
424
446
  predictions: {
425
447
  estimatedExhaustionMinutes: number | null;
448
+ /** Input and output tokens per minute across the window; cache reads and writes are not in it. */
426
449
  tokenBurnRate: number;
450
+ /** Every kind per minute: input, output, cache reads and cache writes. */
451
+ totalTokenBurnRate: number;
427
452
  costBurnRate: number;
428
453
  };
429
454
  }
package/dist/analytics.js CHANGED
@@ -69,6 +69,8 @@ export function billingBucketFromClaim(claim) {
69
69
  return 'extra_usage';
70
70
  case 'api':
71
71
  return 'api';
72
+ case CREDITS_CLAIM:
73
+ return 'credits';
72
74
  default:
73
75
  return 'unknown';
74
76
  }
@@ -96,6 +98,14 @@ export const SUBSCRIPTION_CLAIMS = new Set([
96
98
  ]);
97
99
  /** The claim the proxy stamps on codex-engine requests (see above). */
98
100
  export const CODEX_CLAIM = 'chatgpt_subscription';
101
+ /**
102
+ * The claim the proxy stamps on a request the credits rung served
103
+ * (credits-rung.ts). Not a subscription claim: the request was billed at API
104
+ * rates against the monthly credit balance, so it lands in its own bucket and
105
+ * the ledger files it as metered. The overage guard leaves it alone, because
106
+ * the rung is a chain entry the operator wrote, not a billing surprise.
107
+ */
108
+ export const CREDITS_CLAIM = 'anthropic_credits';
99
109
  /**
100
110
  * One-line per-request usage summary for verbose (-v / -vv) logs.
101
111
  *
@@ -189,6 +199,12 @@ export function consumerFromBody(body) {
189
199
  export function isNonSubscriptionBilling(claim) {
190
200
  if (!claim || claim === NO_BILLING_CLAIM)
191
201
  return false;
202
+ // The credits rung is metered billing the operator configured by name in
203
+ // the failover chain and armed with a key; the guard exists for billing
204
+ // nobody asked for. The claim is still not a subscription claim, so the
205
+ // bucket math and the ledger keep treating it as paid.
206
+ if (claim === CREDITS_CLAIM)
207
+ return false;
192
208
  return !SUBSCRIPTION_CLAIMS.has(claim);
193
209
  }
194
210
  /**
@@ -363,6 +379,22 @@ function estimateCost(record) {
363
379
  // estimates each side correctly rather than repricing history at today's rate.
364
380
  return costOfTokens(record.model, record.timestamp, record);
365
381
  }
382
+ /**
383
+ * Request counts per five-minute bin over the window, oldest bin first; the
384
+ * newest bin is the one `now` falls in. Records outside the window are left
385
+ * out, so the bins sum to the window's request count.
386
+ */
387
+ export function binByFiveMinutes(records, now, windowMinutes) {
388
+ const n = Math.max(1, Math.ceil(windowMinutes / 5));
389
+ const bins = new Array(n).fill(0);
390
+ for (const r of records) {
391
+ const age = now - r.timestamp;
392
+ if (age < 0 || age >= n * 300_000)
393
+ continue;
394
+ bins[n - 1 - Math.floor(age / 300_000)]++;
395
+ }
396
+ return bins;
397
+ }
366
398
  export class Analytics extends EventEmitter {
367
399
  records = [];
368
400
  maxRecords;
@@ -426,13 +458,18 @@ export class Analytics extends EventEmitter {
426
458
  };
427
459
  }
428
460
  summary(windowMinutes = 60) {
429
- const cutoff = Date.now() - windowMinutes * 60_000;
461
+ const now = Date.now();
462
+ const cutoff = now - windowMinutes * 60_000;
430
463
  const recent = this.records.filter(r => r.timestamp >= cutoff);
431
464
  const allTime = this.records;
432
465
  return {
433
466
  window: {
434
467
  minutes: windowMinutes,
435
468
  requests: recent.length,
469
+ // The dashboard's sparkline and its "vs prior 30 min" figure need the
470
+ // whole window; /analytics/stream replays only the last 50 records,
471
+ // which would show an hour of steady traffic as a late surge.
472
+ perFiveMinutes: binByFiveMinutes(recent, now, windowMinutes),
436
473
  ...this.computeStats(recent),
437
474
  },
438
475
  allTime: {
@@ -459,6 +496,7 @@ export class Analytics extends EventEmitter {
459
496
  subscription_fallback: 0,
460
497
  extra_usage: 0,
461
498
  api: 0,
499
+ credits: 0,
462
500
  unknown: 0,
463
501
  },
464
502
  subscriptionPercent: 0,
@@ -478,6 +516,7 @@ export class Analytics extends EventEmitter {
478
516
  subscription_fallback: 0,
479
517
  extra_usage: 0,
480
518
  api: 0,
519
+ credits: 0,
481
520
  unknown: 0,
482
521
  };
483
522
  for (const r of records) {
@@ -601,24 +640,31 @@ export class Analytics extends EventEmitter {
601
640
  }
602
641
  predict(records) {
603
642
  if (records.length < 3) {
604
- return { estimatedExhaustionMinutes: null, tokenBurnRate: 0, costBurnRate: 0 };
643
+ return { estimatedExhaustionMinutes: null, tokenBurnRate: 0, totalTokenBurnRate: 0, costBurnRate: 0 };
605
644
  }
606
645
  const sorted = [...records].sort((a, b) => a.timestamp - b.timestamp);
607
646
  const first = sorted[0];
608
647
  const last = sorted[sorted.length - 1];
609
648
  const durationMin = (last.timestamp - first.timestamp) / 60_000;
610
649
  if (durationMin < 1) {
611
- return { estimatedExhaustionMinutes: null, tokenBurnRate: 0, costBurnRate: 0 };
650
+ return { estimatedExhaustionMinutes: null, tokenBurnRate: 0, totalTokenBurnRate: 0, costBurnRate: 0 };
612
651
  }
613
652
  const totalTokens = sorted.reduce((s, r) => s + r.inputTokens + r.outputTokens, 0);
653
+ // Every kind the provider counts, for a reader asking "how many tokens a
654
+ // minute": on a cached prompt the cache reads dwarf input and output.
655
+ // thinkingTokens is an estimate carved out of outputTokens, not a kind of
656
+ // its own, so adding it would count that output twice.
657
+ const allTokens = sorted.reduce((s, r) => s + r.inputTokens + r.outputTokens + r.cacheReadTokens + r.cacheCreateTokens, 0);
614
658
  const totalCost = sorted.reduce((s, r) => s + estimateCost(r), 0);
615
659
  const tokenBurnRate = totalTokens / durationMin;
660
+ const totalTokenBurnRate = allTokens / durationMin;
616
661
  const costBurnRate = (totalCost / durationMin) * 60;
617
662
  const currentUtil = last.util5h;
618
663
  if (currentUtil >= 0.95) {
619
664
  return {
620
665
  estimatedExhaustionMinutes: 0,
621
666
  tokenBurnRate: Math.round(tokenBurnRate),
667
+ totalTokenBurnRate: Math.round(totalTokenBurnRate),
622
668
  costBurnRate: Math.round(costBurnRate * 100) / 100,
623
669
  };
624
670
  }
@@ -627,6 +673,7 @@ export class Analytics extends EventEmitter {
627
673
  return {
628
674
  estimatedExhaustionMinutes: null,
629
675
  tokenBurnRate: Math.round(tokenBurnRate),
676
+ totalTokenBurnRate: Math.round(totalTokenBurnRate),
630
677
  costBurnRate: Math.round(costBurnRate * 100) / 100,
631
678
  };
632
679
  }
@@ -634,6 +681,7 @@ export class Analytics extends EventEmitter {
634
681
  return {
635
682
  estimatedExhaustionMinutes: Math.round(minutesToExhaustion),
636
683
  tokenBurnRate: Math.round(tokenBurnRate),
684
+ totalTokenBurnRate: Math.round(totalTokenBurnRate),
637
685
  costBurnRate: Math.round(costBurnRate * 100) / 100,
638
686
  };
639
687
  }
@@ -0,0 +1,25 @@
1
+ /** The three values `metadata.user_id` is built from. */
2
+ export interface BodyIdentity {
3
+ deviceId: string;
4
+ accountUuid: string;
5
+ sessionId: string;
6
+ }
7
+ /**
8
+ * The `metadata.user_id` string Claude Code sends: a JSON object with
9
+ * device_id, account_uuid and session_id in that order. Shared by the
10
+ * template build and the failover rebind so both produce the same bytes.
11
+ */
12
+ export declare function buildMetadataUserId(identity: BodyIdentity): string;
13
+ /**
14
+ * `body` with `metadata.user_id` rebuilt for `identity`, for a request that
15
+ * fails over to another account. `cchVersion` is the Claude Code version to
16
+ * stamp cch for after the rewrite, or null where the first serialize did not
17
+ * stamp (DARIO_CCH=random); stampCch is itself a no-op without a seed.
18
+ *
19
+ * Returns `body` itself, the same Buffer, when there is nothing to rebind: no
20
+ * body, a body that is not JSON, no string `metadata.user_id`, or a user_id
21
+ * that already names `identity`. The body was serialized by JSON.stringify,
22
+ * so parse and stringify again give the same bytes apart from user_id, and
23
+ * user_id is replaced in place so key order is kept.
24
+ */
25
+ export declare function rebindBodyIdentity(body: Buffer | undefined, identity: BodyIdentity, cchVersion: string | null): Buffer | undefined;
@@ -0,0 +1,68 @@
1
+ /**
2
+ * The account identity a built Claude Code body carries, and how it moves to
3
+ * another seat when a request fails over mid-flight.
4
+ *
5
+ * The template build stamps `metadata.user_id` from the selected account's
6
+ * device_id and account_uuid plus the session id the
7
+ * `x-claude-code-session-id` header carries. A 429 or 401/403 failover sends
8
+ * the same body to a different account, so the body must name that account
9
+ * and the session id its header now carries; otherwise seat B is sent seat
10
+ * A's device_id and account_uuid and a session_id that disagrees with its own
11
+ * header. The cch stamp (src/cch.ts) hashes the body, user_id included, so a
12
+ * rewritten body is stamped again.
13
+ *
14
+ * Pure, and imports nothing that reads the filesystem, so it is unit-testable
15
+ * without a proxy or a ~/.dario.
16
+ */
17
+ import { stampCch } from './cch.js';
18
+ /**
19
+ * The `metadata.user_id` string Claude Code sends: a JSON object with
20
+ * device_id, account_uuid and session_id in that order. Shared by the
21
+ * template build and the failover rebind so both produce the same bytes.
22
+ */
23
+ export function buildMetadataUserId(identity) {
24
+ return JSON.stringify({
25
+ device_id: identity.deviceId,
26
+ account_uuid: identity.accountUuid,
27
+ session_id: identity.sessionId,
28
+ });
29
+ }
30
+ /**
31
+ * `body` with `metadata.user_id` rebuilt for `identity`, for a request that
32
+ * fails over to another account. `cchVersion` is the Claude Code version to
33
+ * stamp cch for after the rewrite, or null where the first serialize did not
34
+ * stamp (DARIO_CCH=random); stampCch is itself a no-op without a seed.
35
+ *
36
+ * Returns `body` itself, the same Buffer, when there is nothing to rebind: no
37
+ * body, a body that is not JSON, no string `metadata.user_id`, or a user_id
38
+ * that already names `identity`. The body was serialized by JSON.stringify,
39
+ * so parse and stringify again give the same bytes apart from user_id, and
40
+ * user_id is replaced in place so key order is kept.
41
+ */
42
+ export function rebindBodyIdentity(body, identity, cchVersion) {
43
+ if (!body)
44
+ return body;
45
+ let parsed;
46
+ try {
47
+ parsed = JSON.parse(body.toString('utf8'));
48
+ }
49
+ catch {
50
+ return body;
51
+ }
52
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
53
+ return body;
54
+ const metadata = parsed.metadata;
55
+ if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata))
56
+ return body;
57
+ const meta = metadata;
58
+ if (typeof meta.user_id !== 'string')
59
+ return body;
60
+ const userId = buildMetadataUserId(identity);
61
+ if (meta.user_id === userId)
62
+ return body;
63
+ meta.user_id = userId;
64
+ let text = JSON.stringify(parsed);
65
+ if (cchVersion !== null)
66
+ text = stampCch(text, cchVersion);
67
+ return Buffer.from(text);
68
+ }
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import { loadTemplate, promptVariantsOf, VARIANT_FAMILIES, PLATFORM_ONLY_TOOLS, INTERACTIVE_ONLY_TOOLS, CONFIG_SCOPED_TOOLS, DEFERRED_TOOLS, } from './live-fingerprint.js';
11
11
  import { VALID_EFFORT_VALUES, parseEffortSuffix } from './effort.js';
12
+ import { buildMetadataUserId } from './body-identity.js';
12
13
  // Re-exported so existing importers (scripts/capture-and-bake.mjs, the template
13
14
  // invariant tests) keep their import site. The definitions live in
14
15
  // live-fingerprint.ts because loadTemplate must apply the same superset rule to
@@ -1819,13 +1820,7 @@ export function buildCCRequest(clientBody, billingTag, cacheControl, identity, o
1819
1820
  }
1820
1821
  }
1821
1822
  const body = { ...clientBody, system };
1822
- body.metadata = {
1823
- user_id: JSON.stringify({
1824
- device_id: identity.deviceId,
1825
- account_uuid: identity.accountUuid,
1826
- session_id: identity.sessionId,
1827
- }),
1828
- };
1823
+ body.metadata = { user_id: buildMetadataUserId(identity) };
1829
1824
  return { body, toolMap: new Map(), unmappedTools: [], genuineCC: true };
1830
1825
  }
1831
1826
  // ── Detect text-tool-protocol clients up-front ──
@@ -2275,13 +2270,7 @@ export function buildCCRequest(clientBody, billingTag, cacheControl, identity, o
2275
2270
  ccRequest.tool_choice = passed;
2276
2271
  }
2277
2272
  // Metadata
2278
- ccRequest.metadata = {
2279
- user_id: JSON.stringify({
2280
- device_id: identity.deviceId,
2281
- account_uuid: identity.accountUuid,
2282
- session_id: identity.sessionId,
2283
- }),
2284
- };
2273
+ ccRequest.metadata = { user_id: buildMetadataUserId(identity) };
2285
2274
  ccRequest.max_tokens = resolveMaxTokens(opts.maxTokens, clientBody);
2286
2275
  // Model-specific fields — order: thinking, context_management, output_config
2287
2276
  //
package/dist/cli.d.ts CHANGED
@@ -114,6 +114,13 @@ export declare function parseBooleanEnv(value: string | undefined): boolean | un
114
114
  * Set(['thinking','env']) — value "thinking,env" → preserve listed
115
115
  */
116
116
  export declare function resolvePreserveOrchestrationTags(args: string[], env: string | undefined): Set<string> | undefined;
117
+ /**
118
+ * --upstream-timeout / DARIO_UPSTREAM_TIMEOUT_MS. The flag wins over the env
119
+ * var even when it is 0, and 0 then resolves to undefined so the proxy's
120
+ * default applies: an explicit `--upstream-timeout=0` keeps the default
121
+ * rather than falling through to the env value. Exported for tests.
122
+ */
123
+ export declare function resolveUpstreamTimeoutMs(flag: number | undefined, env: string | undefined): number | undefined;
117
124
  /**
118
125
  * The seat shape a running proxy returns from `/accounts`.
119
126
  *
package/dist/cli.js CHANGED
@@ -516,6 +516,12 @@ async function proxy() {
516
516
  // used to sever every streaming response after five seconds. dario#1370.
517
517
  const shutdownGraceMs = parsePositiveIntFlag('--shutdown-grace=')
518
518
  ?? parsePositiveIntEnv(process.env['DARIO_SHUTDOWN_GRACE_MS']);
519
+ // --upstream-timeout=MS: how long dario waits for upstream (default 300000).
520
+ // Until the response streams it caps the whole wait; once a 2xx streams it
521
+ // is the longest gap allowed between two chunks, so a long answer that is
522
+ // still sending is never cut. 0 keeps the default: an always-firing timer
523
+ // would fail every request.
524
+ const upstreamTimeoutMs = resolveUpstreamTimeoutMs(parsePositiveIntFlag('--upstream-timeout='), process.env['DARIO_UPSTREAM_TIMEOUT_MS']);
519
525
  // --pool-strategy=headroom|fill-first — where UNBOUND (new) conversations
520
526
  // land. `headroom` (default) spreads them to the seat with the most slack;
521
527
  // `fill-first` concentrates them on the alphabetically-first eligible seat
@@ -526,9 +532,9 @@ async function proxy() {
526
532
  // expires soonest before touching the rest.
527
533
  const poolStrategyFromFlag = args.find((a) => a.startsWith('--pool-strategy='))?.split('=')[1];
528
534
  if (poolStrategyFromFlag !== undefined
529
- && poolStrategyFromFlag !== 'headroom' && poolStrategyFromFlag !== 'fill-first'
530
- && poolStrategyFromFlag !== 'expiring-first') {
531
- console.error(`[dario] Invalid --pool-strategy "${poolStrategyFromFlag}". Must be headroom, fill-first or expiring-first.`);
535
+ && poolStrategyFromFlag !== 'headroom' && poolStrategyFromFlag !== 'headroom-by-plan'
536
+ && poolStrategyFromFlag !== 'fill-first' && poolStrategyFromFlag !== 'expiring-first') {
537
+ console.error(`[dario] Invalid --pool-strategy "${poolStrategyFromFlag}". Must be headroom, headroom-by-plan, fill-first or expiring-first.`);
532
538
  process.exit(1);
533
539
  }
534
540
  const poolStrategy = poolStrategyFromFlag
@@ -786,7 +792,7 @@ async function proxy() {
786
792
  console.error(`[dario] Override (not recommended): pass --unsafe-no-auth if you have out-of-band network controls and accept the risk.`);
787
793
  process.exit(1);
788
794
  }
789
- await startProxy({ port, host, verbose, verboseBodies, model, fastModel, noClaudeAuth, analyticsToken, passthrough, preserveTools, hybridTools, mergeTools, noAutoDetect, strictTls, pacingMinMs, pacingJitterMs, thinkTimeBaseMs, thinkTimePerTokenMs, thinkTimeJitterMs, thinkTimeMaxMs, sessionStartMinMs, sessionStartJitterMs, stealth, drainOnClose, sessionIdleRotateMs, sessionRotateJitterMs, sessionMaxAgeMs, sessionPerClient, preserveOrchestrationTags, noLiveCapture, strictTemplate, maxConcurrent, maxQueued, queueTimeoutMs, maxConcurrentPerConsumer, shutdownGraceMs, poolStrategy, poolHeadroomFloor, poolSharedState, poolSharedStateIntervalMs, effort, maxTokens, poolFallbackModel, familyFallback, modelAliases, logFile, passthroughBetas, skipFields, systemPrompt, overageGuardEnabled, overageGuardBehavior, overageGuardCooldownMs, overageGuardNotifyOs, honorClientThinking, preserveOutputFormat, midstreamContinue, ledger, keys, keysPath, keySockets: keySockets.length > 0 ? keySockets : undefined });
795
+ await startProxy({ port, host, verbose, verboseBodies, model, fastModel, noClaudeAuth, analyticsToken, passthrough, preserveTools, hybridTools, mergeTools, noAutoDetect, strictTls, pacingMinMs, pacingJitterMs, thinkTimeBaseMs, thinkTimePerTokenMs, thinkTimeJitterMs, thinkTimeMaxMs, sessionStartMinMs, sessionStartJitterMs, stealth, drainOnClose, sessionIdleRotateMs, sessionRotateJitterMs, sessionMaxAgeMs, sessionPerClient, preserveOrchestrationTags, noLiveCapture, strictTemplate, maxConcurrent, maxQueued, queueTimeoutMs, maxConcurrentPerConsumer, shutdownGraceMs, upstreamTimeoutMs, poolStrategy, poolHeadroomFloor, poolSharedState, poolSharedStateIntervalMs, effort, maxTokens, poolFallbackModel, familyFallback, modelAliases, logFile, passthroughBetas, skipFields, systemPrompt, overageGuardEnabled, overageGuardBehavior, overageGuardCooldownMs, overageGuardNotifyOs, honorClientThinking, preserveOutputFormat, midstreamContinue, ledger, keys, keysPath, keySockets: keySockets.length > 0 ? keySockets : undefined });
790
796
  }
791
797
  /**
792
798
  * `dario keys` — named keys for a shared dario (v6.8, dario#1318). One
@@ -1208,6 +1214,16 @@ function parsePreserveTagsValue(value) {
1208
1214
  return new Set(['*']);
1209
1215
  return new Set(trimmed.split(',').map(s => s.trim()).filter(Boolean));
1210
1216
  }
1217
+ /**
1218
+ * --upstream-timeout / DARIO_UPSTREAM_TIMEOUT_MS. The flag wins over the env
1219
+ * var even when it is 0, and 0 then resolves to undefined so the proxy's
1220
+ * default applies: an explicit `--upstream-timeout=0` keeps the default
1221
+ * rather than falling through to the env value. Exported for tests.
1222
+ */
1223
+ export function resolveUpstreamTimeoutMs(flag, env) {
1224
+ const ms = flag ?? parsePositiveIntEnv(env);
1225
+ return ms === 0 ? undefined : ms;
1226
+ }
1211
1227
  function parsePositiveIntFlag(prefix) {
1212
1228
  const found = args.find(a => a.startsWith(prefix));
1213
1229
  if (!found)
@@ -2212,6 +2228,12 @@ async function help() {
2212
2228
  container's stop grace above it, or Docker
2213
2229
  SIGKILLs the drain. Env:
2214
2230
  DARIO_SHUTDOWN_GRACE_MS. (dario#1370)
2231
+ --upstream-timeout=MS How long dario waits on upstream (default:
2232
+ 300000). Until the response streams it caps
2233
+ the whole wait; while a 2xx streams it is
2234
+ the longest gap between two chunks, so a
2235
+ live stream is never cut. 0 keeps the
2236
+ default. Env: DARIO_UPSTREAM_TIMEOUT_MS.
2215
2237
  --max-concurrent=N Max in-flight requests across the WHOLE proxy,
2216
2238
  not per seat (default: 10; a pool defaults to
2217
2239
  10 per seat). Past it, requests wait in dario.
@@ -2239,11 +2261,13 @@ async function help() {
2239
2261
  dario returns 504 "queue-timeout"
2240
2262
  (default: 60000).
2241
2263
  Env: DARIO_QUEUE_TIMEOUT_MS. (dario#80)
2242
- --pool-strategy=<headroom|fill-first|expiring-first>
2264
+ --pool-strategy=<headroom|headroom-by-plan|fill-first|expiring-first>
2243
2265
  Where new conversations land in a multi-
2244
2266
  account pool. headroom (default) spreads
2245
2267
  them to the seat with the most slack;
2246
- fill-first concentrates them on the
2268
+ headroom-by-plan (opt-in) counts each
2269
+ seat's slack by plan size (Max Nx = N,
2270
+ Pro = 1); fill-first concentrates them on the
2247
2271
  alphabetically-first eligible seat until
2248
2272
  it drains to the floor, then spills;
2249
2273
  expiring-first does the same in order of
@@ -2270,6 +2294,15 @@ async function help() {
2270
2294
  Needs one of those configured. Empty value
2271
2295
  disables. Env: DARIO_POOL_FALLBACK. Config:
2272
2296
  poolFallback.model.
2297
+ An entry written credits:<model> is a rung on
2298
+ the API credits a Max plan includes: with
2299
+ DARIO_CREDITS_API_KEY set (env only, never a
2300
+ flag) the request goes to the Claude API on
2301
+ that key as <model>, both wire shapes, after
2302
+ every seat and subscription rung; /analytics
2303
+ and the dashboard file it under credits, the
2304
+ ledger counts it as metered. Without the key
2305
+ the entry is inert and dario doctor says so.
2273
2306
  --family-fallback=<family=model,...>
2274
2307
  When every pool seat is parked for a model
2275
2308
  family, serve its requests as <model> on the
@@ -3174,12 +3207,16 @@ if (isDirectEntry) {
3174
3207
  if (!('Bun' in globalThis) && !process.env.DARIO_NO_BUN) {
3175
3208
  try {
3176
3209
  const { execFileSync, spawn } = await import('node:child_process');
3210
+ const { forwardSignals, relaunchExitCode } = await import('./relaunch-signals.js');
3177
3211
  execFileSync('bun', ['--version'], { stdio: 'ignore', timeout: 3000 });
3178
3212
  const child = spawn('bun', ['run', ...process.argv.slice(1)], {
3179
3213
  stdio: 'inherit',
3180
3214
  env: { ...process.env, DARIO_NO_BUN: '1' },
3181
3215
  });
3182
- child.on('exit', (code) => process.exit(code ?? 0));
3216
+ // The child owns the graceful shutdown; as PID 1 in a container this
3217
+ // process would otherwise drop SIGTERM and docker stop would SIGKILL both.
3218
+ forwardSignals(child);
3219
+ child.on('exit', (code, signal) => process.exit(relaunchExitCode(code, signal)));
3183
3220
  // Prevent this process from continuing
3184
3221
  await new Promise(() => { });
3185
3222
  }
@@ -29,6 +29,7 @@ import { resolveClaudeTarget } from './claude-model.js';
29
29
  import { parseEffortSuffix } from './effort.js';
30
30
  import { BAKED_BASE_MODELS } from './model-catalog.js';
31
31
  import { parseRetryAfterMs } from './provider-cooldown.js';
32
+ import { upstreamDeadline } from './upstream-deadline.js';
32
33
  import { noteCodexUsageHeaders, parseCodexUsageEndpoint, recordCodexUsage, codexUsageFor } from './codex-usage.js';
33
34
  export const CODEX_BACKEND_BASE_URL = process.env.DARIO_CODEX_BASE_URL || 'https://chatgpt.com/backend-api/codex';
34
35
  /** The usage endpoint the Codex CLI's /status reads, beside the Responses base. */
@@ -914,7 +915,8 @@ timing) {
914
915
  abort.abort();
915
916
  } };
916
917
  res.on('close', onClientClose);
917
- const timeout = setTimeout(() => abort.abort(), upstreamTimeoutMs);
918
+ // Caps the wait until headers, then the gap between chunks of the 2xx body.
919
+ const timeout = upstreamDeadline(upstreamTimeoutMs, () => abort.abort());
918
920
  let usage = null;
919
921
  try {
920
922
  if (verbose)
@@ -962,6 +964,7 @@ timing) {
962
964
  return true;
963
965
  }
964
966
  res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'Access-Control-Allow-Origin': corsOrigin, ...securityHeaders, ...splitHeaders() });
967
+ timeout.streaming();
965
968
  const reader = upstream.body.getReader();
966
969
  const decoder = new TextDecoder();
967
970
  let tail = '';
@@ -969,6 +972,7 @@ timing) {
969
972
  try {
970
973
  while (true) {
971
974
  const { done, value } = await reader.read();
975
+ timeout.touch();
972
976
  if (done)
973
977
  break;
974
978
  if (clientGone)
@@ -1033,7 +1037,7 @@ timing) {
1033
1037
  return true;
1034
1038
  }
1035
1039
  finally {
1036
- clearTimeout(timeout);
1040
+ timeout.clear();
1037
1041
  res.removeListener('close', onClientClose);
1038
1042
  }
1039
1043
  }
@@ -1196,7 +1200,9 @@ timing) {
1196
1200
  // Usage seen so far, so a stream the client abandoned still reports what the
1197
1201
  // subscription already spent. Populated once the translators exist.
1198
1202
  let usageSoFar = () => null;
1199
- const timeout = setTimeout(() => abort.abort(), upstreamTimeoutMs);
1203
+ // Caps the wait until headers, then the gap between chunks of the 2xx body:
1204
+ // the backend always streams, so a long answer is never cut mid-way.
1205
+ const timeout = upstreamDeadline(upstreamTimeoutMs, () => abort.abort());
1200
1206
  try {
1201
1207
  if (verbose)
1202
1208
  console.log(`[dario] → codex backend: ${target} (model: ${model})`);
@@ -1324,11 +1330,13 @@ timing) {
1324
1330
  }
1325
1331
  let buffered = '';
1326
1332
  if (upstream.body) {
1333
+ timeout.streaming();
1327
1334
  const reader = upstream.body.getReader();
1328
1335
  const decoder = new TextDecoder();
1329
1336
  try {
1330
1337
  while (true) {
1331
1338
  const { done, value } = await reader.read();
1339
+ timeout.touch();
1332
1340
  if (done)
1333
1341
  break;
1334
1342
  // The abort above normally makes this read reject; a stream that
@@ -1484,7 +1492,7 @@ timing) {
1484
1492
  return true;
1485
1493
  }
1486
1494
  finally {
1487
- clearTimeout(timeout);
1495
+ timeout.clear();
1488
1496
  res.removeListener('close', onClientClose);
1489
1497
  }
1490
1498
  }
@@ -89,7 +89,7 @@ export interface DarioConfig {
89
89
  * `expiring-first` is fill-first ordered by each seat's 7-day reset,
90
90
  * soonest first.
91
91
  */
92
- strategy?: 'headroom' | 'fill-first' | 'expiring-first';
92
+ strategy?: 'headroom' | 'headroom-by-plan' | 'fill-first' | 'expiring-first';
93
93
  /** Headroom at/below which a seat counts as drained — ratio 0.02..0.5 (dario#1333). */
94
94
  headroomFloor?: number;
95
95
  };
@@ -309,8 +309,8 @@ function sanitize(parsed) {
309
309
  }
310
310
  if (isPlainObject(parsed.pool)) {
311
311
  out.pool = {};
312
- if (parsed.pool.strategy === 'headroom' || parsed.pool.strategy === 'fill-first'
313
- || parsed.pool.strategy === 'expiring-first') {
312
+ if (parsed.pool.strategy === 'headroom' || parsed.pool.strategy === 'headroom-by-plan'
313
+ || parsed.pool.strategy === 'fill-first' || parsed.pool.strategy === 'expiring-first') {
314
314
  out.pool.strategy = parsed.pool.strategy;
315
315
  }
316
316
  if (typeof parsed.pool.headroomFloor === 'number' && Number.isFinite(parsed.pool.headroomFloor)) {
@@ -0,0 +1,72 @@
1
+ /**
2
+ * The credits rung of the failover chain.
3
+ *
4
+ * Since October 2026 a Max plan includes monthly Claude API credits ($100 on
5
+ * Max 5x, $200 on Max 20x). They are spent through a Console API key of the
6
+ * organization the plan is linked to, not through the seat's own five-hour
7
+ * and weekly windows, so until now the only ways to spend them through dario
8
+ * were ANTHROPIC_UPSTREAM_API_KEY (the whole Claude backend on the key, pool
9
+ * bypassed) or an OpenAI-compat backend pointed at Anthropic's compatibility
10
+ * layer, which Anthropic describes as a layer for testing models.
11
+ *
12
+ * A chain entry written `credits:<model>` names the key as a rung. When every
13
+ * seat is drained or cooling and no subscription rung can serve, the request
14
+ * goes to api.anthropic.com on the Messages path as a plain API request: the
15
+ * client's own body with the model swapped, `x-api-key`, no Claude Code
16
+ * template and no OAuth beta. Both wire shapes work, because the Claude path
17
+ * already translates OpenAI-shape requests and responses. The response says
18
+ * `x-dario-pool-fallback: credits:<model>`; the rolling window, `/metrics`
19
+ * and the dashboard file the request under its own billing bucket, `credits`,
20
+ * next to extra usage and API key; the persistent ledger counts it in its
21
+ * metered total, as spent; and the overage guard does not halt on it: a rung
22
+ * the operator configured is not a surprise bill.
23
+ *
24
+ * The key comes from DARIO_CREDITS_API_KEY only, never a flag, so it never
25
+ * lands in argv. The rung is opt-in twice: the entry must be in the chain AND
26
+ * the key must be set. Without the key the entry is inert, the proxy says so
27
+ * at startup and `dario doctor` says so in its Failover line.
28
+ *
29
+ * Where it sits: a subscription rung (a Codex account that lists an entry)
30
+ * always goes first, because the plan is the thing dario exists to spend.
31
+ * Among the metered rungs, this one and the openai-compat backend, the
32
+ * chain's own left-to-right order decides.
33
+ */
34
+ import { type ClaudeTarget, type ModelResolver } from './claude-model.js';
35
+ export declare const CREDITS_PREFIX = "credits:";
36
+ /** The analytics `account` of a request the credits rung served. */
37
+ export declare const ACCOUNT_KEY_CREDITS = "__credits__";
38
+ /** The value of `x-dario-pool-fallback` on a response the rung served. */
39
+ export declare function creditsFallbackMarker(model: string): string;
40
+ export declare function isCreditsEntry(entry: string): boolean;
41
+ /** The model half of a `credits:<model>` entry as written; null for any other entry. */
42
+ export declare function creditsEntryModel(entry: string): string | null;
43
+ /** Every credits entry's model as written, in chain order. */
44
+ export declare function creditsEntries(chain: readonly string[]): string[];
45
+ /** The chain without its credits entries: what the subscription and openai-compat rungs read. */
46
+ export declare function withoutCreditsEntries(chain: readonly string[]): string[];
47
+ /**
48
+ * True when the credits rung is asked before the openai-compat backend: the
49
+ * first credits entry precedes every plain entry, or there is no plain entry.
50
+ * False when the chain has no credits entry at all.
51
+ */
52
+ export declare function creditsBeforePlain(chain: readonly string[]): boolean;
53
+ /**
54
+ * The first credits entry the Claude catalog can serve, resolved exactly like
55
+ * a Claude chain entry: aliases (`credits:sonnet`), a provider prefix
56
+ * (`credits:anthropic:claude-opus-5`) and an effort suffix spelled the way a
57
+ * Claude entry spells it (`credits:claude:opus:high`, `credits:claude-opus-5-high`)
58
+ * all work, and a name the catalog does not list is skipped rather than sent, so
59
+ * a typo cannot trade a recoverable 429 for a 404 billed to the key.
60
+ */
61
+ export declare function pickCreditsTarget(chain: readonly string[], bases?: readonly string[], resolve?: ModelResolver): ClaudeTarget | null;
62
+ /**
63
+ * Outbound headers for a credits request: the headers a plain API client
64
+ * sends and nothing of Claude Code's identity. The client's own betas go
65
+ * through minus the OAuth flag, which an API key request must not carry.
66
+ */
67
+ export declare function creditsHeaders(key: string, clientBeta: string | undefined, anthropicVersion?: string): Record<string, string>;
68
+ /**
69
+ * One phrase for the startup line and `dario doctor`, or null when the chain
70
+ * names no credits entry.
71
+ */
72
+ export declare function describeCreditsRung(chain: readonly string[], hasKey: boolean): string | null;