@askalf/dario 6.5.0 → 6.6.1

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
@@ -27,7 +27,7 @@
27
27
 
28
28
  <p><strong>One local endpoint. Every AI tool you own. The subscriptions you already pay for.</strong></p>
29
29
 
30
- <sub><code>npm i -g @askalf/dario</code> · <strong>0</strong> runtime deps · <a href="https://www.npmjs.com/package/@askalf/dario">SLSA-attested</a> every release · nothing phones home · ~35k lines you can read in a weekend · independent, unofficial, third-party (<a href="DISCLAIMER.md">DISCLAIMER.md</a>)</sub>
30
+ <sub><code>npm i -g @askalf/dario</code> · <strong>0</strong> runtime deps · <a href="https://www.npmjs.com/package/@askalf/dario">SLSA-attested</a> every release · nothing phones home · ~36k lines you can read in a weekend · independent, unofficial, third-party (<a href="DISCLAIMER.md">DISCLAIMER.md</a>)</sub>
31
31
 
32
32
  <sub><a href="#start-in-60-seconds">Start</a> · <a href="#point-your-tools-at-it">Your tools</a> · <a href="#what-it-does-with-a-request">Routing</a> · <a href="#two-plans-one-endpoint">Two plans</a> · <a href="#many-seats-one-endpoint">Pool</a> · <a href="#it-tracks-a-moving-target">Drift</a> · <a href="#trust--transparency">Trust</a> · <a href="#will-my-account-get-suspended">Risk</a> · <a href="#commands">Commands</a> · <a href="#faq">FAQ</a> · <a href="docs/returning.md">Coming back after a while?</a></sub>
33
33
 
@@ -373,6 +373,20 @@ Type `dario` with no arguments for a full-screen control panel: live request str
373
373
 
374
374
  <sub>Both screenshots are rendered from the real TUI against a fixture proxy by <a href="scripts/readme/tui.mjs"><code>scripts/readme/tui.mjs</code></a>, so a layout change shows up here instead of rotting a mock-up. The numbers are illustrative; the pixels are not.</sub>
375
375
 
376
+ ### What it would have cost
377
+
378
+ The rolling window forgets on every restart; the **ledger** does not. Since 6.6 dario keeps one small row per day, per model, per billing bucket in `~/.dario/ledger.json` — request counts and the four token buckets, never a price — and prices them at read time from the published API rate cards (Anthropic's, and OpenAI's for the ChatGPT leg), so a pricing correction reprices history instead of freezing the old number in. `dario usage` opens with it, `/analytics` carries it as `lifetime`, the TUI shows it as **API-equivalent**, and it reads from the file when the proxy is down:
379
+
380
+ ```
381
+ API-equivalent spend (since 2026-09-11, 3 days, 1,515 requests):
382
+ $413 would have been billed on the metered API — covered by subscriptions
383
+ Claude $388 1,204 reqs (Opus 5 $301 · Sonnet 5 $86.68)
384
+ ChatGPT $24.75 311 reqs (gpt-5.6-terra $24.75)
385
+ Today $48.20 · Last 7d $413 · Last 30d $413
386
+ ```
387
+
388
+ 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).
389
+
376
390
  ## It tracks a moving target
377
391
 
378
392
  Claude Code's request shape changes between releases — new betas, tool renames, per-model thinking configs — usually with no subscriber-facing note. dario doesn't *guess* that shape: it captures it live from your own installed `claude` binary on every startup, diffs it against each upstream release, and replays it faithfully. That's why your subscription routes the same through dario as it does through Claude Code itself: the request that leaves your machine *is* the shape your plan expects. Details: [wire-fidelity.md](./docs/wire-fidelity.md) · [#13](https://github.com/askalf/dario/discussions/13) · [#14](https://github.com/askalf/dario/discussions/14).
@@ -492,7 +506,7 @@ Longer version, with specifics: [#68](https://github.com/askalf/dario/discussion
492
506
  | `dario accounts list` / `add` / `remove` / `check <alias>` | Pool management; `check` sends one pinned request per model through the running proxy (admin API on) |
493
507
  | `dario backend list` / `add` / `remove` | OpenAI-compatible API-key backends |
494
508
  | `dario codex list` / `add` / `remove` | ChatGPT accounts (the long form of `dario add altman`) |
495
- | `dario usage` · `dario config` · `dario status` | Burn rate for the last hour · effective config, redacted · token health |
509
+ | `dario usage` · `dario config` · `dario status` | Lifetime API-equivalent spend + burn rate for the last hour (`--card` writes the share card) · effective config, redacted · token health |
496
510
  | `dario resume` · `dario refresh` · `dario logout` · `dario upgrade` | Clear an overage halt · force a token refresh · delete credentials · safe self-update |
497
511
  | `dario mcp` · `dario subagent install` / `remove` / `status` | Reach dario from inside any MCP client, or from inside a Claude Code session, read-only |
498
512
 
@@ -503,7 +517,7 @@ Longer version, with specifics: [#68](https://github.com/askalf/dario/discussion
503
517
  | `GET /health` · `GET /livez` | Serviceability (503 when not) · liveness. `/health?probe=1` sends one real request |
504
518
  | `GET /status` · `GET /accounts` · `GET /analytics` | OAuth detail · per-seat utilization and grant age · per-account / per-model stats and burn rate |
505
519
  | `POST /v1/messages/count_tokens` · `POST /v1/complete` | Token counting and the legacy Text Completions shape |
506
- | `GET /analytics/stream` · `GET /codex` | Live analytics over SSE · ChatGPT-seat status, read without spending or exposing a token |
520
+ | `GET /analytics/stream` · `GET /analytics/ledger` · `GET /codex` | Live analytics over SSE · the ledger's per-day table · ChatGPT-seat status, read without spending or exposing a token |
507
521
  | `/admin/*` | Provisioning, `GET /admin/accounts`, `POST /admin/resume`; only with `DARIO_ADMIN=1` ([admin API](./docs/admin-api.md)) |
508
522
 
509
523
  Flags: [commands.md](./docs/commands.md), plus `dario --help` for the ones it doesn't list yet (`--effort`, `--max-tokens`, `--model-alias`, `--fast-model`, session rotation, concurrency caps, the pacing knobs behind `--stealth`) · env vars grouped by task, for Docker / k8s / systemd: [configuration.md](./docs/configuration.md) · SDK examples: [usage.md](./docs/usage.md).
@@ -41,7 +41,39 @@ export interface RequestRecord {
41
41
  status: number;
42
42
  isStream: boolean;
43
43
  isOpenAI: boolean;
44
+ /**
45
+ * Set when the stream died with content on the wire and the mid-stream
46
+ * guard acted (v6.1, src/midstream.ts): what came of it and, when a resume
47
+ * delivered, which leg served the rest. Absent on every ordinary request.
48
+ */
49
+ continuation?: RequestContinuation;
50
+ }
51
+ export interface RequestContinuation {
52
+ /** See midstream.ts ContinuationOutcome. */
53
+ outcome: 'continued' | 'continued-unfinished' | 'resume-failed' | 'no-target';
54
+ /** Label of the leg that served the rest (`gpt-5.6-terra (codex)`), when one did. */
55
+ by?: string;
56
+ /** Characters the client already had when the stream died. */
57
+ partialChars: number;
44
58
  }
59
+ /**
60
+ * How the continuations in a window went. `attempted` is every stream that
61
+ * died with content on the wire and a guard in place; the other four
62
+ * partition it. The number that says whether --pool-fallback is set up to
63
+ * catch a dying stream, and how often one dies at all.
64
+ */
65
+ export interface ContinuationStats {
66
+ attempted: number;
67
+ /** The client holds one complete message. */
68
+ finished: number;
69
+ /** A resume delivered content and then died too; the stream was left open-ended. */
70
+ unfinished: number;
71
+ /** Every choice delivered nothing; the stream ended truncated as before. */
72
+ failed: number;
73
+ /** Nothing to resume through — no --pool-fallback entry for the other provider. */
74
+ noTarget: number;
75
+ }
76
+ export declare function continuationStats(records: readonly RequestRecord[]): ContinuationStats;
45
77
  /**
46
78
  * The four billing buckets a request can land in, derived from the
47
79
  * `anthropic-ratelimit-unified-representative-claim` response header.
@@ -139,7 +171,7 @@ export declare function consumerFromBody(body: Record<string, unknown> | null |
139
171
  * the proxy on every transient non-200/stream-abort.
140
172
  */
141
173
  export declare function isNonSubscriptionBilling(claim: string | null | undefined): boolean;
142
- interface Rate {
174
+ export interface Rate {
143
175
  input: number;
144
176
  output: number;
145
177
  cacheRead: number;
@@ -168,15 +200,50 @@ interface PricingEntry extends Rate {
168
200
  * is why the watcher exists (#1048).
169
201
  */
170
202
  export declare const PRICING: Record<string, PricingEntry>;
203
+ /**
204
+ * OpenAI's published per-1M-token rates for the models the codex backend
205
+ * serves, standard tier, read off developers.openai.com/api/docs/pricing on
206
+ * 2026-09-11. Kept apart from PRICING because scripts/check-pricing-drift.mjs
207
+ * diffs that table against Anthropic's page and would report every row here
208
+ * as "absent upstream". OpenAI charges nothing to write a cache entry, so
209
+ * cacheCreate is the input rate (the codex path reports no cache writes
210
+ * anyway — `cached_tokens` lands in cacheReadTokens, the rest in inputTokens).
211
+ *
212
+ * Before this table every `gpt-*` row was priced at the sonnet-4-6 fallback:
213
+ * a ChatGPT-plan request showed up in "would-be API cost" at Anthropic's
214
+ * rate for a model Anthropic does not sell. Nothing watches this table yet.
215
+ */
216
+ export declare const OPENAI_PRICING: Record<string, Rate>;
217
+ export type PricingProvider = 'anthropic' | 'openai';
218
+ /**
219
+ * Which price list a model id belongs to. Every id the codex backend serves
220
+ * starts `gpt-`; the rest of the pattern covers the older OpenAI families a
221
+ * `--model-alias` might name. Anything else is priced as Claude.
222
+ */
223
+ export declare function providerOfModel(model: string): PricingProvider;
171
224
  /**
172
225
  * The per-1M-token rate for `model` in effect at `atMs` (epoch ms): the intro
173
226
  * rate while within its window, otherwise the standard rate. A trailing context
174
227
  * tag (`claude-sonnet-5[1m]`, `claude-opus-4-7[1m]`) is stripped before lookup —
175
228
  * the [1m] ids used to fall through to the sonnet fallback and bill at the wrong
176
- * family's rate. Unknown models fall back to the sonnet-4-6 rate. Exported for
177
- * tests.
229
+ * family's rate and so are an effort suffix (`gpt-5.6-terra:high`) and the
230
+ * dated form the response echoes (`claude-haiku-4-5-20251001`, which priced
231
+ * at the sonnet fallback until the ledger's first live run caught it).
232
+ * Unknown Claude models fall back to the sonnet-4-6 rate, unknown OpenAI
233
+ * models to gpt-5.6-terra's. Exported for tests.
178
234
  */
179
235
  export declare function pricingRateFor(model: string, atMs: number): Rate;
236
+ /**
237
+ * USD the four token buckets would bill at `model`'s rate in effect at `atMs`.
238
+ * The ledger prices its per-day rows through this too, so a pricing
239
+ * correction reprices history instead of freezing the old number in.
240
+ */
241
+ export declare function costOfTokens(model: string, atMs: number, t: {
242
+ inputTokens: number;
243
+ outputTokens: number;
244
+ cacheReadTokens: number;
245
+ cacheCreateTokens: number;
246
+ }): number;
180
247
  export declare class Analytics extends EventEmitter {
181
248
  private records;
182
249
  private maxRecords;
@@ -281,6 +348,8 @@ interface WindowStats {
281
348
  estimatedCost: number;
282
349
  avgLatencyMs: number;
283
350
  errorRate: number;
351
+ /** Mid-stream continuations in the window and how they went (v6.1 guard, counted since v6.6.1). */
352
+ continuations: ContinuationStats;
284
353
  claimBreakdown: Record<string, number>;
285
354
  /** Count of requests in each derived billing bucket. See #34. */
286
355
  billingBucketBreakdown: Record<BillingBucket, number>;
package/dist/analytics.js CHANGED
@@ -18,6 +18,29 @@
18
18
  */
19
19
  import { EventEmitter } from 'node:events';
20
20
  import { createHash } from 'node:crypto';
21
+ export function continuationStats(records) {
22
+ const out = { attempted: 0, finished: 0, unfinished: 0, failed: 0, noTarget: 0 };
23
+ for (const r of records) {
24
+ if (!r.continuation)
25
+ continue;
26
+ out.attempted++;
27
+ switch (r.continuation.outcome) {
28
+ case 'continued':
29
+ out.finished++;
30
+ break;
31
+ case 'continued-unfinished':
32
+ out.unfinished++;
33
+ break;
34
+ case 'resume-failed':
35
+ out.failed++;
36
+ break;
37
+ case 'no-target':
38
+ out.noTarget++;
39
+ break;
40
+ }
41
+ }
42
+ return out;
43
+ }
21
44
  /**
22
45
  * Map the raw `representative-claim` header value to a human-friendly
23
46
  * billing bucket. Pure function; no state; safe to call from any context.
@@ -206,16 +229,57 @@ export const PRICING = {
206
229
  // LOW. Found by check-pricing-drift.mjs on its very first run (#1048).
207
230
  'claude-haiku-4-5': { input: 1, output: 5, cacheRead: 0.1, cacheCreate: 1.25 },
208
231
  };
232
+ /**
233
+ * OpenAI's published per-1M-token rates for the models the codex backend
234
+ * serves, standard tier, read off developers.openai.com/api/docs/pricing on
235
+ * 2026-09-11. Kept apart from PRICING because scripts/check-pricing-drift.mjs
236
+ * diffs that table against Anthropic's page and would report every row here
237
+ * as "absent upstream". OpenAI charges nothing to write a cache entry, so
238
+ * cacheCreate is the input rate (the codex path reports no cache writes
239
+ * anyway — `cached_tokens` lands in cacheReadTokens, the rest in inputTokens).
240
+ *
241
+ * Before this table every `gpt-*` row was priced at the sonnet-4-6 fallback:
242
+ * a ChatGPT-plan request showed up in "would-be API cost" at Anthropic's
243
+ * rate for a model Anthropic does not sell. Nothing watches this table yet.
244
+ */
245
+ export const OPENAI_PRICING = {
246
+ 'gpt-6-astra': { input: 10, output: 50, cacheRead: 1, cacheCreate: 10 },
247
+ 'gpt-5.6-sol': { input: 4, output: 20, cacheRead: 0.4, cacheCreate: 4 },
248
+ 'gpt-5.6-terra': { input: 2, output: 12, cacheRead: 0.2, cacheCreate: 2 },
249
+ 'gpt-5.6-luna': { input: 0.2, output: 1.2, cacheRead: 0.02, cacheCreate: 0.2 },
250
+ 'gpt-5.5': { input: 5, output: 30, cacheRead: 0.5, cacheCreate: 5 },
251
+ 'gpt-5.4': { input: 2.5, output: 15, cacheRead: 0.25, cacheCreate: 2.5 },
252
+ 'gpt-5.4-mini': { input: 0.75, output: 4.5, cacheRead: 0.075, cacheCreate: 0.75 },
253
+ 'gpt-5.4-nano': { input: 0.2, output: 1.25, cacheRead: 0.02, cacheCreate: 0.2 },
254
+ 'gpt-5.3-codex': { input: 1.75, output: 14, cacheRead: 0.175, cacheCreate: 1.75 },
255
+ };
256
+ /** The unknown-model rate on the OpenAI side: dario's default codex model. */
257
+ const OPENAI_FALLBACK_MODEL = 'gpt-5.6-terra';
258
+ /**
259
+ * Which price list a model id belongs to. Every id the codex backend serves
260
+ * starts `gpt-`; the rest of the pattern covers the older OpenAI families a
261
+ * `--model-alias` might name. Anything else is priced as Claude.
262
+ */
263
+ export function providerOfModel(model) {
264
+ return /^(gpt-|o\d|codex|chatgpt)/i.test(model) ? 'openai' : 'anthropic';
265
+ }
209
266
  /**
210
267
  * The per-1M-token rate for `model` in effect at `atMs` (epoch ms): the intro
211
268
  * rate while within its window, otherwise the standard rate. A trailing context
212
269
  * tag (`claude-sonnet-5[1m]`, `claude-opus-4-7[1m]`) is stripped before lookup —
213
270
  * the [1m] ids used to fall through to the sonnet fallback and bill at the wrong
214
- * family's rate. Unknown models fall back to the sonnet-4-6 rate. Exported for
215
- * tests.
271
+ * family's rate and so are an effort suffix (`gpt-5.6-terra:high`) and the
272
+ * dated form the response echoes (`claude-haiku-4-5-20251001`, which priced
273
+ * at the sonnet fallback until the ledger's first live run caught it).
274
+ * Unknown Claude models fall back to the sonnet-4-6 rate, unknown OpenAI
275
+ * models to gpt-5.6-terra's. Exported for tests.
216
276
  */
217
277
  export function pricingRateFor(model, atMs) {
218
- const baseModel = model.replace(/\[[^\]]*\]$/, '');
278
+ const baseModel = model.replace(/\[[^\]]*\]$/, '').replace(/:[a-z]+$/i, '').replace(/-\d{8}$/, '');
279
+ if (providerOfModel(baseModel) === 'openai') {
280
+ const rate = OPENAI_PRICING[baseModel] ?? OPENAI_PRICING[OPENAI_FALLBACK_MODEL];
281
+ return { ...rate };
282
+ }
219
283
  const entry = PRICING[baseModel] ?? PRICING['claude-sonnet-4-6'];
220
284
  if (entry.intro && atMs <= Date.parse(`${entry.intro.until}T23:59:59.999Z`)) {
221
285
  const { until: _until, ...introRate } = entry.intro;
@@ -223,16 +287,24 @@ export function pricingRateFor(model, atMs) {
223
287
  }
224
288
  return { input: entry.input, output: entry.output, cacheRead: entry.cacheRead, cacheCreate: entry.cacheCreate };
225
289
  }
290
+ /**
291
+ * USD the four token buckets would bill at `model`'s rate in effect at `atMs`.
292
+ * The ledger prices its per-day rows through this too, so a pricing
293
+ * correction reprices history instead of freezing the old number in.
294
+ */
295
+ export function costOfTokens(model, atMs, t) {
296
+ const p = pricingRateFor(model, atMs);
297
+ return ((t.inputTokens * p.input) +
298
+ (t.outputTokens * p.output) +
299
+ (t.cacheReadTokens * p.cacheRead) +
300
+ (t.cacheCreateTokens * p.cacheCreate)) / 1_000_000;
301
+ }
226
302
  function estimateCost(record) {
227
303
  // Price each record at the rate effective at ITS OWN timestamp, so a window
228
304
  // that spans a pricing cutover (no model currently has one — Sonnet 5's
229
305
  // scheduled increase was cancelled and its $2/$10 made permanent)
230
306
  // estimates each side correctly rather than repricing history at today's rate.
231
- const p = pricingRateFor(record.model, record.timestamp);
232
- return ((record.inputTokens * p.input) +
233
- (record.outputTokens * p.output) +
234
- (record.cacheReadTokens * p.cacheRead) +
235
- (record.cacheCreateTokens * p.cacheCreate)) / 1_000_000;
307
+ return costOfTokens(record.model, record.timestamp, record);
236
308
  }
237
309
  export class Analytics extends EventEmitter {
238
310
  records = [];
@@ -323,6 +395,7 @@ export class Analytics extends EventEmitter {
323
395
  totalInputTokens: 0, totalOutputTokens: 0, totalThinkingTokens: 0,
324
396
  totalCacheReadTokens: 0, totalCacheCreateTokens: 0, cachedPromptPercent: 0,
325
397
  estimatedCost: 0, avgLatencyMs: 0, errorRate: 0,
398
+ continuations: { attempted: 0, finished: 0, unfinished: 0, failed: 0, noTarget: 0 },
326
399
  claimBreakdown: {},
327
400
  billingBucketBreakdown: {
328
401
  subscription: 0,
@@ -369,6 +442,7 @@ export class Analytics extends EventEmitter {
369
442
  estimatedCost: Math.round(cost * 10000) / 10000,
370
443
  avgLatencyMs: Math.round(avgLatency),
371
444
  errorRate: Math.round((errors / records.length) * 10000) / 10000,
445
+ continuations: continuationStats(records),
372
446
  claimBreakdown: claims,
373
447
  billingBucketBreakdown: buckets,
374
448
  subscriptionPercent: subscriptionPct,
package/dist/cli.js CHANGED
@@ -16,7 +16,8 @@
16
16
  // at the bottom of the file so importing this module (e.g. from tests that
17
17
  // just want `parsePositiveIntEnv`) doesn't trigger a Bun relaunch or any
18
18
  // other startup side effect.
19
- import { unlink } from 'node:fs/promises';
19
+ import { unlink, writeFile } from 'node:fs/promises';
20
+ import { formatLedgerSummary, formatUsd, renderLedgerCard, readLedgerFile, resolveLedgerPath, summarizeLedger } from './ledger.js';
20
21
  import { loadAllAccounts as loadAllAccountsForIdentity, regenerateClientIdentity } from './accounts.js';
21
22
  import { maskEmail } from './pool.js';
22
23
  import { realpathSync, readFileSync } from 'node:fs';
@@ -630,6 +631,10 @@ async function proxy() {
630
631
  // On by default; see ProxyOptions.midstreamContinue.
631
632
  const midstreamContinue = !(args.includes('--no-midstream-continue')
632
633
  || ['0', 'false', 'no', 'off'].includes((process.env['DARIO_MIDSTREAM_CONTINUE'] ?? '').toLowerCase()));
634
+ // --no-ledger / DARIO_LEDGER=0 — do not keep the lifetime ledger (v6.6).
635
+ // On by default; see ProxyOptions.ledger.
636
+ const ledger = !(args.includes('--no-ledger')
637
+ || ['0', 'false', 'no', 'off'].includes((process.env['DARIO_LEDGER'] ?? '').toLowerCase()));
633
638
  // --preserve-output-format — carry the client body's `output_config.format`
634
639
  // (structured-output JSON schema) through to upstream instead of dropping it
635
640
  // during the CC rebuild. See ProxyOptions.preserveOutputFormat for rationale.
@@ -654,7 +659,7 @@ async function proxy() {
654
659
  console.error(`[dario] Override (not recommended): pass --unsafe-no-auth if you have out-of-band network controls and accept the risk.`);
655
660
  process.exit(1);
656
661
  }
657
- 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, poolSharedState, poolSharedStateIntervalMs, effort, maxTokens, poolFallbackModel, modelAliases, logFile, passthroughBetas, skipFields, systemPrompt, overageGuardEnabled, overageGuardBehavior, overageGuardCooldownMs, overageGuardNotifyOs, honorClientThinking, preserveOutputFormat, midstreamContinue });
662
+ 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, poolSharedState, poolSharedStateIntervalMs, effort, maxTokens, poolFallbackModel, modelAliases, logFile, passthroughBetas, skipFields, systemPrompt, overageGuardEnabled, overageGuardBehavior, overageGuardCooldownMs, overageGuardNotifyOs, honorClientThinking, preserveOutputFormat, midstreamContinue, ledger });
658
663
  }
659
664
  /**
660
665
  * Parse `--system-prompt=<verbatim|partial|aggressive|filepath>` (or the
@@ -1583,7 +1588,12 @@ async function help() {
1583
1588
  rate-limit snapshot from Anthropic, see
1584
1589
  \`dario doctor --usage\`. --port=N to target
1585
1590
  a non-default port; --json for the raw
1586
- /analytics payload.
1591
+ /analytics payload. Above the window: the
1592
+ lifetime API-equivalent spend from the
1593
+ ledger (read from disk when the proxy is
1594
+ down). --card[=file.svg] writes a share
1595
+ card of that number (default
1596
+ dario-api-equivalent.svg). (v6.6)
1587
1597
  dario upgrade npm install -g @askalf/dario@latest with a
1588
1598
  pre-flight current-vs-latest check.
1589
1599
 
@@ -1704,6 +1714,14 @@ async function help() {
1704
1714
  fallback entry for the other provider,
1705
1715
  the stream ends truncated as before.
1706
1716
  Env: DARIO_MIDSTREAM_CONTINUE=0. (v6.1)
1717
+ --no-ledger Do not keep the lifetime ledger
1718
+ (~/.dario/ledger.json): per-day, per-model
1719
+ token totals that let /analytics and
1720
+ \`dario usage\` say what the traffic would
1721
+ have cost on the metered API since the
1722
+ first request, across restarts. Env:
1723
+ DARIO_LEDGER=0; DARIO_LEDGER_PATH=<file>
1724
+ moves it. (v6.6)
1707
1725
  --session-idle-rotate=MS Idle ms before an account's session id
1708
1726
  rotates (default: 900000 = 15 min).
1709
1727
  Real CC rotates once per conversation, not
@@ -2322,6 +2340,9 @@ async function usage() {
2322
2340
  ? parseInt(process.env['DARIO_USAGE_PORT'], 10)
2323
2341
  : 3456;
2324
2342
  const asJson = args.includes('--json');
2343
+ // --card / --card=<file>: write the share card (SVG) of the lifetime number.
2344
+ const cardArg = args.find(a => a === '--card' || a.startsWith('--card='));
2345
+ const cardPath = cardArg ? (cardArg.includes('=') ? cardArg.slice('--card='.length) : 'dario-api-equivalent.svg') : null;
2325
2346
  const url = `http://127.0.0.1:${port}/analytics`;
2326
2347
  let payload = null;
2327
2348
  let connectError = null;
@@ -2337,18 +2358,54 @@ async function usage() {
2337
2358
  catch (err) {
2338
2359
  connectError = err instanceof Error ? err.message : String(err);
2339
2360
  }
2361
+ // The lifetime number does not need a running proxy: the ledger is a file.
2362
+ // Prefer the proxy's view (it holds records not yet flushed); fall back to
2363
+ // reading the file this port's proxy would write.
2364
+ let lifetime = payload?.lifetime ?? null;
2365
+ let lifetimeNote = null;
2366
+ if (!payload) {
2367
+ const ledgerPath = resolveLedgerPath(port);
2368
+ const { file, error } = await readLedgerFile(ledgerPath);
2369
+ if (file)
2370
+ lifetime = summarizeLedger(file, ledgerPath);
2371
+ else if (error)
2372
+ lifetimeNote = `ledger at ${ledgerPath} unreadable: ${error}`;
2373
+ else
2374
+ lifetimeNote = `no ledger at ${ledgerPath} yet — it appears after the first request through a proxy on this port`;
2375
+ }
2376
+ else if (payload.lifetime === null) {
2377
+ lifetimeNote = 'ledger disabled on this proxy (--no-ledger)';
2378
+ }
2379
+ if (cardPath) {
2380
+ if (!lifetime) {
2381
+ console.error(` No lifetime numbers to draw${lifetimeNote ? ` (${lifetimeNote})` : ''}.`);
2382
+ process.exit(1);
2383
+ }
2384
+ await writeFile(cardPath, renderLedgerCard(lifetime), 'utf8');
2385
+ if (!asJson)
2386
+ console.log(` Wrote ${cardPath} — ${formatUsd(lifetime.apiEquivalentCost)} API-equivalent since ${lifetime.since.slice(0, 10)}.`);
2387
+ }
2340
2388
  if (asJson) {
2341
2389
  if (payload) {
2342
2390
  process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
2343
2391
  return;
2344
2392
  }
2345
- process.stdout.write(JSON.stringify({ error: 'proxy not reachable', port, detail: connectError }, null, 2) + '\n');
2393
+ process.stdout.write(JSON.stringify({ error: 'proxy not reachable', port, detail: connectError, lifetime }, null, 2) + '\n');
2346
2394
  process.exit(1);
2347
2395
  }
2348
2396
  console.log('');
2349
2397
  console.log(' dario — Usage');
2350
2398
  console.log(' ─────────────');
2351
2399
  console.log('');
2400
+ if (lifetime) {
2401
+ for (const line of formatLedgerSummary(lifetime))
2402
+ console.log(line);
2403
+ console.log('');
2404
+ }
2405
+ else if (lifetimeNote) {
2406
+ console.log(` API-equivalent spend: ${lifetimeNote}.`);
2407
+ console.log('');
2408
+ }
2352
2409
  if (!payload) {
2353
2410
  console.log(` Proxy not reachable on http://127.0.0.1:${port} (${connectError ?? 'no response'}).`);
2354
2411
  console.log(' `dario usage` summarizes traffic from a running proxy (live history).');
@@ -2393,6 +2450,19 @@ async function usage() {
2393
2450
  if ((win.estimatedCost ?? 0) > 0) {
2394
2451
  console.log(` Est. cost: $${(win.estimatedCost ?? 0).toFixed(4)} (would-be API cost)`);
2395
2452
  }
2453
+ // Streams that died with content on the wire, and what the mid-stream
2454
+ // guard made of them. Silent when none did — the common case.
2455
+ const c = win.continuations;
2456
+ if (c && c.attempted > 0) {
2457
+ const parts = [`${c.finished} finished`];
2458
+ if (c.unfinished > 0)
2459
+ parts.push(`${c.unfinished} unfinished`);
2460
+ if (c.failed > 0)
2461
+ parts.push(`${c.failed} failed`);
2462
+ if (c.noTarget > 0)
2463
+ parts.push(`${c.noTarget} no target — set --pool-fallback for the other provider`);
2464
+ console.log(` Continuations: ${c.attempted} stream${c.attempted === 1 ? '' : 's'} died mid-answer: ${parts.join(', ')}`);
2465
+ }
2396
2466
  }
2397
2467
  if (perAccount && Object.keys(perAccount).length > 0) {
2398
2468
  console.log('');
@@ -0,0 +1,175 @@
1
+ /**
2
+ * The ledger — what the traffic dario has served would have cost on the
3
+ * metered API, kept across restarts.
4
+ *
5
+ * /analytics is a rolling in-memory window: it forgets on every restart and
6
+ * caps at 10k records, so the one number a subscription user actually wants
7
+ * — "what has this saved me" — was never answerable past the last few hours.
8
+ * The ledger keeps one small row per (UTC day, model, bucket): a request
9
+ * count and the four token buckets. It never stores a price. Rows are priced
10
+ * at read time through `costOfTokens` at the day's own timestamp, so a
11
+ * pricing correction (#1047, #1048 — both happened) reprices history instead
12
+ * of freezing the wrong number in.
13
+ *
14
+ * Two buckets per row. `covered` is traffic a subscription paid for — the
15
+ * API-equivalent cost of that is the headline, the invoice that never
16
+ * arrived. `metered` is traffic billed per token anyway (an API key, or
17
+ * Anthropic's paid `extra_usage` overage) — that money was spent, and it is
18
+ * reported separately rather than counted as saved. Only 2xx responses
19
+ * count: a 429 carries no tokens and a 5xx bills nothing.
20
+ *
21
+ * On disk: `~/.dario/ledger.json` for the default port, `ledger-<port>.json`
22
+ * for any other, so two instances sharing a home (the box's live-test rig
23
+ * runs one on :3999 next to production) do not overwrite each other's file.
24
+ * Writes are debounced and durable (`durableWriteFile`); the shutdown hook
25
+ * flushes what the debounce still holds, so at most the last few seconds
26
+ * before a SIGKILL are lost.
27
+ */
28
+ import { type PricingProvider, type RequestRecord } from './analytics.js';
29
+ export declare const LEDGER_VERSION = 1;
30
+ /** Days kept before the oldest roll off — two years at one row per model per day. */
31
+ export declare const LEDGER_MAX_DAYS = 730;
32
+ /** How long after the last record the file is rewritten. */
33
+ export declare const LEDGER_FLUSH_DELAY_MS = 3000;
34
+ export type LedgerBucket = 'covered' | 'metered';
35
+ export interface LedgerCell {
36
+ requests: number;
37
+ inputTokens: number;
38
+ outputTokens: number;
39
+ cacheReadTokens: number;
40
+ cacheCreateTokens: number;
41
+ }
42
+ export type LedgerRow = Partial<Record<LedgerBucket, LedgerCell>>;
43
+ export interface LedgerFile {
44
+ version: number;
45
+ /** ISO timestamp of the first record the ledger ever saw. */
46
+ since: string;
47
+ /** ISO timestamp of the last write. */
48
+ updated: string;
49
+ /** `YYYY-MM-DD` (UTC) → model id → per-bucket totals. */
50
+ days: Record<string, Record<string, LedgerRow>>;
51
+ }
52
+ export interface LedgerModelSummary {
53
+ provider: PricingProvider;
54
+ requests: number;
55
+ inputTokens: number;
56
+ outputTokens: number;
57
+ cacheReadTokens: number;
58
+ cacheCreateTokens: number;
59
+ /** API-equivalent cost of this model's covered traffic, USD. */
60
+ apiEquivalentCost: number;
61
+ /** What this model's metered traffic cost at list price, USD. */
62
+ meteredCost: number;
63
+ }
64
+ export interface LedgerSummary {
65
+ /** Where the file lives — so `dario usage` can say what it read. */
66
+ path: string;
67
+ since: string;
68
+ /** Distinct UTC days with traffic. */
69
+ days: number;
70
+ /** Covered + metered, 2xx only. */
71
+ requests: number;
72
+ /**
73
+ * The headline: what subscription-covered traffic would have been billed
74
+ * on the metered API at today's list prices, USD.
75
+ */
76
+ apiEquivalentCost: number;
77
+ /** What metered traffic (API key, paid overage) actually cost at list price, USD. */
78
+ meteredCost: number;
79
+ /** Covered token totals. */
80
+ tokens: {
81
+ input: number;
82
+ output: number;
83
+ cacheRead: number;
84
+ cacheCreate: number;
85
+ };
86
+ perProvider: Record<PricingProvider, {
87
+ requests: number;
88
+ apiEquivalentCost: number;
89
+ }>;
90
+ perModel: Record<string, LedgerModelSummary>;
91
+ /** apiEquivalentCost over the trailing windows, UTC days. */
92
+ recent: {
93
+ today: number;
94
+ last7d: number;
95
+ last30d: number;
96
+ };
97
+ }
98
+ export declare function ledgerPathFor(port: number, home?: string): string;
99
+ /**
100
+ * `DARIO_LEDGER_PATH` names the file; `DARIO_LEDGER=0` (or `--no-ledger`)
101
+ * turns the ledger off. Off, /analytics reports `lifetime: null` and the
102
+ * usage command says so.
103
+ */
104
+ export declare function resolveLedgerPath(port: number, env?: NodeJS.ProcessEnv): string;
105
+ export declare function ledgerDisabledByEnv(env?: NodeJS.ProcessEnv): boolean;
106
+ export declare function emptyLedger(now?: number): LedgerFile;
107
+ /** UTC calendar day of an epoch-ms timestamp. */
108
+ export declare function dayKey(atMs: number): string;
109
+ /**
110
+ * Which bucket a record lands in, or null when it should not be counted.
111
+ * `api` and `extra_usage` are metered; every subscription claim, the codex
112
+ * claim, and an absent claim on a 2xx (stream aborts, api-key mode without
113
+ * the header) are covered — the request was served, and nothing says it was
114
+ * billed per token.
115
+ */
116
+ export declare function ledgerBucketFor(record: Pick<RequestRecord, 'status' | 'claim'>): LedgerBucket | null;
117
+ /**
118
+ * Parse a ledger file's text, keeping only well-formed rows. A file that is
119
+ * not a ledger at all throws; the caller moves it aside and starts fresh.
120
+ */
121
+ export declare function parseLedger(text: string): LedgerFile;
122
+ /** Add one record's tokens to the file in place. Returns false when it was not counted. */
123
+ export declare function addToLedger(file: LedgerFile, record: RequestRecord): boolean;
124
+ /** Drop the oldest days past LEDGER_MAX_DAYS. */
125
+ export declare function pruneLedger(file: LedgerFile, maxDays?: number): void;
126
+ export declare function summarizeLedger(file: LedgerFile, path: string, now?: number): LedgerSummary;
127
+ /**
128
+ * Read a ledger file for display without a running proxy (`dario usage`
129
+ * when the proxy is down). Missing file → null; unreadable → null with the
130
+ * reason, never a throw.
131
+ */
132
+ export declare function readLedgerFile(path: string): Promise<{
133
+ file: LedgerFile | null;
134
+ error?: string;
135
+ }>;
136
+ export declare class Ledger {
137
+ readonly path: string;
138
+ private readonly log;
139
+ private file;
140
+ private dirty;
141
+ private timer;
142
+ private writing;
143
+ private closed;
144
+ private constructor();
145
+ /**
146
+ * Load the ledger at `path`, or start one. A file that cannot be parsed is
147
+ * moved aside (`<path>.corrupt-<ts>`) rather than overwritten, so a bad
148
+ * write never silently zeroes two years of history.
149
+ */
150
+ static open(path: string, log?: (line: string) => void): Promise<Ledger>;
151
+ /** Count a request. Returns false when it was not ledger material. */
152
+ add(record: RequestRecord): boolean;
153
+ summary(now?: number): LedgerSummary;
154
+ /** The raw per-day table, for /analytics/ledger. */
155
+ snapshot(): LedgerFile;
156
+ private scheduleFlush;
157
+ /** Write now if anything changed. Serialized; a failure is logged, not thrown. */
158
+ flush(): Promise<void>;
159
+ /** Final flush for the shutdown hook. */
160
+ close(): Promise<void>;
161
+ }
162
+ export declare function formatUsd(usd: number): string;
163
+ /** `claude-opus-5` → `Opus 5`, `claude-haiku-4-5-20251001` → `Haiku 4.5`, `gpt-5.6-terra` → `gpt-5.6-terra`. */
164
+ export declare function shortModelName(model: string): string;
165
+ /**
166
+ * The block `dario usage` prints above the rolling window. Two-space indent
167
+ * to match the rest of that command's output.
168
+ */
169
+ export declare function formatLedgerSummary(s: LedgerSummary): string[];
170
+ /**
171
+ * A share card: one SVG, 640×320, dark, the number in the middle. Plain
172
+ * system monospace so it renders the same in a README, a tweet screenshot
173
+ * and an <img> tag with nothing to fetch.
174
+ */
175
+ export declare function renderLedgerCard(s: LedgerSummary): string;