@askalf/dario 6.8.11 → 6.8.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -407,7 +407,9 @@ The rolling window forgets on every restart; the **ledger** does not. Since 6.6
407
407
  Today $48.20 · Last 7d $413 · Last 30d $413
408
408
  ```
409
409
 
410
- Only served requests count. Traffic that was metered anyway — an API key upstream, or Anthropic's paid `extra_usage` overage — is kept in its own column and reported as spent, not saved. `dario usage --card` writes the headline as a 640×320 SVG you can drop in a README or a post; `--no-ledger` / `DARIO_LEDGER=0` turns the file off, `DARIO_LEDGER_PATH` moves it, and `GET /analytics/ledger` is the per-day table behind the number. Details: [api-equivalent-spend.md](./docs/api-equivalent-spend.md).
410
+ Only served requests count. Traffic that was metered anyway — an API key upstream, or Anthropic's paid `extra_usage` overage — is kept in its own column and reported as spent, not saved. `dario usage --card` writes the headline as a 640×320 SVG you can drop in a README or a post, and `--donut` writes the same number as three rings — by model, by key, subscription vs metered; `--no-ledger` / `DARIO_LEDGER=0` turns the file off, `DARIO_LEDGER_PATH` moves it, and `GET /analytics/ledger` is the per-day table behind the number. Details: [api-equivalent-spend.md](./docs/api-equivalent-spend.md).
411
+
412
+ **Scrape it, or open it.** `GET /metrics` is the same state as Prometheus text exposition — window, seats, models, consumers, queue, latency quantiles, burn rates, ledger — so Grafana reads dario like anything else. `GET /analytics/ui` is a self-contained dashboard page with the headline, the rings and the tables, refreshing every minute. Both sit behind the same gate as `/analytics`; `--analytics-token` (env `DARIO_ANALYTICS_TOKEN`) adds a **read-only** credential accepted on those paths and nowhere else, so a scraper or a browser can hold the numbers without holding request rights. Families and the gate: [analytics.md](./docs/analytics.md).
411
413
 
412
414
  ## It tracks a moving target
413
415
 
package/dist/cli.d.ts CHANGED
@@ -10,6 +10,14 @@
10
10
  * dario logout — Remove saved credentials
11
11
  */
12
12
  import { type EffortValue } from './cc-template.js';
13
+ /**
14
+ * Bare words after `proxy` (dario#1353). `dario proxy` takes flags only, but it
15
+ * read them by prefix and ignored everything else, so `dario proxy status`,
16
+ * typed by someone expecting a report, started a full proxy and ran the OAuth
17
+ * refresh timer against the shared credential for five days. Anything that is
18
+ * not a flag is an error now; the one obvious guess is an alias for the report.
19
+ */
20
+ export declare function strayProxyArgs(argv: readonly string[]): string[];
13
21
  /**
14
22
  * Parse `--system-prompt=<verbatim|partial|aggressive|filepath>` (or the
15
23
  * `DARIO_SYSTEM_PROMPT` env-var fallback) into the value passed through
package/dist/cli.js CHANGED
@@ -18,6 +18,7 @@
18
18
  // other startup side effect.
19
19
  import { unlink, writeFile } from 'node:fs/promises';
20
20
  import { formatLedgerSummary, formatLedgerConsumers, formatUsd, renderLedgerCard, readLedgerFile, resolveLedgerPath, summarizeLedger } from './ledger.js';
21
+ import { renderSpendDonuts } from './donuts.js';
21
22
  import { KeyStore, createKey, revokeKey, rotateKey, deleteKey, parseExpiry, publicKey, resolveKeysPath, KEY_NAME_RE } from './keys.js';
22
23
  import { loadAllAccounts as loadAllAccountsForIdentity, regenerateClientIdentity } from './accounts.js';
23
24
  import { maskEmail, parsePoolHeadroomFloor } from './pool.js';
@@ -299,7 +300,28 @@ async function logout() {
299
300
  }
300
301
  }
301
302
  }
303
+ /**
304
+ * Bare words after `proxy` (dario#1353). `dario proxy` takes flags only, but it
305
+ * read them by prefix and ignored everything else, so `dario proxy status`,
306
+ * typed by someone expecting a report, started a full proxy and ran the OAuth
307
+ * refresh timer against the shared credential for five days. Anything that is
308
+ * not a flag is an error now; the one obvious guess is an alias for the report.
309
+ */
310
+ export function strayProxyArgs(argv) {
311
+ // The command token is the first `proxy`, wherever it sits: `--no-tui` is a
312
+ // global flag that may precede it (review on dario#1353). A second bare
313
+ // `proxy` is a stray word like any other.
314
+ const command = argv.indexOf('proxy');
315
+ return argv.filter((a, i) => i !== command && !a.startsWith('-'));
316
+ }
302
317
  async function proxy() {
318
+ const stray = strayProxyArgs(args);
319
+ if (stray.length === 1 && stray[0] === 'status')
320
+ return status();
321
+ if (stray.length > 0) {
322
+ console.error(`[dario] Unknown proxy argument "${stray[0]}". \`dario proxy\` takes flags only (--port=, --host=, ...); nothing was started. For a report run \`dario status\`.`);
323
+ process.exit(1);
324
+ }
303
325
  // v4: load ~/.dario/config.json once at startup so file-stored values
304
326
  // serve as defaults below where no CLI flag / env var supplies one.
305
327
  // Precedence per M1: defaults < file < env < CLI. Missing-file is
@@ -372,6 +394,10 @@ async function proxy() {
372
394
  // proxies). Stops dario rotating a shared refresh token out from under an
373
395
  // interactive Claude Code on the same machine.
374
396
  const noClaudeAuth = args.includes('--no-claude-auth');
397
+ // Read-only token for /analytics*, /metrics and the /analytics/ui page:
398
+ // a scraper or a browser gets the numbers, never a request slot.
399
+ const analyticsTokenArg = args.find(a => a.startsWith('--analytics-token='));
400
+ const analyticsToken = analyticsTokenArg ? analyticsTokenArg.slice('--analytics-token='.length) : undefined;
375
401
  const modelArg = args.find(a => a.startsWith('--model='));
376
402
  const model = modelArg ? modelArg.split('=')[1] : undefined;
377
403
  // --fast-model=MODEL: route Haiku-tier (CC sub-agent) requests to this
@@ -678,7 +704,7 @@ async function proxy() {
678
704
  console.error(`[dario] Override (not recommended): pass --unsafe-no-auth if you have out-of-band network controls and accept the risk.`);
679
705
  process.exit(1);
680
706
  }
681
- await startProxy({ port, host, verbose, verboseBodies, model, fastModel, noClaudeAuth, 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, poolStrategy, poolHeadroomFloor, poolSharedState, poolSharedStateIntervalMs, effort, maxTokens, poolFallbackModel, modelAliases, logFile, passthroughBetas, skipFields, systemPrompt, overageGuardEnabled, overageGuardBehavior, overageGuardCooldownMs, overageGuardNotifyOs, honorClientThinking, preserveOutputFormat, midstreamContinue, ledger, keys, keysPath });
707
+ 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, poolStrategy, poolHeadroomFloor, poolSharedState, poolSharedStateIntervalMs, effort, maxTokens, poolFallbackModel, modelAliases, logFile, passthroughBetas, skipFields, systemPrompt, overageGuardEnabled, overageGuardBehavior, overageGuardCooldownMs, overageGuardNotifyOs, honorClientThinking, preserveOutputFormat, midstreamContinue, ledger, keys, keysPath });
682
708
  }
683
709
  /**
684
710
  * `dario keys` — named keys for a shared dario (v6.8, dario#1318). One
@@ -1716,7 +1742,9 @@ async function help() {
1716
1742
  existing credentials and runs a fresh OAuth
1717
1743
  flow — for when the refresh token is dead and
1718
1744
  /health still reports access-token countdown.
1719
- dario proxy [options] Start the API proxy server
1745
+ dario proxy [options] Start the API proxy server. Flags only: a bare
1746
+ word after "proxy" is an error, and "dario proxy
1747
+ status" prints the report instead of starting.
1720
1748
  dario status Check authentication status
1721
1749
  dario refresh Force token refresh
1722
1750
  dario resume Clear the overage-guard halt on a running proxy.
@@ -1851,6 +1879,9 @@ async function help() {
1851
1879
  down). --card[=file.svg] writes a share
1852
1880
  card of that number (default
1853
1881
  dario-api-equivalent.svg). (v6.6)
1882
+ --donut[=file.svg] writes it as three
1883
+ rings: by model, by key, subscription
1884
+ vs metered (default dario-spend-donuts.svg).
1854
1885
  --by-key splits the lifetime number per
1855
1886
  consumer: named key, x-dario-consumer
1856
1887
  header, or hashed user id. (v6.8)
@@ -1876,6 +1907,11 @@ async function help() {
1876
1907
  instead of --model, so Claude Code's cheap
1877
1908
  sub-agents aren't upgraded to the forced model.
1878
1909
  Same MODEL forms as --model. No effect unless set.
1910
+ --analytics-token=TOKEN Read-only credential for /analytics*, /metrics and
1911
+ the /analytics/ui page (env DARIO_ANALYTICS_TOKEN).
1912
+ Refused on /v1/*, /accounts, /status, /admin/*:
1913
+ a scraper or a browser gets the numbers, never
1914
+ a request slot. Gates nothing without DARIO_API_KEY.
1879
1915
  --no-claude-auth Don't load or refresh the Claude OAuth token —
1880
1916
  for OpenAI-only proxies (e.g. --model=openai:...).
1881
1917
  Prevents dario rotating a shared refresh token out
@@ -2672,6 +2708,18 @@ async function usage() {
2672
2708
  if (!asJson)
2673
2709
  console.log(` Wrote ${cardPath} — ${formatUsd(lifetime.apiEquivalentCost)} API-equivalent since ${lifetime.since.slice(0, 10)}.`);
2674
2710
  }
2711
+ // --donut / --donut=<file>: the three spend rings (by model, by key, by billing).
2712
+ const donutArg = args.find(a => a === '--donut' || a.startsWith('--donut='));
2713
+ const donutPath = donutArg ? (donutArg.includes('=') ? donutArg.slice('--donut='.length) : 'dario-spend-donuts.svg') : null;
2714
+ if (donutPath) {
2715
+ if (!lifetime) {
2716
+ console.error(` No lifetime numbers to draw${lifetimeNote ? ` (${lifetimeNote})` : ''}.`);
2717
+ process.exit(1);
2718
+ }
2719
+ await writeFile(donutPath, renderSpendDonuts(lifetime), 'utf8');
2720
+ if (!asJson)
2721
+ console.log(` Wrote ${donutPath} — spend by model, by key and by billing since ${lifetime.since.slice(0, 10)}.`);
2722
+ }
2675
2723
  if (asJson) {
2676
2724
  if (payload) {
2677
2725
  process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
@@ -0,0 +1,46 @@
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
+ import type { LedgerSummary } from './ledger.js';
14
+ import type { AnalyticsSummary } from './analytics.js';
15
+ import type { QueueSnapshot } from './request-queue.js';
16
+ /** What /analytics serves: the summary plus the queue snapshot riding along. */
17
+ export type AnalyticsView = AnalyticsSummary & {
18
+ queue?: QueueSnapshot;
19
+ };
20
+ export interface DonutSlice {
21
+ label: string;
22
+ value: number;
23
+ share: number;
24
+ }
25
+ export declare const escapeHtml: (s: string) => string;
26
+ /**
27
+ * Top `max` entries by value plus one "other" bucket; zero and negative
28
+ * values are dropped. Shares sum to 1 (or the array is empty).
29
+ */
30
+ export declare function donutSlices(entries: Record<string, number>, max?: number): DonutSlice[];
31
+ /** One ring of arcs. A single slice is drawn as a full circle (an arc from a point to itself is empty). */
32
+ export declare function donutPaths(slices: readonly DonutSlice[], cx: number, cy: number, r: number, width: number): string;
33
+ /** The three-ring SVG. 640×320, the share card's frame. */
34
+ export declare function renderSpendDonuts(s: LedgerSummary): string;
35
+ /**
36
+ * The server-rendered fragment behind `/analytics/ui`: headline, the three
37
+ * 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.
39
+ */
40
+ export declare function renderAnalyticsView(summary: AnalyticsView, lifetime: LedgerSummary | null, version: string): string;
41
+ /**
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.
45
+ */
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";
package/dist/donuts.js ADDED
@@ -0,0 +1,176 @@
1
+ import { formatUsd } from './ledger.js';
2
+ const PALETTE = ['#7c3aed', '#db2777', '#2563eb', '#059669', '#d97706', '#0891b2'];
3
+ const OTHER = '#6b7280';
4
+ export const escapeHtml = (s) => s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
5
+ /**
6
+ * Top `max` entries by value plus one "other" bucket; zero and negative
7
+ * values are dropped. Shares sum to 1 (or the array is empty).
8
+ */
9
+ export function donutSlices(entries, max = 5) {
10
+ const rows = Object.entries(entries).filter(([, v]) => Number.isFinite(v) && v > 0).sort((a, b) => b[1] - a[1]);
11
+ const total = rows.reduce((n, [, v]) => n + v, 0);
12
+ if (total <= 0)
13
+ return [];
14
+ const head = rows.slice(0, max);
15
+ const rest = rows.slice(max).reduce((n, [, v]) => n + v, 0);
16
+ const slices = head.map(([label, value]) => ({ label, value, share: value / total }));
17
+ if (rest > 0)
18
+ slices.push({ label: 'other', value: rest, share: rest / total });
19
+ return slices;
20
+ }
21
+ const polar = (cx, cy, r, angle) => [cx + r * Math.cos(angle), cy + r * Math.sin(angle)];
22
+ /** One ring of arcs. A single slice is drawn as a full circle (an arc from a point to itself is empty). */
23
+ export function donutPaths(slices, cx, cy, r, width) {
24
+ if (slices.length === 0)
25
+ return `<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="#1f2937" stroke-width="${width}"/>`;
26
+ if (slices.length === 1)
27
+ return `<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="${PALETTE[0]}" stroke-width="${width}"/>`;
28
+ let start = -Math.PI / 2;
29
+ const out = [];
30
+ slices.forEach((s, i) => {
31
+ const sweep = s.share * 2 * Math.PI;
32
+ const end = start + sweep;
33
+ const [x1, y1] = polar(cx, cy, r, start);
34
+ const [x2, y2] = polar(cx, cy, r, end);
35
+ const large = sweep > Math.PI ? 1 : 0;
36
+ const color = s.label === 'other' ? OTHER : PALETTE[i % PALETTE.length];
37
+ out.push(`<path d="M ${x1.toFixed(2)} ${y1.toFixed(2)} A ${r} ${r} 0 ${large} 1 ${x2.toFixed(2)} ${y2.toFixed(2)}" fill="none" stroke="${color}" stroke-width="${width}"/>`);
38
+ start = end;
39
+ });
40
+ return out.join('\n ');
41
+ }
42
+ const pct = (share) => `${Math.round(share * 100)}%`;
43
+ function ring(title, slices, cx, empty) {
44
+ const cy = 150;
45
+ const r = 62;
46
+ const width = 22;
47
+ const legend = slices.slice(0, 6).map((s, i) => {
48
+ const color = s.label === 'other' ? OTHER : PALETTE[i % PALETTE.length];
49
+ const y = 236 + i * 15;
50
+ const label = s.label.length > 18 ? s.label.slice(0, 17) + '…' : s.label;
51
+ return `<rect x="${cx - 95}" y="${y - 9}" width="9" height="9" rx="2" fill="${color}"/>` +
52
+ `<text x="${cx - 80}" y="${y}" font-size="11" fill="#d1d5db">${escapeHtml(label)}</text>` +
53
+ `<text x="${cx + 95}" y="${y}" font-size="11" fill="#9ca3af" text-anchor="end">${escapeHtml(formatUsd(s.value))} · ${pct(s.share)}</text>`;
54
+ }).join('\n ');
55
+ const centre = slices.length === 0
56
+ ? `<text x="${cx}" y="${cy + 4}" font-size="11" fill="#6b7280" text-anchor="middle">${escapeHtml(empty)}</text>`
57
+ : `<text x="${cx}" y="${cy + 5}" font-size="13" font-weight="700" fill="#ffffff" text-anchor="middle">${escapeHtml(formatUsd(slices.reduce((n, s) => n + s.value, 0)))}</text>`;
58
+ return `<text x="${cx}" y="58" font-size="12" fill="#9ca3af" text-anchor="middle" letter-spacing="1.5">${escapeHtml(title.toUpperCase())}</text>
59
+ ${donutPaths(slices, cx, cy, r, width)}
60
+ ${centre}
61
+ ${legend}`;
62
+ }
63
+ /** The three-ring SVG. 640×320, the share card's frame. */
64
+ export function renderSpendDonuts(s) {
65
+ const byModel = donutSlices(Object.fromEntries(Object.entries(s.perModel).map(([m, v]) => [m, v.apiEquivalentCost + v.meteredCost])));
66
+ const byConsumer = donutSlices(Object.fromEntries(Object.entries(s.perConsumer).map(([c, v]) => [c, v.apiEquivalentCost + v.meteredCost])));
67
+ // Short on purpose: the legend column is 18 characters wide.
68
+ const byBilling = donutSlices({ 'subscription': s.apiEquivalentCost, 'metered': s.meteredCost });
69
+ const total = formatUsd(s.apiEquivalentCost + s.meteredCost);
70
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="640" height="320" viewBox="0 0 640 320" role="img" aria-label="${escapeHtml(total)} of spend through dario, by model, by key and by billing">
71
+ <defs>
72
+ <linearGradient id="accent" x1="0" y1="0" x2="1" y2="0">
73
+ <stop offset="0" stop-color="#7c3aed"/>
74
+ <stop offset="1" stop-color="#db2777"/>
75
+ </linearGradient>
76
+ <clipPath id="card"><rect width="640" height="320" rx="20"/></clipPath>
77
+ </defs>
78
+ <rect width="640" height="320" rx="20" fill="#0a0a0f"/>
79
+ <rect x="0" y="0" width="640" height="6" fill="url(#accent)" clip-path="url(#card)"/>
80
+ <g font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace" fill="#e5e7eb">
81
+ <text x="40" y="34" font-size="12" fill="#9ca3af" letter-spacing="2">SPEND THROUGH DARIO · ${escapeHtml(total)} · since ${escapeHtml(s.since.slice(0, 10))}</text>
82
+ ${ring('by model', byModel, 112, 'no traffic yet')}
83
+ ${ring('by key', byConsumer, 320, 'no named keys')}
84
+ ${ring('by billing', byBilling, 528, 'no traffic yet')}
85
+ <text x="600" y="308" font-size="11" fill="#6b7280" text-anchor="end">dario</text>
86
+ </g>
87
+ </svg>
88
+ `;
89
+ }
90
+ /**
91
+ * The server-rendered fragment behind `/analytics/ui`: headline, the three
92
+ * 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.
94
+ */
95
+ export function renderAnalyticsView(summary, lifetime, version) {
96
+ const w = summary.window;
97
+ const stat = (label, value) => `<div class="stat"><div class="v">${escapeHtml(value)}</div><div class="l">${escapeHtml(label)}</div></div>`;
98
+ const headline = lifetime
99
+ ? `<div class="headline">${escapeHtml(formatUsd(lifetime.apiEquivalentCost))}<span> API-equivalent · covered by subscriptions · since ${escapeHtml(lifetime.since.slice(0, 10))} · ${lifetime.requests.toLocaleString('en-US')} requests</span></div>`
100
+ : '<div class="headline muted">ledger disabled <span>start without --no-ledger to keep lifetime spend</span></div>';
101
+ const rings = lifetime ? renderSpendDonuts(lifetime) : '';
102
+ const models = lifetime
103
+ ? Object.entries(lifetime.perModel).sort((a, b) => (b[1].apiEquivalentCost + b[1].meteredCost) - (a[1].apiEquivalentCost + a[1].meteredCost))
104
+ : [];
105
+ const table = models.length
106
+ ? `<table><thead><tr><th>model</th><th>requests</th><th>in</th><th>out</th><th>cache read</th><th>api-equivalent</th><th>metered</th></tr></thead><tbody>${models.map(([m, v]) => `<tr><td>${escapeHtml(m)}</td><td>${v.requests.toLocaleString('en-US')}</td><td>${v.inputTokens.toLocaleString('en-US')}</td><td>${v.outputTokens.toLocaleString('en-US')}</td><td>${v.cacheReadTokens.toLocaleString('en-US')}</td><td>${escapeHtml(formatUsd(v.apiEquivalentCost))}</td><td>${escapeHtml(formatUsd(v.meteredCost))}</td></tr>`).join('')}</tbody></table>`
107
+ : '';
108
+ const accounts = Object.entries(summary.perAccount).map(([a, s]) => `<tr><td>${escapeHtml(a)}</td><td>${s.requests.toLocaleString('en-US')}</td><td>${Math.round(s.currentUtil5h * 100)}%</td><td>${Math.round(s.currentUtil7d * 100)}%</td><td>${escapeHtml(s.lastClaim)}</td></tr>`).join('');
109
+ return `${headline}
110
+ <div class="stats">
111
+ ${stat(`requests · last ${w.minutes} min`, w.requests.toLocaleString('en-US'))}
112
+ ${stat('avg latency', `${Math.round(w.avgLatencyMs)} ms`)}
113
+ ${stat('error rate', `${(w.errorRate * 100).toFixed(1)}%`)}
114
+ ${stat('cached prompt', `${Math.round(w.cachedPromptPercent)}%`)}
115
+ ${stat('in flight / queued', `${summary.queue?.active ?? 0} / ${summary.queue?.queued ?? 0}`)}
116
+ </div>
117
+ <div class="rings">${rings}</div>
118
+ ${table}
119
+ ${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
+ <div class="foot">dario ${escapeHtml(version)} · rendered ${escapeHtml(new Date().toISOString().slice(0, 19).replace('T', ' '))} UTC</div>`;
121
+ }
122
+ /**
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.
126
+ */
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
+ `;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Prometheus text exposition for `GET /metrics` (dario#1341).
3
+ *
4
+ * Everything here is derived from state dario already keeps — the rolling
5
+ * analytics window, the request queue, and the ledger — rendered in the
6
+ * text format every scraper reads. No new collection, no new state: the
7
+ * endpoint is a view, and a scrape costs the same as `GET /analytics`.
8
+ *
9
+ * Pure over its inputs so it is testable without a proxy. Label values are
10
+ * escaped per the exposition rules (backslash, double quote, newline).
11
+ */
12
+ import type { AnalyticsSummary, RequestRecord } from './analytics.js';
13
+ import type { QueueSnapshot } from './request-queue.js';
14
+ import type { LedgerSummary } from './ledger.js';
15
+ export interface MetricsInput {
16
+ summary: AnalyticsSummary;
17
+ queue: QueueSnapshot;
18
+ lifetime: LedgerSummary | null;
19
+ /** Most recent records, newest last — the latency quantiles come from these. */
20
+ recent: readonly RequestRecord[];
21
+ version: string;
22
+ }
23
+ /** Nearest-rank quantile over a sorted ascending array. */
24
+ export declare function quantile(sorted: readonly number[], q: number): number;
25
+ export declare function renderPrometheus(input: MetricsInput): string;
@@ -0,0 +1,116 @@
1
+ import { billingBucketFromClaim } from './analytics.js';
2
+ const escapeLabel = (v) => v.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
3
+ const labels = (kv) => {
4
+ const parts = Object.entries(kv).map(([k, v]) => `${k}="${escapeLabel(v)}"`);
5
+ return parts.length ? `{${parts.join(',')}}` : '';
6
+ };
7
+ const num = (n) => {
8
+ if (!Number.isFinite(n))
9
+ return n === Infinity ? '+Inf' : n === -Infinity ? '-Inf' : 'NaN';
10
+ return String(n);
11
+ };
12
+ /** Nearest-rank quantile over a sorted ascending array. */
13
+ export function quantile(sorted, q) {
14
+ if (sorted.length === 0)
15
+ return NaN;
16
+ const idx = Math.min(sorted.length - 1, Math.max(0, Math.ceil(q * sorted.length) - 1));
17
+ return sorted[idx];
18
+ }
19
+ export function renderPrometheus(input) {
20
+ const { summary, queue, lifetime, recent, version } = input;
21
+ const out = [];
22
+ // `_total` names are counters by Prometheus convention; everything else here
23
+ // is a gauge. The latency summary is emitted by hand below.
24
+ const metric = (name, help, rows, type = name.endsWith('_total') ? 'counter' : 'gauge') => {
25
+ if (rows.length === 0)
26
+ return;
27
+ out.push(`# HELP ${name} ${help}`);
28
+ out.push(`# TYPE ${name} ${type}`);
29
+ for (const [kv, v] of rows)
30
+ out.push(`${name}${labels(kv)} ${num(v)}`);
31
+ };
32
+ metric('dario_info', 'dario version, always 1.', [[{ version }, 1]]);
33
+ // ---- all-time (since proxy start) -------------------------------------
34
+ const all = summary.allTime;
35
+ metric('dario_requests_total', 'Requests served since the proxy started.', [[{}, all.requests]]);
36
+ metric('dario_tokens_total', 'Tokens since the proxy started, by kind.', [
37
+ [{ kind: 'input' }, all.totalInputTokens],
38
+ [{ kind: 'output' }, all.totalOutputTokens],
39
+ [{ kind: 'cache_read' }, all.totalCacheReadTokens],
40
+ [{ kind: 'cache_create' }, all.totalCacheCreateTokens],
41
+ [{ kind: 'thinking' }, all.totalThinkingTokens],
42
+ ]);
43
+ metric('dario_estimated_cost_usd_total', 'API-equivalent cost of all traffic since the proxy started, USD at list price.', [[{}, all.estimatedCost]]);
44
+ metric('dario_error_rate', 'Share of requests that failed, 0..1, all-time.', [[{}, all.errorRate]]);
45
+ // ---- rolling window ----------------------------------------------------
46
+ const w = summary.window;
47
+ const win = { window_minutes: String(w.minutes) };
48
+ metric('dario_window_requests', 'Requests in the rolling window.', [[win, w.requests]]);
49
+ metric('dario_window_avg_latency_ms', 'Mean request latency in the rolling window, ms.', [[win, w.avgLatencyMs]]);
50
+ metric('dario_window_error_rate', 'Share of requests that failed in the rolling window, 0..1.', [[win, w.errorRate]]);
51
+ metric('dario_window_cached_prompt_percent', 'Share of prompt tokens served from cache in the rolling window, 0..100.', [[win, w.cachedPromptPercent]]);
52
+ metric('dario_window_estimated_cost_usd', 'API-equivalent cost of the rolling window, USD.', [[win, w.estimatedCost]]);
53
+ // ---- billing buckets (window) ------------------------------------------
54
+ const buckets = new Map();
55
+ for (const [claim, n] of Object.entries(w.claimBreakdown ?? {})) {
56
+ const b = billingBucketFromClaim(claim);
57
+ buckets.set(b, (buckets.get(b) ?? 0) + n);
58
+ }
59
+ metric('dario_window_billing_requests', 'Requests in the rolling window by billing bucket.', [...buckets.entries()].map(([bucket, n]) => [{ bucket }, n]));
60
+ // ---- per account -------------------------------------------------------
61
+ const accounts = Object.entries(summary.perAccount);
62
+ metric('dario_account_requests_total', 'Requests per pool seat since the proxy started.', accounts.map(([account, a]) => [{ account }, a.requests]));
63
+ metric('dario_account_utilization', 'Last reported rate-limit utilization per seat, 0..1.', accounts.flatMap(([account, a]) => [
64
+ [{ account, window: '5h' }, a.currentUtil5h],
65
+ [{ account, window: '7d' }, a.currentUtil7d],
66
+ ]));
67
+ metric('dario_account_estimated_cost_usd', 'API-equivalent cost per seat since the proxy started, USD.', accounts.map(([account, a]) => [{ account }, a.estimatedCost]));
68
+ // ---- per model ---------------------------------------------------------
69
+ const models = Object.entries(summary.perModel);
70
+ metric('dario_model_requests_total', 'Requests per model since the proxy started.', models.map(([model, m]) => [{ model }, m.requests]));
71
+ metric('dario_model_estimated_cost_usd', 'API-equivalent cost per model since the proxy started, USD.', models.map(([model, m]) => [{ model }, m.estimatedCost]));
72
+ // ---- per consumer (named key / header) ---------------------------------
73
+ const consumers = Object.entries(summary.perConsumer);
74
+ metric('dario_consumer_requests_total', 'Requests per consumer (named key or x-dario-consumer) since the proxy started.', consumers.map(([consumer, c]) => [{ consumer }, c.requests]));
75
+ metric('dario_consumer_estimated_cost_usd', 'API-equivalent cost per consumer since the proxy started, USD.', consumers.map(([consumer, c]) => [{ consumer }, c.estimatedCost]));
76
+ // ---- queue ---------------------------------------------------------------
77
+ metric('dario_queue_active', 'Requests in flight upstream.', [[{}, queue.active]]);
78
+ metric('dario_queue_queued', 'Requests waiting for a slot.', [[{}, queue.queued]]);
79
+ metric('dario_queue_max_concurrent', 'Configured in-flight ceiling.', [[{}, queue.maxConcurrent]]);
80
+ metric('dario_queue_max_queued', 'Configured queue ceiling.', [[{}, queue.maxQueued]]);
81
+ metric('dario_queue_stalled', '1 when slots are held but nothing is turning over, else 0.', [[{}, queue.stalledSince ? 1 : 0]]);
82
+ metric('dario_queue_max_wait_ms', 'Longest a request has waited for a slot since start, ms.', [[{}, queue.maxWaitMs]]);
83
+ metric('dario_queue_consumers_active', 'Distinct consumers with a request in flight.', [[{}, queue.consumersActive]]);
84
+ // ---- latency quantiles over the recent records -------------------------
85
+ const lat = recent.map(r => r.latencyMs).filter(n => Number.isFinite(n)).sort((a, b) => a - b);
86
+ if (lat.length > 0) {
87
+ out.push('# HELP dario_request_latency_ms Request latency over the most recent records, ms (nearest-rank quantiles).');
88
+ out.push('# TYPE dario_request_latency_ms summary');
89
+ for (const q of [0.5, 0.9, 0.99])
90
+ out.push(`dario_request_latency_ms{quantile="${q}"} ${num(quantile(lat, q))}`);
91
+ out.push(`dario_request_latency_ms_sum ${num(lat.reduce((a, b) => a + b, 0))}`);
92
+ out.push(`dario_request_latency_ms_count ${lat.length}`);
93
+ }
94
+ // ---- predictions -------------------------------------------------------
95
+ const p = summary.predictions;
96
+ if (p.estimatedExhaustionMinutes !== null) {
97
+ metric('dario_predicted_exhaustion_minutes', 'Minutes until the current seat window is predicted to exhaust at the present burn rate.', [[{}, p.estimatedExhaustionMinutes]]);
98
+ }
99
+ metric('dario_burn_tokens_per_minute', 'Token burn rate over the rolling window.', [[{}, p.tokenBurnRate]]);
100
+ metric('dario_burn_cost_usd_per_minute', 'API-equivalent cost burn rate over the rolling window, USD/min.', [[{}, p.costBurnRate]]);
101
+ // ---- ledger (survives restarts) ----------------------------------------
102
+ if (lifetime) {
103
+ metric('dario_ledger_requests_total', 'Requests in the ledger (covered + metered, 2xx), lifetime.', [[{}, lifetime.requests]]);
104
+ metric('dario_ledger_api_equivalent_usd', 'What subscription-covered traffic would have cost on the metered API, lifetime, USD.', [[{}, lifetime.apiEquivalentCost]]);
105
+ metric('dario_ledger_metered_usd', 'What metered traffic actually cost at list price, lifetime, USD.', [[{}, lifetime.meteredCost]]);
106
+ metric('dario_ledger_recent_api_equivalent_usd', 'API-equivalent spend over trailing UTC-day windows, USD.', [
107
+ [{ window: 'today' }, lifetime.recent.today],
108
+ [{ window: '7d' }, lifetime.recent.last7d],
109
+ [{ window: '30d' }, lifetime.recent.last30d],
110
+ ]);
111
+ metric('dario_ledger_model_api_equivalent_usd', 'Lifetime API-equivalent spend per model, USD.', Object.entries(lifetime.perModel).map(([model, m]) => [{ model, provider: m.provider }, m.apiEquivalentCost]));
112
+ metric('dario_ledger_model_requests_total', 'Lifetime requests per model in the ledger.', Object.entries(lifetime.perModel).map(([model, m]) => [{ model, provider: m.provider }, m.requests]));
113
+ metric('dario_ledger_consumer_api_equivalent_usd', 'Lifetime API-equivalent spend per consumer, USD.', Object.entries(lifetime.perConsumer).map(([consumer, c]) => [{ consumer }, c.apiEquivalentCost]));
114
+ }
115
+ return out.join('\n') + '\n';
116
+ }
package/dist/proxy.d.ts CHANGED
@@ -280,6 +280,7 @@ interface ProxyOptions {
280
280
  model?: string;
281
281
  fastModel?: string;
282
282
  noClaudeAuth?: boolean;
283
+ analyticsToken?: string;
283
284
  /**
284
285
  * Override the fetch used for UPSTREAM calls (api.anthropic.com). Test seam:
285
286
  * it makes the request path hermetic, which the 400-recovery chain needs —
@@ -628,6 +629,14 @@ export declare function sanitizeError(err: unknown): string;
628
629
  * API-key auth via DARIO_API_KEY (x-api-key or Authorization: Bearer).
629
630
  * If unset, requests are allowed (loopback-only default). Exported for tests.
630
631
  */
632
+ /**
633
+ * The read-only analytics surfaces — the only paths the analytics token
634
+ * (`--analytics-token` / `DARIO_ANALYTICS_TOKEN`) is accepted on. Exact
635
+ * matches on purpose: a prefix test would let a future `/analytics/reset`
636
+ * inherit read-only auth by accident.
637
+ */
638
+ export declare const ANALYTICS_READ_PATHS: readonly string[];
639
+ export declare function isAnalyticsReadPath(urlPath: string): boolean;
631
640
  export declare function authenticateRequest(headers: IncomingMessage['headers'], apiKeyBuf: Buffer | null): boolean;
632
641
  /**
633
642
  * Describe WHY authenticateRequest rejected, for operator-facing logs only.
package/dist/proxy.js CHANGED
@@ -17,6 +17,8 @@ import { backfillIdentity } from './accounts.js';
17
17
  import { PoolSync, DEFAULT_POOL_SYNC_INTERVAL_MS } from './pool-sync.js';
18
18
  import { Analytics, billingBucketFromClaim, formatUsageLogLine, SUBSCRIPTION_CLAIMS, consumerFromHeader, consumerFromBody, CONSUMER_HEADER, CODEX_CLAIM } from './analytics.js';
19
19
  import { Ledger, resolveLedgerPath, ledgerDisabledByEnv } from './ledger.js';
20
+ import { renderPrometheus } from './metrics.js';
21
+ import { renderSpendDonuts, renderAnalyticsView, ANALYTICS_UI_SHELL } from './donuts.js';
20
22
  import { KeyStore, keyAllowsModel, resolveKeysPath, looksLikeNamedKey } from './keys.js';
21
23
  import { OverageGuard, buildHaltErrorBody } from './overage-guard.js';
22
24
  import { notify as osNotify } from './notify.js';
@@ -1006,6 +1008,18 @@ export function sanitizeError(err) {
1006
1008
  * API-key auth via DARIO_API_KEY (x-api-key or Authorization: Bearer).
1007
1009
  * If unset, requests are allowed (loopback-only default). Exported for tests.
1008
1010
  */
1011
+ /**
1012
+ * The read-only analytics surfaces — the only paths the analytics token
1013
+ * (`--analytics-token` / `DARIO_ANALYTICS_TOKEN`) is accepted on. Exact
1014
+ * matches on purpose: a prefix test would let a future `/analytics/reset`
1015
+ * inherit read-only auth by accident.
1016
+ */
1017
+ export const ANALYTICS_READ_PATHS = [
1018
+ '/analytics', '/analytics/ledger', '/analytics/stream', '/analytics/view', '/analytics/donuts.svg', '/metrics',
1019
+ ];
1020
+ export function isAnalyticsReadPath(urlPath) {
1021
+ return ANALYTICS_READ_PATHS.includes(urlPath);
1022
+ }
1009
1023
  export function authenticateRequest(headers, apiKeyBuf) {
1010
1024
  if (!apiKeyBuf)
1011
1025
  return true;
@@ -1970,6 +1984,17 @@ export async function startProxy(opts = {}) {
1970
1984
  // Optional proxy authentication — pre-encode key buffer for performance
1971
1985
  const apiKey = process.env.DARIO_API_KEY;
1972
1986
  const apiKeyBuf = apiKey ? Buffer.from(apiKey) : null;
1987
+ // Read-only analytics credential (dario#1341). Accepted ONLY on the
1988
+ // read-only surfaces listed in isAnalyticsReadPath — never on /v1/*, never
1989
+ // on /admin/*, never on /accounts — so a Grafana box or a browser tab can
1990
+ // hold it without holding request rights. On an unkeyed proxy it changes
1991
+ // nothing (everything is already open on loopback). When DARIO_API_KEY is
1992
+ // set, the root key keeps working on these paths too.
1993
+ const analyticsToken = opts.analyticsToken || process.env.DARIO_ANALYTICS_TOKEN || '';
1994
+ const analyticsTokenBuf = analyticsToken ? Buffer.from(analyticsToken) : null;
1995
+ if (analyticsTokenBuf && !apiKeyBuf) {
1996
+ console.warn('[dario] --analytics-token set but DARIO_API_KEY is not: /analytics and /metrics are already open on this proxy, the token gates nothing.');
1997
+ }
1973
1998
  // Named keys (dario#1318): one credential per developer, hashes on disk,
1974
1999
  // re-read when the file moves. Attribution and per-key limits ride on the
1975
2000
  // match; the root DARIO_API_KEY keeps working beside them.
@@ -2515,7 +2540,19 @@ export async function startProxy(opts = {}) {
2515
2540
  if (handled)
2516
2541
  return;
2517
2542
  }
2518
- const requestAuth = resolveRequestAuth(req);
2543
+ // The dashboard shell carries no data, so it needs no credential: it is
2544
+ // the page that ASKS for the token and then fetches /analytics/view.
2545
+ if (urlPath === '/analytics/ui' && req.method === 'GET') {
2546
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', ...SECURITY_HEADERS });
2547
+ res.end(ANALYTICS_UI_SHELL);
2548
+ return;
2549
+ }
2550
+ // Read-only analytics token: accepted on the read-only surfaces only.
2551
+ // Anything else falls through to the normal request auth below.
2552
+ const analyticsRead = isAnalyticsReadPath(urlPath) && req.method === 'GET';
2553
+ const requestAuth = (analyticsRead && analyticsTokenBuf && authenticateRequest(req.headers, analyticsTokenBuf))
2554
+ ? { ok: true, key: null }
2555
+ : resolveRequestAuth(req);
2519
2556
  if (!requestAuth.ok) {
2520
2557
  if (verbose) {
2521
2558
  // Silent auth rejects are hard to diagnose when a client's config
@@ -2673,6 +2710,40 @@ export async function startProxy(opts = {}) {
2673
2710
  res.end(JSON.stringify({ ...analytics.summary(), queue: queue.snapshot(), lifetime: ledger ? ledger.summary() : null }));
2674
2711
  return;
2675
2712
  }
2713
+ // Prometheus text exposition of the same state (dario#1341). A view, not
2714
+ // new collection: a scrape costs what GET /analytics costs. Same gate.
2715
+ if (urlPath === '/metrics' && req.method === 'GET') {
2716
+ const body = renderPrometheus({
2717
+ summary: analytics.summary(),
2718
+ queue: queue.snapshot(),
2719
+ lifetime: ledger ? ledger.summary() : null,
2720
+ recent: analytics.recent(1000),
2721
+ version: darioVersion(),
2722
+ });
2723
+ res.writeHead(200, { 'Content-Type': 'text/plain; version=0.0.4; charset=utf-8', ...SECURITY_HEADERS });
2724
+ res.end(body);
2725
+ return;
2726
+ }
2727
+ // Spend donuts — by model, by key, by billing — from the ledger. The same
2728
+ // SVG `dario usage --donut` writes to disk.
2729
+ if (urlPath === '/analytics/donuts.svg' && req.method === 'GET') {
2730
+ if (!ledger) {
2731
+ res.writeHead(404, JSON_HEADERS);
2732
+ res.end(JSON.stringify({ error: 'ledger disabled', hint: 'start without --no-ledger / DARIO_LEDGER=0' }));
2733
+ return;
2734
+ }
2735
+ res.writeHead(200, { 'Content-Type': 'image/svg+xml; charset=utf-8', ...SECURITY_HEADERS });
2736
+ res.end(renderSpendDonuts(ledger.summary()));
2737
+ return;
2738
+ }
2739
+ // The server-rendered body behind /analytics/ui. Gated like /analytics;
2740
+ // the shell fetches it with whatever the viewer typed.
2741
+ if (urlPath === '/analytics/view' && req.method === 'GET') {
2742
+ const s = analytics.summary();
2743
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', ...SECURITY_HEADERS });
2744
+ res.end(renderAnalyticsView({ ...s, queue: queue.snapshot() }, ledger ? ledger.summary() : null, darioVersion()));
2745
+ return;
2746
+ }
2676
2747
  // The ledger's per-day table, for anyone charting it. `lifetime` on
2677
2748
  // /analytics is the summary; this is the data behind it.
2678
2749
  if (urlPath === '/analytics/ledger' && req.method === 'GET') {
@@ -0,0 +1,60 @@
1
+ # Analytics, `/metrics`, and the dashboard
2
+
3
+ dario keeps two kinds of numbers. The **rolling window** (`src/analytics.ts`) lives in memory and forgets on restart: per-request records with tokens, latency, seat, model, consumer and billing claim, summarised over the last 60 minutes and since start. The **ledger** (`src/ledger.ts`, `~/.dario/ledger.json`) survives restarts: one row per day, per model, per billing bucket, priced at read time from the published rate cards.
4
+
5
+ Every surface below is a view over those two. Nothing here collects anything new.
6
+
7
+ ## Surfaces
8
+
9
+ | path | what | format |
10
+ |---|---|---|
11
+ | `GET /analytics` | window summary + `queue` snapshot + `lifetime` ledger summary | JSON |
12
+ | `GET /analytics/ledger` | the ledger's per-day table | JSON |
13
+ | `GET /analytics/stream` | live tail of request records (drives the TUI) | SSE |
14
+ | `GET /metrics` | the same state as Prometheus text exposition | text/plain 0.0.4 |
15
+ | `GET /analytics/donuts.svg` | spend by model / by key / by billing | SVG |
16
+ | `GET /analytics/view` | server-rendered dashboard body | HTML fragment |
17
+ | `GET /analytics/ui` | the dashboard shell (no data; asks for the token, loads `/view` every 60 s) | HTML page |
18
+
19
+ CLI: `dario usage` prints the ledger; `--json` dumps `/analytics`; `--card[=file]` writes the share card; `--donut[=file]` writes the three rings.
20
+
21
+ ## Who can read them
22
+
23
+ By default the proxy binds loopback and these paths need no credential. With `DARIO_API_KEY` set, they need the key like everything else.
24
+
25
+ `--analytics-token=<secret>` (or `DARIO_ANALYTICS_TOKEN`) adds a **read-only** credential accepted on exactly the paths in the table above, on `GET` only. It is refused on `/v1/*`, `/accounts`, `/status`, `/admin/*`, and any non-GET. That is the point: a Grafana box or a browser tab can hold the numbers without holding request rights. The root key keeps working on the analytics paths too. On an unkeyed proxy the token gates nothing, and the proxy says so at startup.
26
+
27
+ `/analytics/ui` itself is served without a credential because it contains no data. It stores the token you type in `sessionStorage` and sends it as a bearer on every fetch of `/analytics/view`.
28
+
29
+ ## `/metrics` families
30
+
31
+ Names ending in `_total` are counters; everything else is a gauge. Labels are escaped per the exposition format.
32
+
33
+ | family | labels | source |
34
+ |---|---|---|
35
+ | `dario_info` | `version` | always 1 |
36
+ | `dario_requests_total`, `dario_tokens_total{kind}`, `dario_estimated_cost_usd_total`, `dario_error_rate` | `kind` ∈ input, output, cache_read, cache_create, thinking | since start |
37
+ | `dario_window_requests`, `_avg_latency_ms`, `_error_rate`, `_cached_prompt_percent`, `_estimated_cost_usd` | `window_minutes` | rolling window |
38
+ | `dario_window_billing_requests` | `bucket` ∈ subscription, subscription_fallback, extra_usage, api, unknown | claims folded by `billingBucketFromClaim` |
39
+ | `dario_account_requests_total`, `dario_account_estimated_cost_usd`, `dario_account_utilization{window}` | `account`, `window` ∈ 5h, 7d | last rate-limit headers per seat |
40
+ | `dario_model_requests_total`, `dario_model_estimated_cost_usd` | `model` | since start |
41
+ | `dario_consumer_requests_total`, `dario_consumer_estimated_cost_usd` | `consumer` (named key or `x-dario-consumer`) | since start |
42
+ | `dario_queue_active`, `_queued`, `_max_concurrent`, `_max_queued`, `_stalled`, `_max_wait_ms`, `_consumers_active` | — | request queue |
43
+ | `dario_request_latency_ms{quantile}` + `_sum`, `_count` | `quantile` ∈ 0.5, 0.9, 0.99 | nearest-rank over the most recent 1,000 records |
44
+ | `dario_predicted_exhaustion_minutes` (omitted when unknown), `dario_burn_tokens_per_minute`, `dario_burn_cost_usd_per_minute` | — | window predictions |
45
+ | `dario_ledger_requests_total`, `_api_equivalent_usd`, `_metered_usd`, `_recent_api_equivalent_usd{window}`, `_model_api_equivalent_usd{model,provider}`, `_model_requests_total{model,provider}`, `_consumer_api_equivalent_usd{consumer}` | `window` ∈ today, 7d, 30d | ledger (absent when the ledger is off) |
46
+
47
+ Latency here is end-to-end through dario as the client saw it. Time-to-first-token and the split between dario's own overhead and the provider's time are not recorded per request today; they are the natural next columns on `RequestRecord` if a scrape wants them.
48
+
49
+ A minimal scrape config:
50
+
51
+ ```yaml
52
+ scrape_configs:
53
+ - job_name: dario
54
+ static_configs: [{ targets: ['127.0.0.1:3456'] }]
55
+ authorization: { credentials: '<DARIO_ANALYTICS_TOKEN>' }
56
+ ```
57
+
58
+ ## The rings
59
+
60
+ `dario usage --donut` and `/analytics/donuts.svg` render the ledger's lifetime spend as three rings: **by model**, **by key** (empty until a named key or `x-dario-consumer` has traffic), and **subscription vs metered**. Each ring keeps the top five and folds the rest into *other*. Shares are of API-equivalent plus metered spend, so a model that only ever ran on an API key still shows.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "6.8.11",
3
+ "version": "6.8.12",
4
4
  "description": "Use your Claude and ChatGPT subscriptions in Cursor, Cline, Aider, Claude Code and the Agent SDK — at subscription pricing, not per-token API bills. One local Anthropic + OpenAI-compatible endpoint: either plan answers either wire shape, with automatic failover when one hits its limit.",
5
5
  "type": "module",
6
6
  "bin": {