@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
@@ -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;
package/dist/donuts.js CHANGED
@@ -1,3 +1,16 @@
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` page
8
+ * loaded before 6.17 (still served). 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
+ import { readFileSync } from 'node:fs';
1
14
  import { formatUsd } from './ledger.js';
2
15
  const PALETTE = ['#7c3aed', '#db2777', '#2563eb', '#059669', '#d97706', '#0891b2'];
3
16
  const OTHER = '#6b7280';
@@ -90,7 +103,7 @@ export function renderSpendDonuts(s) {
90
103
  /**
91
104
  * The server-rendered fragment behind `/analytics/ui`: headline, the three
92
105
  * rings, the rolling window, and a per-model table. Same gate as
93
- * `/analytics`; the shell page fetches it with the token the viewer typed.
106
+ * `/analytics`; the page before 6.17 fetched it with the token the viewer typed; it is still served.
94
107
  */
95
108
  export function renderAnalyticsView(summary, lifetime, version) {
96
109
  const w = summary.window;
@@ -119,58 +132,37 @@ ${table}
119
132
  ${accounts ? `<table><thead><tr><th>seat</th><th>requests</th><th>5h</th><th>7d</th><th>last claim</th></tr></thead><tbody>${accounts}</tbody></table>` : ''}
120
133
  <div class="foot">dario ${escapeHtml(version)} · rendered ${escapeHtml(new Date().toISOString().slice(0, 19).replace('T', ' '))} UTC</div>`;
121
134
  }
135
+ /** The page served when the build ran tsc without the copy step: say so instead of a 500. */
136
+ const ANALYTICS_UI_MISSING = '<!doctype html><html lang="en"><head><meta charset="utf-8"><title>dario</title></head><body><p>This build is missing <code>analytics-ui.html</code> next to <code>donuts.js</code>; run <code>npm run build</code>.</p></body></html>';
122
137
  /**
123
- * The static shell for `/analytics/ui`. Carries NO data — it is safe to
124
- * serve without auth — and asks the viewer for the token once (kept in
125
- * sessionStorage), then fetches `/analytics/view` every 60 s with it.
138
+ * A reader of the dashboard page that reads it once: the first read that succeeds is kept for
139
+ * every call after it. A failed read answers with the missing-file page and keeps nothing, so
140
+ * copying the file in takes effect on the next request without a restart. The reader is a
141
+ * parameter so the test can count the reads.
126
142
  */
127
- export const ANALYTICS_UI_SHELL = `<!doctype html>
128
- <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
129
- <title>dario analytics</title>
130
- <style>
131
- :root{color-scheme:dark}
132
- body{margin:0;background:#0a0a0f;color:#e5e7eb;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace;font-size:14px}
133
- 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}
134
- header h1{font-size:14px;letter-spacing:2px;margin:0;color:#9ca3af;font-weight:600}
135
- header input{background:#111827;color:#e5e7eb;border:1px solid #374151;border-radius:6px;padding:6px 10px;font:inherit;width:22em}
136
- header button{background:#7c3aed;color:#fff;border:0;border-radius:6px;padding:6px 12px;font:inherit;cursor:pointer}
137
- #status{color:#9ca3af;margin-left:auto}
138
- main{padding:20px;max-width:1100px;margin:0 auto}
139
- .headline{font-size:34px;font-weight:700;color:#fff;margin:6px 0 16px}
140
- .headline span{display:block;font-size:13px;font-weight:400;color:#9ca3af;margin-top:4px}
141
- .headline.muted{color:#6b7280}
142
- .stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px;margin-bottom:18px}
143
- .stat{background:#111827;border:1px solid #1f2937;border-radius:12px;padding:12px 14px}
144
- .stat .v{font-size:20px;font-weight:700;color:#fff}.stat .l{font-size:11px;color:#9ca3af;margin-top:2px}
145
- .rings svg{width:100%;max-width:640px;height:auto;display:block;margin:0 auto 18px}
146
- table{width:100%;border-collapse:collapse;margin:0 0 18px;font-size:12px}
147
- th,td{text-align:right;padding:6px 8px;border-bottom:1px solid #1f2937}th:first-child,td:first-child{text-align:left}
148
- th{color:#9ca3af;font-weight:500}
149
- .foot{color:#6b7280;font-size:11px}
150
- .err{color:#fca5a5;padding:20px;background:#1f1115;border:1px solid #7f1d1d;border-radius:12px}
151
- </style></head>
152
- <body>
153
- <header><h1>DARIO ANALYTICS</h1>
154
- <input id="tok" type="password" placeholder="analytics token or API key (blank on an unkeyed proxy)" autocomplete="off">
155
- <button id="go">connect</button><span id="status"></span></header>
156
- <main id="view"></main>
157
- <script>
158
- (function(){
159
- var tok=document.getElementById('tok'),view=document.getElementById('view'),status=document.getElementById('status'),timer=null;
160
- try{tok.value=sessionStorage.getItem('dario.analytics.token')||''}catch(e){}
161
- function headers(){var h={};if(tok.value)h['Authorization']='Bearer '+tok.value;return h}
162
- function load(){
163
- fetch('/analytics/view',{headers:headers(),cache:'no-store'}).then(function(r){
164
- if(r.status===401){view.innerHTML='<div class="err">401 — this proxy is keyed. Paste its analytics token (DARIO_ANALYTICS_TOKEN) or API key above.</div>';status.textContent='';return}
165
- if(!r.ok){view.innerHTML='<div class="err">'+r.status+' from /analytics/view</div>';return}
166
- return r.text().then(function(html){view.innerHTML=html;status.textContent='live · refreshes every 60 s';try{sessionStorage.setItem('dario.analytics.token',tok.value)}catch(e){}})
167
- }).catch(function(e){view.innerHTML='<div class="err">'+String(e)+'</div>'});
168
- }
169
- function start(){if(timer)clearInterval(timer);load();timer=setInterval(load,60000)}
170
- document.getElementById('go').addEventListener('click',start);
171
- tok.addEventListener('keydown',function(e){if(e.key==='Enter')start()});
172
- start();
173
- })();
174
- </script>
175
- </body></html>
176
- `;
143
+ export function analyticsUiLoader(read) {
144
+ let page = null;
145
+ return () => {
146
+ if (page !== null)
147
+ return page;
148
+ try {
149
+ page = read();
150
+ return page;
151
+ }
152
+ catch {
153
+ return ANALYTICS_UI_MISSING;
154
+ }
155
+ };
156
+ }
157
+ /**
158
+ * The dashboard behind `/analytics/ui`: `dist/analytics-ui.html`, copied from `src/` by the
159
+ * build and read once, on the first request. It carries no data and no credential. It asks for
160
+ * the analytics token or an API key once (kept in sessionStorage) and reads `/analytics`,
161
+ * `/accounts`, `/codex`, `/health`, `/analytics/ledger` and `/admin/keys` with it, and tails
162
+ * `/analytics/stream`; it loads nothing from outside the proxy. The
163
+ * `window.DARIO_COCKPIT = "served"` line in its head tells the page a proxy is serving it, so it
164
+ * goes live at once; the same file opened from disk runs on sample data, because the page checks
165
+ * the protocol before the marker. `/analytics/view` and the rings above are unchanged for the CLI
166
+ * and for anything that embedded them.
167
+ */
168
+ export const analyticsUiPage = analyticsUiLoader(() => readFileSync(new URL('./analytics-ui.html', import.meta.url), 'utf8'));
package/dist/ledger.d.ts CHANGED
@@ -142,6 +142,11 @@ export declare function dayKey(atMs: number): string;
142
142
  * claim, and an absent claim on a 2xx (stream aborts, api-key mode without
143
143
  * the header) are covered — the request was served, and nothing says it was
144
144
  * billed per token.
145
+ *
146
+ * `credits` is metered too: the credits rung spends the monthly API credits
147
+ * a Max plan includes, at API rates, from a balance with an end. That is
148
+ * money the plan handed over and the request used up, not a bill that never
149
+ * arrived, so it is reported as spent and never counted as saved.
145
150
  */
146
151
  export declare function ledgerBucketFor(record: Pick<RequestRecord, 'status' | 'claim'>): LedgerBucket | null;
147
152
  /**
@@ -169,6 +174,19 @@ export declare function consumerDayUsage(file: LedgerFile, consumer: string, now
169
174
  /** `1234` → `1.2k`, `1234567` → `1.2M`; below a thousand, the number itself. */
170
175
  export declare function formatTokenCount(n: number): string;
171
176
  export declare function summarizeLedger(file: LedgerFile, path: string, now?: number): LedgerSummary;
177
+ /** One row of the table as money: the bucket's USD at that day's rate, present where the bucket is. */
178
+ export type PricedLedgerRow = Partial<Record<LedgerBucket, number>>;
179
+ /**
180
+ * The table priced row for row, the way summarizeLedger prices it: what
181
+ * /analytics/ledger sends next to the raw cells, so a reader charting them
182
+ * (the dashboard, a script) needs no price list of its own and a pricing
183
+ * correction here reaches every reader at once.
184
+ */
185
+ export interface PricedLedger {
186
+ days: Record<string, Record<string, PricedLedgerRow>>;
187
+ consumers: Record<string, Record<string, Record<string, PricedLedgerRow>>>;
188
+ }
189
+ export declare function priceLedger(file: LedgerFile): PricedLedger;
172
190
  /**
173
191
  * Read a ledger file for display without a running proxy (`dario usage`
174
192
  * when the proxy is down). Missing file → null; unreadable → null with the
@@ -204,6 +222,8 @@ export declare class Ledger {
204
222
  };
205
223
  /** The raw per-day table, for /analytics/ledger. */
206
224
  snapshot(): LedgerFile;
225
+ /** The same table as money, sent next to it on /analytics/ledger. */
226
+ priced(): PricedLedger;
207
227
  private scheduleFlush;
208
228
  /** Write now if anything changed. Serialized; a failure is logged, not thrown. */
209
229
  flush(): Promise<void>;
package/dist/ledger.js CHANGED
@@ -71,12 +71,17 @@ function dayMs(day) {
71
71
  * claim, and an absent claim on a 2xx (stream aborts, api-key mode without
72
72
  * the header) are covered — the request was served, and nothing says it was
73
73
  * billed per token.
74
+ *
75
+ * `credits` is metered too: the credits rung spends the monthly API credits
76
+ * a Max plan includes, at API rates, from a balance with an end. That is
77
+ * money the plan handed over and the request used up, not a bill that never
78
+ * arrived, so it is reported as spent and never counted as saved.
74
79
  */
75
80
  export function ledgerBucketFor(record) {
76
81
  if (record.status < 200 || record.status >= 300)
77
82
  return null;
78
83
  const bucket = billingBucketFromClaim(record.claim);
79
- return bucket === 'api' || bucket === 'extra_usage' ? 'metered' : 'covered';
84
+ return bucket === 'api' || bucket === 'extra_usage' || bucket === 'credits' ? 'metered' : 'covered';
80
85
  }
81
86
  function isCell(v) {
82
87
  if (!v || typeof v !== 'object')
@@ -365,6 +370,36 @@ export function summarizeLedger(file, path, now = Date.now()) {
365
370
  perConsumer: summarizeLedgerConsumers(file, now),
366
371
  };
367
372
  }
373
+ export function priceLedger(file) {
374
+ const priceRow = (day, model, row) => {
375
+ const at = dayMs(day);
376
+ const out = {};
377
+ if (row.covered)
378
+ out.covered = round(costOfTokens(model, at, row.covered));
379
+ if (row.metered)
380
+ out.metered = round(costOfTokens(model, at, row.metered));
381
+ return out;
382
+ };
383
+ const days = {};
384
+ for (const [day, models] of Object.entries(file.days)) {
385
+ const priced = {};
386
+ for (const [model, row] of Object.entries(models))
387
+ priced[model] = priceRow(day, model, row);
388
+ days[day] = priced;
389
+ }
390
+ const consumers = {};
391
+ for (const [day, byConsumer] of Object.entries(file.consumers ?? {})) {
392
+ const priced = {};
393
+ for (const [consumer, models] of Object.entries(byConsumer)) {
394
+ const rows = {};
395
+ for (const [model, row] of Object.entries(models))
396
+ rows[model] = priceRow(day, model, row);
397
+ priced[consumer] = rows;
398
+ }
399
+ consumers[day] = priced;
400
+ }
401
+ return { days, consumers };
402
+ }
368
403
  /**
369
404
  * Read a ledger file for display without a running proxy (`dario usage`
370
405
  * when the proxy is down). Missing file → null; unreadable → null with the
@@ -438,6 +473,10 @@ export class Ledger {
438
473
  snapshot() {
439
474
  return JSON.parse(JSON.stringify(this.file));
440
475
  }
476
+ /** The same table as money, sent next to it on /analytics/ledger. */
477
+ priced() {
478
+ return priceLedger(this.file);
479
+ }
441
480
  scheduleFlush() {
442
481
  this.dirty = true;
443
482
  if (this.timer)
package/dist/metrics.js CHANGED
@@ -130,7 +130,8 @@ export function renderPrometheus(input) {
130
130
  if (p.estimatedExhaustionMinutes !== null) {
131
131
  metric('dario_predicted_exhaustion_minutes', 'Minutes until the current seat window is predicted to exhaust at the present burn rate.', [[{}, p.estimatedExhaustionMinutes]]);
132
132
  }
133
- metric('dario_burn_tokens_per_minute', 'Token burn rate over the rolling window.', [[{}, p.tokenBurnRate]]);
133
+ metric('dario_burn_tokens_per_minute', 'Input and output token burn rate over the rolling window; cache reads and writes are not in it.', [[{}, p.tokenBurnRate]]);
134
+ metric('dario_burn_total_tokens_per_minute', 'Token burn rate over the rolling window, every kind: input, output, cache reads and cache writes.', [[{}, p.totalTokenBurnRate]]);
134
135
  metric('dario_burn_cost_usd_per_minute', 'API-equivalent cost burn rate over the rolling window, USD/min.', [[{}, p.costBurnRate]]);
135
136
  // ---- ledger (survives restarts) ----------------------------------------
136
137
  if (lifetime) {
package/dist/midstream.js CHANGED
@@ -47,6 +47,7 @@
47
47
  * the existing pre-byte failover covers them), and the api-key OpenAI backend.
48
48
  * A stream that cannot be continued ends exactly as it did before this module.
49
49
  */
50
+ import { upstreamDeadline } from './upstream-deadline.js';
50
51
  /**
51
52
  * Marks a loopback request as a continuation and carries its DEPTH: `1` for
52
53
  * the resume of a client request, `2` for the resume of that resume. A
@@ -836,7 +837,8 @@ export class MidstreamGuard {
836
837
  const body = buildResumeBody(this.o.shape, clientBody, target.model, partial);
837
838
  const splicer = new Splicer(this.o.shape, s, partial);
838
839
  const abort = new AbortController();
839
- const timer = setTimeout(() => abort.abort(), r.timeoutMs);
840
+ // Caps the wait until headers, then the gap between chunks of the resume.
841
+ const timer = upstreamDeadline(r.timeoutMs, () => abort.abort());
840
842
  const startedAt = Date.now();
841
843
  try {
842
844
  r.onBeforeResume?.();
@@ -854,6 +856,7 @@ export class MidstreamGuard {
854
856
  // An SSE comment, ignored by every parser, so a raw capture shows where
855
857
  // the second provider took over.
856
858
  this.o.write(`${SEAM_COMMENT_PREFIX} ${target.label} after ${partial.length} chars\n\n`);
859
+ timer.streaming();
857
860
  const reader = res.body.getReader();
858
861
  const split = new SseFrameSplitter();
859
862
  let sawContent = false;
@@ -861,6 +864,7 @@ export class MidstreamGuard {
861
864
  try {
862
865
  outer: while (true) {
863
866
  const { done, value } = await reader.read();
867
+ timer.touch();
864
868
  if (done)
865
869
  break;
866
870
  if (this.o.isClientGone()) {
@@ -919,7 +923,7 @@ export class MidstreamGuard {
919
923
  return 'failed';
920
924
  }
921
925
  finally {
922
- clearTimeout(timer);
926
+ timer.clear();
923
927
  }
924
928
  }
925
929
  log(line) {
@@ -15,6 +15,7 @@
15
15
  import { readFile, writeFile, mkdir, unlink, readdir } from 'node:fs/promises';
16
16
  import { join, basename } from 'node:path';
17
17
  import { homedir } from 'node:os';
18
+ import { upstreamDeadline } from './upstream-deadline.js';
18
19
  const DARIO_DIR = join(homedir(), '.dario');
19
20
  const BACKENDS_DIR = join(DARIO_DIR, 'backends');
20
21
  export const MINIMAX_MODELS = ['MiniMax-M3.1-Flash', 'MiniMax-M3'];
@@ -138,7 +139,8 @@ export async function forwardToOpenAI(req, res, body, backend, corsOrigin, secur
138
139
  // Drop Anthropic-specific headers entirely
139
140
  void clientBeta;
140
141
  const abort = new AbortController();
141
- const timeout = setTimeout(() => abort.abort(), upstreamTimeoutMs);
142
+ // Caps the wait until headers, then the gap between chunks of a 2xx body.
143
+ const timeout = upstreamDeadline(upstreamTimeoutMs, () => abort.abort());
142
144
  try {
143
145
  if (verbose) {
144
146
  console.log(`[dario] → openai backend: ${target}`);
@@ -165,10 +167,13 @@ export async function forwardToOpenAI(req, res, body, backend, corsOrigin, secur
165
167
  }
166
168
  res.writeHead(upstream.status, respHeaders);
167
169
  if (upstream.body) {
170
+ if (upstream.ok)
171
+ timeout.streaming();
168
172
  const reader = upstream.body.getReader();
169
173
  try {
170
174
  while (true) {
171
175
  const { done, value } = await reader.read();
176
+ timeout.touch();
172
177
  if (done)
173
178
  break;
174
179
  if (value)
@@ -182,7 +187,7 @@ export async function forwardToOpenAI(req, res, body, backend, corsOrigin, secur
182
187
  res.end();
183
188
  }
184
189
  catch (err) {
185
- clearTimeout(timeout);
190
+ timeout.clear();
186
191
  // Log error details server-side only. Responding with err.message
187
192
  // exposes internal stack / path / module information (CodeQL
188
193
  // js/stack-trace-exposure). The client gets a generic 502.
@@ -205,6 +210,6 @@ export async function forwardToOpenAI(req, res, body, backend, corsOrigin, secur
205
210
  return;
206
211
  }
207
212
  finally {
208
- clearTimeout(timeout);
213
+ timeout.clear();
209
214
  }
210
215
  }