@askalf/dario 6.8.10 → 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
 
@@ -75,6 +75,7 @@
75
75
  * against it.
76
76
  */
77
77
  import type { IncomingMessage, ServerResponse } from 'node:http';
78
+ import type { CodexSeatState } from './codex-accounts.js';
78
79
  import { type KeyStore } from './keys.js';
79
80
  /** Persisted account metadata surfaced by `GET /admin/accounts`. */
80
81
  export interface AdminAccountRecord {
@@ -150,9 +151,10 @@ export interface AdminAccountLive {
150
151
  }
151
152
  /** An audited admin action — see `AdminDeps.audit`. Never carries secrets. */
152
153
  /** One stored ChatGPT seat as `GET /admin/codex/accounts` reports it. */
153
- export interface AdminCodexAccountRecord {
154
+ export interface AdminCodexAccountRecord extends CodexSeatState {
154
155
  alias: string;
155
156
  expiresAt: number;
157
+ /** The clock's opinion. `status` is the proxy's (dario#1343). */
156
158
  needsRefresh: boolean;
157
159
  }
158
160
  export interface AdminAuditEvent {
package/dist/admin-api.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { maskEmail } from './pool.js';
2
2
  import { timingSafeEqual } from 'node:crypto';
3
3
  import { startAddAccount, completeAddAccount, removeAccount, listAccountAliases, loadAccount, } from './accounts.js';
4
- import { startAddCodexAccount, completeAddCodexAccount, removeCodexAccount, loadAllCodexAccounts, listCodexAccountAliases, codexAccountNeedsRefresh, parseCodexManualPaste, } from './codex-accounts.js';
4
+ import { startAddCodexAccount, completeAddCodexAccount, removeCodexAccount, loadAllCodexAccounts, listCodexAccountAliases, codexAccountNeedsRefresh, codexSeatStatus, parseCodexManualPaste, } from './codex-accounts.js';
5
5
  import { parseManualPaste } from './oauth.js';
6
6
  import { grantAge } from './refresh-grant.js';
7
7
  import { createKey, revokeKey, rotateKey, parseExpiry, publicKey, KEY_NAME_RE } from './keys.js';
@@ -153,7 +153,7 @@ async function doCompleteCodexLogin(alias, rawCode, now, deps, remote) {
153
153
  async function listCodexAccountRecords() {
154
154
  const all = await loadAllCodexAccounts();
155
155
  return all
156
- .map((a) => ({ alias: a.alias, expiresAt: a.expiresAt, needsRefresh: codexAccountNeedsRefresh(a) }))
156
+ .map((a) => ({ alias: a.alias, expiresAt: a.expiresAt, needsRefresh: codexAccountNeedsRefresh(a), ...codexSeatStatus(a.alias) }))
157
157
  .sort((x, y) => x.alias.localeCompare(y.alias));
158
158
  }
159
159
  /** On-disk account inventory — the default `AdminDeps.listAccounts`. */
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
@@ -144,6 +152,28 @@ export interface LivePayload {
144
152
  * them, so a legacy payload can be driven straight through it in a test.
145
153
  */
146
154
  export declare function formatLiveAccountsListing(payload: LivePayload, port: number, now: number): string[];
155
+ /**
156
+ * `dario accounts list --live` — the running proxy's view of the pool
157
+ * (dario#1244): status with its countdown, the reading and its age, 429s
158
+ * answered, the organization, and which seats share a window. The on-disk
159
+ * listing knows none of that. Returns false when no proxy answered, so the
160
+ * caller falls back to the on-disk listing.
161
+ */
162
+ /** One seat as `GET /codex` reports it — the fields the live listing prints. */
163
+ export interface LiveCodexSeat {
164
+ alias: string;
165
+ expiresInMs: number;
166
+ requestCount: number;
167
+ status: 'ok' | 'cooling' | 'refresh-failed';
168
+ cooldownRemainingMs: number;
169
+ lastRefreshError: {
170
+ at: number;
171
+ status: number;
172
+ message: string;
173
+ } | null;
174
+ }
175
+ /** Lines for `dario codex list --live` — pure, so the shape is testable without a proxy. */
176
+ export declare function formatLiveCodexListing(accounts: readonly LiveCodexSeat[], port: number): string[];
147
177
  /**
148
178
  * Decide whether this module is being invoked as the CLI entry point or
149
179
  * imported as a library. Pure, exported for tests; the file-bottom uses
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
@@ -1134,13 +1160,67 @@ export function formatLiveAccountsListing(payload, port, now) {
1134
1160
  lines.push('');
1135
1161
  return lines;
1136
1162
  }
1163
+ /** Lines for `dario codex list --live` — pure, so the shape is testable without a proxy. */
1164
+ export function formatLiveCodexListing(accounts, port) {
1165
+ const out = ['', ` dario — Codex accounts (live, from http://127.0.0.1:${port}/codex)`, ' ───────────────────────────────────────', ''];
1166
+ if (accounts.length === 0) {
1167
+ out.push(' No Codex accounts.', '');
1168
+ return out;
1169
+ }
1170
+ for (const a of accounts) {
1171
+ const mins = Math.floor(Math.max(0, a.expiresInMs) / 60000);
1172
+ const expiry = a.expiresInMs > 0 ? `${mins}m` : 'expired';
1173
+ let state;
1174
+ if (a.status === 'refresh-failed') {
1175
+ const e = a.lastRefreshError;
1176
+ state = `refresh-failed (${e ? `${e.status}: ${e.message}` : 'token endpoint refused'}) — re-add the seat`;
1177
+ }
1178
+ else if (a.status === 'cooling') {
1179
+ state = `cooling ${Math.ceil(a.cooldownRemainingMs / 1000)}s — the backend declined it; selection skips it until then`;
1180
+ }
1181
+ else {
1182
+ state = 'ok';
1183
+ }
1184
+ out.push(` ${a.alias.padEnd(20)} ${state}`);
1185
+ out.push(` ${''.padEnd(20)} token expires in ${expiry}, ${a.requestCount} request${a.requestCount === 1 ? '' : 's'} served`);
1186
+ }
1187
+ out.push('');
1188
+ return out;
1189
+ }
1137
1190
  /**
1138
- * `dario accounts list --live` — the running proxy's view of the pool
1139
- * (dario#1244): status with its countdown, the reading and its age, 429s
1140
- * answered, the organization, and which seats share a window. The on-disk
1141
- * listing knows none of that. Returns false when no proxy answered, so the
1142
- * caller falls back to the on-disk listing.
1191
+ * `dario codex list --live` — the running proxy's view of each seat (dario#1343):
1192
+ * what it will do with it, not what the clock says. The on-disk listing cannot
1193
+ * know that a seat is cooling or that its refresh was refused; only the process
1194
+ * that tried does. Returns false when no proxy answered, so the caller falls
1195
+ * back to the on-disk listing.
1143
1196
  */
1197
+ async function codexListLive() {
1198
+ const { loadConfig } = await import('./config-file.js');
1199
+ const fileCfg = loadConfig().config;
1200
+ const portArg = args.find(a => a.startsWith('--port='));
1201
+ const port = (portArg ? parseInt(portArg.split('=')[1], 10) : undefined)
1202
+ ?? (process.env['DARIO_PORT'] ? parseInt(process.env['DARIO_PORT'], 10) : undefined)
1203
+ ?? fileCfg.port ?? 3456;
1204
+ const headers = {};
1205
+ if (process.env['DARIO_API_KEY'])
1206
+ headers['x-api-key'] = process.env['DARIO_API_KEY'];
1207
+ let payload = null;
1208
+ try {
1209
+ const res = await fetch(`http://127.0.0.1:${port}/codex`, { headers, signal: AbortSignal.timeout(3000) });
1210
+ if (res.ok)
1211
+ payload = await res.json();
1212
+ else
1213
+ console.log(` (proxy on http://127.0.0.1:${port} answered ${res.status} to /codex — showing the on-disk listing)`);
1214
+ }
1215
+ catch (err) {
1216
+ console.log(` (no proxy on http://127.0.0.1:${port}: ${err instanceof Error ? err.message : String(err)} — showing the on-disk listing)`);
1217
+ }
1218
+ if (!payload || !Array.isArray(payload.accounts))
1219
+ return false;
1220
+ for (const line of formatLiveCodexListing(payload.accounts, port))
1221
+ console.log(line);
1222
+ return true;
1223
+ }
1144
1224
  async function accountsListLive() {
1145
1225
  const { loadConfig } = await import('./config-file.js');
1146
1226
  const fileCfg = loadConfig().config;
@@ -1444,6 +1524,10 @@ async function accounts() {
1444
1524
  */
1445
1525
  async function codex() {
1446
1526
  const sub = args[1];
1527
+ if ((!sub || sub === 'list') && args.includes('--live')) {
1528
+ if (await codexListLive())
1529
+ return;
1530
+ }
1447
1531
  if (!sub || sub === 'list') {
1448
1532
  const aliases = await listCodexAccountAliases();
1449
1533
  console.log('');
@@ -1467,6 +1551,8 @@ async function codex() {
1467
1551
  console.log(` ${a.alias.padEnd(20)} token expires in ${expiry}`);
1468
1552
  }
1469
1553
  console.log('');
1554
+ console.log(' (what the proxy will do with each seat — cooling, refresh refused — is `dario codex list --live` on a running proxy)');
1555
+ console.log('');
1470
1556
  return;
1471
1557
  }
1472
1558
  if (sub === 'add') {
@@ -1656,7 +1742,9 @@ async function help() {
1656
1742
  existing credentials and runs a fresh OAuth
1657
1743
  flow — for when the refresh token is dead and
1658
1744
  /health still reports access-token countdown.
1659
- 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.
1660
1748
  dario status Check authentication status
1661
1749
  dario refresh Force token refresh
1662
1750
  dario resume Clear the overage-guard halt on a running proxy.
@@ -1791,6 +1879,9 @@ async function help() {
1791
1879
  down). --card[=file.svg] writes a share
1792
1880
  card of that number (default
1793
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).
1794
1885
  --by-key splits the lifetime number per
1795
1886
  consumer: named key, x-dario-consumer
1796
1887
  header, or hashed user id. (v6.8)
@@ -1816,6 +1907,11 @@ async function help() {
1816
1907
  instead of --model, so Claude Code's cheap
1817
1908
  sub-agents aren't upgraded to the forced model.
1818
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.
1819
1915
  --no-claude-auth Don't load or refresh the Claude OAuth token —
1820
1916
  for OpenAI-only proxies (e.g. --model=openai:...).
1821
1917
  Prevents dario rotating a shared refresh token out
@@ -2612,6 +2708,18 @@ async function usage() {
2612
2708
  if (!asJson)
2613
2709
  console.log(` Wrote ${cardPath} — ${formatUsd(lifetime.apiEquivalentCost)} API-equivalent since ${lifetime.since.slice(0, 10)}.`);
2614
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
+ }
2615
2723
  if (asJson) {
2616
2724
  if (payload) {
2617
2725
  process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
@@ -51,8 +51,36 @@ export interface CodexRefreshFailure {
51
51
  message: string;
52
52
  }
53
53
  /** Last remembered refresh failure for an alias, or null. Read-only view for
54
- * the admin surface (`GET /codex`) — never triggers an upstream call. */
55
- export declare function getCodexRefreshFailure(alias: string): CodexRefreshFailure | null;
54
+ * the admin surface (`GET /codex`) — never triggers an upstream call.
55
+ *
56
+ * Remembered means the proxy would still refuse to retry: the same
57
+ * `now < retryAt` test `refreshNow` applies. Past that instant the next
58
+ * request will try the token endpoint again, so the failure is no longer what
59
+ * the proxy will do with the seat and must not be reported as such (review on
60
+ * dario#1343: the old read returned the entry until something overwrote it,
61
+ * which could tell an operator to re-add a seat that was about to recover).
62
+ * The expired entry is dropped here so every reader agrees. */
63
+ export declare function getCodexRefreshFailure(alias: string, now?: number): CodexRefreshFailure | null;
64
+ export type CodexSeatStatus = 'ok' | 'cooling' | 'refresh-failed';
65
+ export interface CodexSeatState {
66
+ /** What the proxy will do with this seat right now. */
67
+ status: CodexSeatStatus;
68
+ /** ms until a declined seat is offered again; 0 when it is not cooling. */
69
+ cooldownRemainingMs: number;
70
+ /** The last token-endpoint rejection still remembered (about a minute), or null. */
71
+ lastRefreshError: CodexRefreshFailure | null;
72
+ }
73
+ /**
74
+ * The seat as the proxy sees it, not as the clock sees it (dario#1343). A stored
75
+ * token can be a day from its stated expiry while the backend rejects every
76
+ * request made with it; `expiresAt` alone reported such a seat as healthy for
77
+ * six hours. Two in-memory facts say otherwise: a refresh the token endpoint
78
+ * refused (the seat needs re-adding), and a cool-down after the backend
79
+ * declined it (selection is skipping it). Read from memory only — no request,
80
+ * no refresh, no credential in the answer. An alias the proxy has never touched
81
+ * reads `ok`, which is the truth: nothing is known against it.
82
+ */
83
+ export declare function codexSeatStatus(alias: string, now?: number): CodexSeatState;
56
84
  /** Test seam — forget every remembered failure. */
57
85
  export declare function _resetCodexRefreshFailuresForTest(): void;
58
86
  /**
@@ -205,10 +205,40 @@ export class CodexCredentialsUnavailableError extends Error {
205
205
  const REFRESH_FAILURE_TTL_MS = 60 * 1000;
206
206
  const refreshFailures = new Map();
207
207
  /** Last remembered refresh failure for an alias, or null. Read-only view for
208
- * the admin surface (`GET /codex`) — never triggers an upstream call. */
209
- export function getCodexRefreshFailure(alias) {
208
+ * the admin surface (`GET /codex`) — never triggers an upstream call.
209
+ *
210
+ * Remembered means the proxy would still refuse to retry: the same
211
+ * `now < retryAt` test `refreshNow` applies. Past that instant the next
212
+ * request will try the token endpoint again, so the failure is no longer what
213
+ * the proxy will do with the seat and must not be reported as such (review on
214
+ * dario#1343: the old read returned the entry until something overwrote it,
215
+ * which could tell an operator to re-add a seat that was about to recover).
216
+ * The expired entry is dropped here so every reader agrees. */
217
+ export function getCodexRefreshFailure(alias, now = Date.now()) {
210
218
  const hit = refreshFailures.get(alias);
211
- return hit ? { at: hit.at, status: hit.status, message: hit.message } : null;
219
+ if (!hit)
220
+ return null;
221
+ if (now >= hit.retryAt) {
222
+ refreshFailures.delete(alias);
223
+ return null;
224
+ }
225
+ return { at: hit.at, status: hit.status, message: hit.message };
226
+ }
227
+ /**
228
+ * The seat as the proxy sees it, not as the clock sees it (dario#1343). A stored
229
+ * token can be a day from its stated expiry while the backend rejects every
230
+ * request made with it; `expiresAt` alone reported such a seat as healthy for
231
+ * six hours. Two in-memory facts say otherwise: a refresh the token endpoint
232
+ * refused (the seat needs re-adding), and a cool-down after the backend
233
+ * declined it (selection is skipping it). Read from memory only — no request,
234
+ * no refresh, no credential in the answer. An alias the proxy has never touched
235
+ * reads `ok`, which is the truth: nothing is known against it.
236
+ */
237
+ export function codexSeatStatus(alias, now = Date.now()) {
238
+ const lastRefreshError = getCodexRefreshFailure(alias, now);
239
+ const cooldownRemainingMs = codexCooldownRemainingMs(alias);
240
+ const status = lastRefreshError ? 'refresh-failed' : cooldownRemainingMs > 0 ? 'cooling' : 'ok';
241
+ return { status, cooldownRemainingMs, lastRefreshError };
212
242
  }
213
243
  /** Test seam — forget every remembered failure. */
214
244
  export function _resetCodexRefreshFailuresForTest() {
@@ -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
+ `;
@@ -496,7 +496,7 @@ export declare function detectDrift(t: TemplateData, installedOverride?: string
496
496
  */
497
497
  export declare const SUPPORTED_CC_RANGE: {
498
498
  readonly min: "1.0.0";
499
- readonly maxTested: "2.1.275";
499
+ readonly maxTested: "2.1.276";
500
500
  };
501
501
  /**
502
502
  * Compare two dotted-numeric version strings. Returns negative if `a<b`,
@@ -1194,7 +1194,7 @@ export function detectDrift(t, installedOverride) {
1194
1194
  */
1195
1195
  export const SUPPORTED_CC_RANGE = {
1196
1196
  min: '1.0.0',
1197
- maxTested: '2.1.275',
1197
+ maxTested: '2.1.276',
1198
1198
  };
1199
1199
  /**
1200
1200
  * Compare two dotted-numeric version strings. Returns negative if `a<b`,
@@ -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';
@@ -45,7 +47,7 @@ import { responsesRequestToAnthropic, unsupportedOnClaudeError, ResponsesRequest
45
47
  import { isClaudeServableModel } from './claude-model.js';
46
48
  import { MODEL_UNROUTABLE } from './upstream-rejection.js';
47
49
  import { readCompareTarget, teeResponse, runCompare, writeCompareRecord, COMPARE_RESULT_HEADER } from './compare.js';
48
- import { listCodexAccountAliases, loadAllCodexAccounts, codexAccountNeedsRefresh, hasAnyCodexAccount, selectCodexAccount, selectCodexAccountExcluding, rebindCodexSticky, getFreshCodexAccount, noteCodexDecline, clearCodexDecline, allAliasesCooled, getCodexRefreshFailure, CodexCredentialsUnavailableError, resetCodexPresenceCache } from './codex-accounts.js';
50
+ import { listCodexAccountAliases, loadAllCodexAccounts, codexAccountNeedsRefresh, hasAnyCodexAccount, selectCodexAccount, selectCodexAccountExcluding, rebindCodexSticky, getFreshCodexAccount, noteCodexDecline, clearCodexDecline, allAliasesCooled, CodexCredentialsUnavailableError, resetCodexPresenceCache, codexSeatStatus, } from './codex-accounts.js';
49
51
  import { route as routeProvider } from './provider-adapter.js';
50
52
  import { selectPoolFallbackModels } from './pool-fallback-tier.js';
51
53
  import { RequestQueue, QueueFullError, QueueTimeoutError, DEFAULT_MAX_CONCURRENT, DEFAULT_MAX_QUEUED, DEFAULT_QUEUE_TIMEOUT_MS, resolveMaxConcurrent } from './request-queue.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
@@ -2649,11 +2686,13 @@ export async function startProxy(opts = {}) {
2649
2686
  needsRefresh: codexAccountNeedsRefresh(a),
2650
2687
  models: peekCodexModelSlugs(a.alias) ?? [],
2651
2688
  requestCount: codexRequestCounts.get(a.alias) ?? 0,
2652
- // Why an account that LOOKS present is serving nothing: the last
2653
- // token-endpoint rejection, remembered in-process for a minute
2654
- // (DEV-179a412f). Read from memory only a status read never spends
2655
- // or exposes a credential, so there is no token in {at,status,message}.
2656
- lastRefreshError: getCodexRefreshFailure(a.alias),
2689
+ // Why an account that LOOKS present is serving nothing (dario#1343):
2690
+ // `status` is what the proxy will do with the seat, `cooldownRemainingMs`
2691
+ // how long selection skips it after the backend declined it, and
2692
+ // `lastRefreshError` the token-endpoint rejection remembered in-process
2693
+ // for a minute (DEV-179a412f). Read from memory only — a status read
2694
+ // never spends or exposes a credential, so there is no token in any of it.
2695
+ ...codexSeatStatus(a.alias),
2657
2696
  }));
2658
2697
  res.writeHead(200, JSON_HEADERS);
2659
2698
  res.end(JSON.stringify({
@@ -2671,6 +2710,40 @@ export async function startProxy(opts = {}) {
2671
2710
  res.end(JSON.stringify({ ...analytics.summary(), queue: queue.snapshot(), lifetime: ledger ? ledger.summary() : null }));
2672
2711
  return;
2673
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
+ }
2674
2747
  // The ledger's per-day table, for anyone charting it. `lifetime` on
2675
2748
  // /analytics is the summary; this is the data behind it.
2676
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.10",
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": {