@askalf/dario 6.16.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.
@@ -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
  }
package/dist/cli.js CHANGED
@@ -2294,6 +2294,15 @@ async function help() {
2294
2294
  Needs one of those configured. Empty value
2295
2295
  disables. Env: DARIO_POOL_FALLBACK. Config:
2296
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.
2297
2306
  --family-fallback=<family=model,...>
2298
2307
  When every pool seat is parked for a model
2299
2308
  family, serve its requests as <model> on the
@@ -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;
@@ -0,0 +1,133 @@
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 { resolveClaudeTarget } from './claude-model.js';
35
+ export const CREDITS_PREFIX = 'credits:';
36
+ /** The analytics `account` of a request the credits rung served. */
37
+ export const ACCOUNT_KEY_CREDITS = '__credits__';
38
+ /** The OAuth-enablement beta a plain API request must not carry. */
39
+ const OAUTH_BETA = 'oauth-2025-04-20';
40
+ /** The value of `x-dario-pool-fallback` on a response the rung served. */
41
+ export function creditsFallbackMarker(model) {
42
+ return `${CREDITS_PREFIX}${model}`;
43
+ }
44
+ export function isCreditsEntry(entry) {
45
+ return entry.trim().toLowerCase().startsWith(CREDITS_PREFIX);
46
+ }
47
+ /** The model half of a `credits:<model>` entry as written; null for any other entry. */
48
+ export function creditsEntryModel(entry) {
49
+ const e = entry.trim();
50
+ if (!e.toLowerCase().startsWith(CREDITS_PREFIX))
51
+ return null;
52
+ const model = e.slice(CREDITS_PREFIX.length).trim();
53
+ return model || null;
54
+ }
55
+ /** Every credits entry's model as written, in chain order. */
56
+ export function creditsEntries(chain) {
57
+ const out = [];
58
+ for (const entry of chain) {
59
+ const model = creditsEntryModel(entry);
60
+ if (model)
61
+ out.push(model);
62
+ }
63
+ return out;
64
+ }
65
+ /** The chain without its credits entries: what the subscription and openai-compat rungs read. */
66
+ export function withoutCreditsEntries(chain) {
67
+ return chain.filter((entry) => !isCreditsEntry(entry));
68
+ }
69
+ /**
70
+ * True when the credits rung is asked before the openai-compat backend: the
71
+ * first credits entry precedes every plain entry, or there is no plain entry.
72
+ * False when the chain has no credits entry at all.
73
+ */
74
+ export function creditsBeforePlain(chain) {
75
+ const credits = chain.findIndex((entry) => isCreditsEntry(entry));
76
+ if (credits < 0)
77
+ return false;
78
+ const plain = chain.findIndex((entry) => !isCreditsEntry(entry));
79
+ return plain < 0 || credits < plain;
80
+ }
81
+ /**
82
+ * The first credits entry the Claude catalog can serve, resolved exactly like
83
+ * a Claude chain entry: aliases (`credits:sonnet`), a provider prefix
84
+ * (`credits:anthropic:claude-opus-5`) and an effort suffix spelled the way a
85
+ * Claude entry spells it (`credits:claude:opus:high`, `credits:claude-opus-5-high`)
86
+ * all work, and a name the catalog does not list is skipped rather than sent, so
87
+ * a typo cannot trade a recoverable 429 for a 404 billed to the key.
88
+ */
89
+ export function pickCreditsTarget(chain, bases, resolve) {
90
+ for (const entry of chain) {
91
+ const model = creditsEntryModel(entry);
92
+ if (!model)
93
+ continue;
94
+ const target = resolveClaudeTarget(model, bases, resolve);
95
+ if (target)
96
+ return target;
97
+ }
98
+ return null;
99
+ }
100
+ /**
101
+ * Outbound headers for a credits request: the headers a plain API client
102
+ * sends and nothing of Claude Code's identity. The client's own betas go
103
+ * through minus the OAuth flag, which an API key request must not carry.
104
+ */
105
+ export function creditsHeaders(key, clientBeta, anthropicVersion = '2023-06-01') {
106
+ const headers = {
107
+ 'accept': 'application/json',
108
+ 'Content-Type': 'application/json',
109
+ 'x-api-key': key,
110
+ 'anthropic-version': anthropicVersion,
111
+ };
112
+ const beta = (clientBeta ?? '')
113
+ .split(',')
114
+ .map((b) => b.trim())
115
+ .filter((b) => b.length > 0 && b !== OAUTH_BETA)
116
+ .join(',');
117
+ if (beta)
118
+ headers['anthropic-beta'] = beta;
119
+ return headers;
120
+ }
121
+ /**
122
+ * One phrase for the startup line and `dario doctor`, or null when the chain
123
+ * names no credits entry.
124
+ */
125
+ export function describeCreditsRung(chain, hasKey) {
126
+ const credits = creditsEntries(chain);
127
+ if (credits.length === 0)
128
+ return null;
129
+ const model = credits[0];
130
+ return hasKey
131
+ ? `API credits as ${model} (x-api-key on the Messages path, both wire shapes, billing bucket credits, metered in the ledger)`
132
+ : `credits:${model} named but DARIO_CREDITS_API_KEY is not set, so that rung is inert`;
133
+ }
@@ -83,6 +83,8 @@ export declare function failoverReadiness(input: {
83
83
  chain: readonly string[];
84
84
  codexAccounts: number;
85
85
  backends: readonly string[];
86
+ /** DARIO_CREDITS_API_KEY is set (the credits rung, credits-rung.ts). */
87
+ creditsKey?: boolean;
86
88
  }): {
87
89
  status: CheckStatus;
88
90
  detail: string;
@@ -12,6 +12,7 @@
12
12
  */
13
13
  import { grantAge, grantThresholds, worstGrantLevel, describeGrantAge } from './refresh-grant.js';
14
14
  import { maskEmail } from './pool.js';
15
+ import { creditsEntries, withoutCreditsEntries } from './credits-rung.js';
15
16
  import { readFileSync } from 'node:fs';
16
17
  import { join, dirname } from 'node:path';
17
18
  import { fileURLToPath } from 'node:url';
@@ -111,36 +112,71 @@ function describeAge(ms) {
111
112
  }
112
113
  export function failoverReadiness(input) {
113
114
  const { chain, codexAccounts, backends } = input;
115
+ const creditsKey = input.creditsKey === true;
114
116
  const hasCodex = codexAccounts > 0;
115
117
  const hasBackend = backends.length > 0;
118
+ // The subscription and openai-compat rungs read the chain without its
119
+ // credits entries; the credits rung is reported on the end of whichever
120
+ // verdict those earn.
121
+ const plain = withoutCreditsEntries(chain);
122
+ const credits = creditsEntries(chain);
123
+ const creditsArmed = credits.length > 0 && creditsKey;
124
+ const creditsNote = credits.length > 0
125
+ ? (creditsKey
126
+ ? ` Then API credits as ${credits[0]} on DARIO_CREDITS_API_KEY (billing bucket credits, metered in the ledger).`
127
+ : ` credits:${credits[0]} is in the chain but DARIO_CREDITS_API_KEY is not set, so that rung is inert.`)
128
+ : (creditsKey ? ' DARIO_CREDITS_API_KEY is set but no chain entry names credits:<model>, so nothing spends it.' : '');
116
129
  if (chain.length === 0) {
117
- return { status: 'info', detail: 'off — a drained Claude pool returns 429/503 (--pool-fallback to arm)' };
130
+ return { status: 'info', detail: 'off: a drained Claude pool returns 429/503 (--pool-fallback to arm)' + creditsNote };
131
+ }
132
+ if (plain.length === 0) {
133
+ // Nothing but credits entries: the rung is the whole chain.
134
+ if (creditsArmed) {
135
+ return {
136
+ status: 'ok',
137
+ detail: `claude → API credits as ${credits[0]} (x-api-key on the Messages path, both wire shapes, billing bucket credits, metered in the ledger). `
138
+ + 'Metered: add a Codex account (`dario add altman`) to the chain to spend a subscription first.',
139
+ };
140
+ }
141
+ return {
142
+ status: 'warn',
143
+ detail: `armed (${chain.join(' → ')}) but INERT: DARIO_CREDITS_API_KEY is not set. Set it (env only, never a flag) `
144
+ + 'to arm the credits rung, or add a subscription rung: `dario add altman`.',
145
+ };
118
146
  }
119
147
  if (!hasCodex && !hasBackend) {
148
+ if (creditsArmed) {
149
+ return {
150
+ status: 'ok',
151
+ detail: `claude → API credits as ${credits[0]} (both wire shapes, billing bucket credits, metered in the ledger). `
152
+ + `${plain.join(' → ')} has no Codex account and no backend to serve it; add one `
153
+ + '(`dario add altman` for a subscription, `dario backend add …` for an api key) to spend it before the key.',
154
+ };
155
+ }
120
156
  return {
121
157
  status: 'warn',
122
- detail: `armed (${chain.join(' → ')}) but INERT — no Codex account and no backend to fall back to. `
123
- + 'Add one: `dario add altman` (subscription) or `dario backend add …` (api key).',
158
+ detail: `armed (${chain.join(' → ')}) but INERT: no Codex account and no backend to fall back to. `
159
+ + 'Add one: `dario add altman` (subscription) or `dario backend add …` (api key).' + creditsNote,
124
160
  };
125
161
  }
126
- if (hasCodex && chain.length > 1) {
162
+ if (hasCodex && plain.length > 1) {
127
163
  return {
128
164
  status: 'ok',
129
- detail: `symmetric: ${chain.join(' → ')}, across ${codexAccounts} Codex account`
130
- + `${codexAccounts === 1 ? '' : 's'}${hasBackend ? ` + ${backends.length} backend(s)` : ''}`,
165
+ detail: `symmetric: ${plain.join(' → ')}, across ${codexAccounts} Codex account`
166
+ + `${codexAccounts === 1 ? '' : 's'}${hasBackend ? ` + ${backends.length} backend(s)` : ''}.` + creditsNote,
131
167
  };
132
168
  }
133
169
  if (hasCodex) {
134
170
  return {
135
171
  status: 'ok',
136
- detail: `claude → codex as ${chain[0]} (both wire shapes). One-way — add a Claude model to `
137
- + 'the chain (--pool-fallback=a,b) to cover a rate-limited ChatGPT plan too.',
172
+ detail: `claude → codex as ${plain[0]} (both wire shapes). One-way: add a Claude model to `
173
+ + 'the chain (--pool-fallback=a,b) to cover a rate-limited ChatGPT plan too.' + creditsNote,
138
174
  };
139
175
  }
140
176
  return {
141
177
  status: 'ok',
142
- detail: `claude → ${backends[0]} as ${chain[0]} — OpenAI path only. A Codex account would `
143
- + 'extend it to Anthropic-shape clients (Claude Code, agent runtimes).',
178
+ detail: `claude → ${backends[0]} as ${plain[0]}, OpenAI path only. A Codex account would `
179
+ + 'extend it to Anthropic-shape clients (Claude Code, agent runtimes).' + creditsNote,
144
180
  };
145
181
  }
146
182
  /**
@@ -1252,6 +1288,7 @@ export async function runChecks(opts = {}) {
1252
1288
  chain,
1253
1289
  codexAccounts: codexAliases.length,
1254
1290
  backends: backends.map((b) => b.name),
1291
+ creditsKey: (process.env.DARIO_CREDITS_API_KEY ?? '').trim().length > 0,
1255
1292
  });
1256
1293
  checks.push({ status: verdict.status, label: 'Failover', detail: verdict.detail });
1257
1294
  const midstreamEnabled = !['0', 'false', 'no', 'off'].includes((process.env.DARIO_MIDSTREAM_CONTINUE ?? '').toLowerCase());
package/dist/donuts.d.ts CHANGED
@@ -1,15 +1,3 @@
1
- /**
2
- * Spend donuts — where the API-equivalent number comes from, as shares.
3
- *
4
- * Three rings from the ledger, no new data: by model, by consumer (named
5
- * key / `x-dario-consumer`), and covered-vs-metered. Rendered as one SVG
6
- * (`dario usage --donut`, `GET /analytics/donuts.svg`) and inside the
7
- * server-rendered `/analytics/view` fragment that the `/analytics/ui` shell
8
- * page loads. Same palette and frame as the share card in ledger.ts.
9
- *
10
- * Pure over a LedgerSummary / AnalyticsSummary so the geometry is testable
11
- * without a proxy.
12
- */
13
1
  import type { LedgerSummary } from './ledger.js';
14
2
  import type { AnalyticsSummary } from './analytics.js';
15
3
  import type { QueueSnapshot } from './request-queue.js';
@@ -35,12 +23,25 @@ export declare function renderSpendDonuts(s: LedgerSummary): string;
35
23
  /**
36
24
  * The server-rendered fragment behind `/analytics/ui`: headline, the three
37
25
  * rings, the rolling window, and a per-model table. Same gate as
38
- * `/analytics`; the shell page fetches it with the token the viewer typed.
26
+ * `/analytics`; the page before 6.17 fetched it with the token the viewer typed; it is still served.
39
27
  */
40
28
  export declare function renderAnalyticsView(summary: AnalyticsView, lifetime: LedgerSummary | null, version: string): string;
41
29
  /**
42
- * The static shell for `/analytics/ui`. Carries NO data — it is safe to
43
- * serve without auth — and asks the viewer for the token once (kept in
44
- * sessionStorage), then fetches `/analytics/view` every 60 s with it.
30
+ * A reader of the dashboard page that reads it once: the first read that succeeds is kept for
31
+ * every call after it. A failed read answers with the missing-file page and keeps nothing, so
32
+ * copying the file in takes effect on the next request without a restart. The reader is a
33
+ * parameter so the test can count the reads.
34
+ */
35
+ export declare function analyticsUiLoader(read: () => string): () => string;
36
+ /**
37
+ * The dashboard behind `/analytics/ui`: `dist/analytics-ui.html`, copied from `src/` by the
38
+ * build and read once, on the first request. It carries no data and no credential. It asks for
39
+ * the analytics token or an API key once (kept in sessionStorage) and reads `/analytics`,
40
+ * `/accounts`, `/codex`, `/health`, `/analytics/ledger` and `/admin/keys` with it, and tails
41
+ * `/analytics/stream`; it loads nothing from outside the proxy. The
42
+ * `window.DARIO_COCKPIT = "served"` line in its head tells the page a proxy is serving it, so it
43
+ * goes live at once; the same file opened from disk runs on sample data, because the page checks
44
+ * the protocol before the marker. `/analytics/view` and the rings above are unchanged for the CLI
45
+ * and for anything that embedded them.
45
46
  */
46
- export declare const ANALYTICS_UI_SHELL = "<!doctype html>\n<html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n<title>dario analytics</title>\n<style>\n :root{color-scheme:dark}\n body{margin:0;background:#0a0a0f;color:#e5e7eb;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,\"Liberation Mono\",monospace;font-size:14px}\n header{display:flex;gap:12px;align-items:center;padding:14px 20px;border-bottom:1px solid #1f2937;background:linear-gradient(90deg,#7c3aed,#db2777) top/100% 4px no-repeat,#0a0a0f}\n header h1{font-size:14px;letter-spacing:2px;margin:0;color:#9ca3af;font-weight:600}\n header input{background:#111827;color:#e5e7eb;border:1px solid #374151;border-radius:6px;padding:6px 10px;font:inherit;width:22em}\n header button{background:#7c3aed;color:#fff;border:0;border-radius:6px;padding:6px 12px;font:inherit;cursor:pointer}\n #status{color:#9ca3af;margin-left:auto}\n main{padding:20px;max-width:1100px;margin:0 auto}\n .headline{font-size:34px;font-weight:700;color:#fff;margin:6px 0 16px}\n .headline span{display:block;font-size:13px;font-weight:400;color:#9ca3af;margin-top:4px}\n .headline.muted{color:#6b7280}\n .stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px;margin-bottom:18px}\n .stat{background:#111827;border:1px solid #1f2937;border-radius:12px;padding:12px 14px}\n .stat .v{font-size:20px;font-weight:700;color:#fff}.stat .l{font-size:11px;color:#9ca3af;margin-top:2px}\n .rings svg{width:100%;max-width:640px;height:auto;display:block;margin:0 auto 18px}\n table{width:100%;border-collapse:collapse;margin:0 0 18px;font-size:12px}\n th,td{text-align:right;padding:6px 8px;border-bottom:1px solid #1f2937}th:first-child,td:first-child{text-align:left}\n th{color:#9ca3af;font-weight:500}\n .foot{color:#6b7280;font-size:11px}\n .err{color:#fca5a5;padding:20px;background:#1f1115;border:1px solid #7f1d1d;border-radius:12px}\n</style></head>\n<body>\n<header><h1>DARIO ANALYTICS</h1>\n <input id=\"tok\" type=\"password\" placeholder=\"analytics token or API key (blank on an unkeyed proxy)\" autocomplete=\"off\">\n <button id=\"go\">connect</button><span id=\"status\"></span></header>\n<main id=\"view\"></main>\n<script>\n(function(){\n var tok=document.getElementById('tok'),view=document.getElementById('view'),status=document.getElementById('status'),timer=null;\n try{tok.value=sessionStorage.getItem('dario.analytics.token')||''}catch(e){}\n function headers(){var h={};if(tok.value)h['Authorization']='Bearer '+tok.value;return h}\n function load(){\n fetch('/analytics/view',{headers:headers(),cache:'no-store'}).then(function(r){\n if(r.status===401){view.innerHTML='<div class=\"err\">401 \u2014 this proxy is keyed. Paste its analytics token (DARIO_ANALYTICS_TOKEN) or API key above.</div>';status.textContent='';return}\n if(!r.ok){view.innerHTML='<div class=\"err\">'+r.status+' from /analytics/view</div>';return}\n return r.text().then(function(html){view.innerHTML=html;status.textContent='live \u00B7 refreshes every 60 s';try{sessionStorage.setItem('dario.analytics.token',tok.value)}catch(e){}})\n }).catch(function(e){view.innerHTML='<div class=\"err\">'+String(e)+'</div>'});\n }\n function start(){if(timer)clearInterval(timer);load();timer=setInterval(load,60000)}\n document.getElementById('go').addEventListener('click',start);\n tok.addEventListener('keydown',function(e){if(e.key==='Enter')start()});\n start();\n})();\n</script>\n</body></html>\n";
47
+ export declare const analyticsUiPage: () => string;