@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/dist/proxy.js CHANGED
@@ -16,6 +16,7 @@ import { AccountPool, computeStickyKey, parseRateLimits, modelFamily, isInAuthCo
16
16
  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
+ import { Ledger, resolveLedgerPath, ledgerDisabledByEnv } from './ledger.js';
19
20
  import { OverageGuard, buildHaltErrorBody } from './overage-guard.js';
20
21
  import { notify as osNotify } from './notify.js';
21
22
  import { grantAge, grantThresholds, worstGrantLevel, describeGrantAge } from './refresh-grant.js';
@@ -26,7 +27,19 @@ import { createTokenBucket } from './rate-limit.js';
26
27
  import { getOpenAIBackend, isOpenAIModel, forwardToOpenAI } from './openai-backend.js';
27
28
  import { forwardToCodex, forwardResponsesToCodex, getCodexModelSlugs, peekCodexModelSlugs, isCodexModel, pickCodexFallback, pickClaudeTarget, CODEX_BACKEND_BASE_URL } from './codex-backend.js';
28
29
  import { effortForCodex } from './effort.js';
29
- import { MidstreamGuard, guardFor, loopbackBaseFor, chaosCutFetch, chaosCutState, CONTINUATION_HEADER, MAX_CONTINUATION_DEPTH, continuationDepth } from './midstream.js';
30
+ import { MidstreamGuard, guardFor, loopbackBaseFor, chaosCutFetch, chaosCutState, CONTINUATION_HEADER, CONTINUATION_OF_HEADER, MAX_CONTINUATION_DEPTH, continuationDepth, continuationOfRequest } from './midstream.js';
31
+ /**
32
+ * The continuation fields for a request's analytics row, from its guard.
33
+ * Only the client's own request (depth 0) carries them: a resume leg is a
34
+ * loopback request with a guard of its own, and counting its attempt too
35
+ * would show one dying client stream as two. The log line still carries
36
+ * every leg's outcome — that is where the hop-by-hop story is read.
37
+ */
38
+ function continuationOf(guard, depth) {
39
+ if (!guard || !guard.outcome || depth > 0)
40
+ return undefined;
41
+ return { outcome: guard.outcome, ...(guard.continuedBy ? { by: guard.continuedBy } : {}), partialChars: guard.partialChars };
42
+ }
30
43
  import { responsesRequestToAnthropic, unsupportedOnClaudeError, ResponsesRequestError, ResponsesOut, wrapResponsesClient } from './responses-inbound.js';
31
44
  import { isClaudeServableModel } from './claude-model.js';
32
45
  import { MODEL_UNROUTABLE } from './upstream-rejection.js';
@@ -1445,6 +1458,19 @@ export async function startProxy(opts = {}) {
1445
1458
  // : null` — that gated the /analytics endpoint, but burn-rate /
1446
1459
  // per-request visibility is useful for a pool of one too.
1447
1460
  const analytics = new Analytics();
1461
+ // The lifetime ledger rides the same record stream analytics emits, so
1462
+ // every site that records a request feeds it without a second call. Off,
1463
+ // /analytics reports `lifetime: null`.
1464
+ const ledgerOn = opts.ledger !== false && !ledgerDisabledByEnv();
1465
+ const ledger = ledgerOn ? await Ledger.open(resolveLedgerPath(port), (line) => console.log(line)) : null;
1466
+ if (ledger) {
1467
+ analytics.on('record', (r) => { ledger.add(r); });
1468
+ if (verbose)
1469
+ console.log(`[dario] ledger: ${ledger.path}`);
1470
+ }
1471
+ else {
1472
+ console.log('[dario] ledger: disabled (--no-ledger)');
1473
+ }
1448
1474
  // Per-alias request counts for GET /codex — the pool has requestCount per
1449
1475
  // account; the codex accounts had nothing until now.
1450
1476
  const codexRequestCounts = new Map();
@@ -2569,7 +2595,19 @@ export async function startProxy(opts = {}) {
2569
2595
  // `queue` rides along the summary (dario#905): request-queue.ts always
2570
2596
  // documented snapshot() as "exposed for /analytics", but it was never
2571
2597
  // actually wired in, so slot exhaustion was invisible from outside.
2572
- res.end(JSON.stringify({ ...analytics.summary(), queue: queue.snapshot() }));
2598
+ res.end(JSON.stringify({ ...analytics.summary(), queue: queue.snapshot(), lifetime: ledger ? ledger.summary() : null }));
2599
+ return;
2600
+ }
2601
+ // The ledger's per-day table, for anyone charting it. `lifetime` on
2602
+ // /analytics is the summary; this is the data behind it.
2603
+ if (urlPath === '/analytics/ledger' && req.method === 'GET') {
2604
+ if (!ledger) {
2605
+ res.writeHead(404, JSON_HEADERS);
2606
+ res.end(JSON.stringify({ error: 'ledger disabled', hint: 'start without --no-ledger / DARIO_LEDGER=0' }));
2607
+ return;
2608
+ }
2609
+ res.writeHead(200, JSON_HEADERS);
2610
+ res.end(JSON.stringify({ path: ledger.path, ...ledger.snapshot() }));
2573
2611
  return;
2574
2612
  }
2575
2613
  // Analytics live stream — SSE of new RequestRecord JSON, one event
@@ -3103,6 +3141,12 @@ export async function startProxy(opts = {}) {
3103
3141
  // never continued itself (MAX_CONTINUATION_DEPTH).
3104
3142
  const requestDepth = continuationDepth(req.headers[CONTINUATION_HEADER]);
3105
3143
  const isContinuation = requestDepth >= MAX_CONTINUATION_DEPTH;
3144
+ // A resume leg's log row points back at the request whose stream died
3145
+ // (its guard's number) and says how deep it sits, so the hop-by-hop
3146
+ // story is readable from the log alone. Empty on a client request.
3147
+ const continuationLeg = requestDepth > 0
3148
+ ? { continuation_depth: requestDepth, continuation_of: continuationOfRequest(req.headers[CONTINUATION_OF_HEADER]) }
3149
+ : {};
3106
3150
  /**
3107
3151
  * First hop: the SAME model again, through the front door. The pool
3108
3152
  * picks a seat (sticky binding keeps the prompt cache warm), and if the
@@ -3624,6 +3668,7 @@ export async function startProxy(opts = {}) {
3624
3668
  // overage guard (#288) leaves it alone.
3625
3669
  claim: CODEX_CLAIM, util5h: 0, util7d: 0, overageUtil: 0,
3626
3670
  latencyMs: o.latencyMs, status: o.status, isStream: o.stream, isOpenAI,
3671
+ continuation: continuationOf(codexGuard, requestDepth),
3627
3672
  });
3628
3673
  writeLogLine(logFileStream, {
3629
3674
  ts: new Date().toISOString(), req: codexReq,
@@ -3631,6 +3676,8 @@ export async function startProxy(opts = {}) {
3631
3676
  status: o.status, latency_ms: o.latencyMs, in_tokens: o.inputTokens, out_tokens: o.outputTokens,
3632
3677
  cache_read: o.cacheReadTokens, cache_create: o.cacheCreateTokens,
3633
3678
  claim: CODEX_CLAIM, bucket: 'subscription', account: o.alias, consumer, stream: o.stream,
3679
+ ...(codexGuard?.outcome ? { continued: codexGuard.outcome, continued_by: codexGuard.continuedBy ?? undefined, continued_after: codexGuard.partialChars } : {}),
3680
+ ...continuationLeg,
3634
3681
  });
3635
3682
  if (verbose)
3636
3683
  console.log(formatUsageLogLine(codexReq, {
@@ -5156,6 +5203,7 @@ export async function startProxy(opts = {}) {
5156
5203
  thinkingTokens: Math.round(streamThinkingChars / 4),
5157
5204
  claim: rl.claim, util5h: rl.util5h, util7d: rl.util7d, overageUtil: rl.overageUtil,
5158
5205
  latencyMs: Date.now() - startTime, status: upstream.status, isStream: true, isOpenAI,
5206
+ continuation: continuationOf(guard, requestDepth),
5159
5207
  });
5160
5208
  }
5161
5209
  writeLogLine(logFileStream, {
@@ -5172,6 +5220,8 @@ export async function startProxy(opts = {}) {
5172
5220
  client: detectedClientForLog,
5173
5221
  preserve_tools: preserveToolsEffective,
5174
5222
  stream: true,
5223
+ ...(guard?.outcome ? { continued: guard.outcome, continued_by: guard.continuedBy ?? undefined, continued_after: guard.partialChars } : {}),
5224
+ ...continuationLeg,
5175
5225
  });
5176
5226
  if (verbose)
5177
5227
  console.log(formatUsageLogLine(requestCount, {
@@ -5532,7 +5582,7 @@ export async function startProxy(opts = {}) {
5532
5582
  // Flush tokens first (best-effort, bounded), then close the server. The
5533
5583
  // flush is fire-and-forget under the same 5s force-exit guard below so a
5534
5584
  // hung fsync can't wedge shutdown.
5535
- void flushPoolTokens().finally(() => {
5585
+ void Promise.all([flushPoolTokens(), ledger?.close()]).finally(() => {
5536
5586
  server.close(() => process.exit(0));
5537
5587
  });
5538
5588
  // Force exit after 5s if connections (or the flush) don't complete.
@@ -43,6 +43,14 @@ interface SummaryShape {
43
43
  currentUtil7d: number;
44
44
  lastClaim: string;
45
45
  }>;
46
+ /** The ledger's lifetime view (v6.6); null when the proxy runs --no-ledger, absent on older proxies. */
47
+ lifetime?: {
48
+ apiEquivalentCost: number;
49
+ since: string;
50
+ recent: {
51
+ today: number;
52
+ };
53
+ } | null;
46
54
  }
47
55
  export interface AnalyticsState {
48
56
  summary: SummaryShape | null;
@@ -14,6 +14,7 @@
14
14
  import { fg, dim, brand, progressBar, pad, truncate } from '../render.js';
15
15
  import { renderKvRow } from '../layout.js';
16
16
  import { fitPanels } from '../panels.js';
17
+ import { formatUsd } from '../../ledger.js';
17
18
  const POLL_INTERVAL_MS = 2000;
18
19
  /**
19
20
  * Label column for the gauge rows (5h / 7d / Overage). Was 6, which is
@@ -97,13 +98,21 @@ export const AnalyticsTab = {
97
98
  counters.push(' ' + renderKvRow('Thinking tokens', formatNumber(s.window.totalThinkingTokens), w - 4));
98
99
  counters.push(' ' + renderKvRow('Avg latency', `${Math.round(s.window.avgLatencyMs)}ms`, w - 4));
99
100
  counters.push(' ' + renderKvRow('Subscription %', `${s.window.subscriptionPercent.toFixed(0)}%`, w - 4));
100
- // Headline numbers the two that answer "is this costing me money?"
101
+ // The ledger's number: what everything since the first request would
102
+ // have been billed on the metered API. Lifetime, not the window.
103
+ const lifetimeRow = s.lifetime
104
+ ? ' ' + renderKvRow('API-equivalent', `${formatUsd(s.lifetime.apiEquivalentCost)} ${dim(`lifetime, ${formatUsd(s.lifetime.recent.today)} today, since ${s.lifetime.since.slice(0, 10)}`)}`, w - 4)
105
+ : null;
106
+ if (lifetimeRow)
107
+ counters.push(lifetimeRow);
108
+ // Headline numbers — the ones that answer "is this costing me money?"
101
109
  // survive as the collapsed form.
102
110
  panels.push({
103
111
  lines: counters,
104
112
  collapsed: ['',
105
113
  ' ' + renderKvRow('Requests', `${s.window.requests} ${dim(`(${rpm.toFixed(1)}/min)`)}`, w - 4),
106
- ' ' + renderKvRow('Subscription %', `${s.window.subscriptionPercent.toFixed(0)}%`, w - 4)],
114
+ ' ' + renderKvRow('Subscription %', `${s.window.subscriptionPercent.toFixed(0)}%`, w - 4),
115
+ ...(lifetimeRow ? [lifetimeRow] : [])],
107
116
  priority: 1,
108
117
  });
109
118
  // ── Per-model bars ─────────────────────────────────────────
@@ -0,0 +1,116 @@
1
+ # API-equivalent spend — the ledger
2
+
3
+ What the traffic dario has served would have cost on the metered API, kept
4
+ across restarts. `dario usage` opens with it; `/analytics` carries it as
5
+ `lifetime`; the TUI's Analytics tab shows it as **API-equivalent**.
6
+
7
+ ## Why a file
8
+
9
+ `/analytics` is a rolling in-memory window: 10k records, gone on restart. It
10
+ answers "what is this costing me right now" and could never answer the
11
+ question a subscription user actually has — what has this saved me since I
12
+ set it up. The ledger is the persistent half. It is deliberately small: one
13
+ row per UTC day, per model, per billing bucket, holding a request count and
14
+ the four token buckets (input, output, cache read, cache write). Nothing else.
15
+
16
+ It never stores a price. Rows are priced when read, at the rate in effect on
17
+ the row's day, from the same tables the rolling window uses (`PRICING` for
18
+ Claude, `OPENAI_PRICING` for the ChatGPT leg, both in `src/analytics.ts`).
19
+ Pricing has been wrong twice in this repo (#1047, #1048); a stored dollar
20
+ figure would have frozen the wrong number in, a stored token count gets
21
+ repriced the moment the table is fixed.
22
+
23
+ ## What counts
24
+
25
+ Only responses with a 2xx status. A 429 carries no tokens and a 5xx bills
26
+ nothing.
27
+
28
+ Two columns per row:
29
+
30
+ - **covered** — served against a subscription: every Anthropic subscription
31
+ claim (`five_hour`, `seven_day`, their `_fallback` and `_overage_included`
32
+ forms), the ChatGPT leg, and a 2xx that carried no claim at all (a stream
33
+ cut before the headers were read, api-key mode without the header). The
34
+ API-equivalent cost of this column is the headline — the invoice that never
35
+ arrived.
36
+ - **metered** — billed per token anyway: an API key upstream (`api`) or
37
+ Anthropic's paid overage (`overage` → `extra_usage`). That money was spent.
38
+ It is reported on its own line ("Paid per token on top") and never counted
39
+ as saved.
40
+
41
+ A mid-stream continuation (6.1) is two upstream requests and records as two
42
+ rows, one per provider, each with the tokens that leg actually consumed.
43
+
44
+ ## Where it lives
45
+
46
+ `~/.dario/ledger.json` for a proxy on the default port; `ledger-<port>.json`
47
+ for any other, so two instances sharing a home (a live-test rig next to
48
+ production) do not overwrite each other. `DARIO_LEDGER_PATH=<file>` moves it;
49
+ `--no-ledger` / `DARIO_LEDGER=0` turns it off, after which `/analytics`
50
+ reports `lifetime: null`, `/analytics/ledger` is a 404 and `dario usage` says
51
+ so.
52
+
53
+ Writes are debounced (3 s after the last record) and durable — temp file,
54
+ fsync, rename, directory fsync, the same path the credential store uses
55
+ (#790). The shutdown hook flushes what the debounce still holds, so a
56
+ `docker rm -f` loses at most the last few seconds. A file that will not parse
57
+ is moved aside as `ledger.json.corrupt-<ts>` and the ledger starts fresh; it
58
+ is never overwritten in place. Days past 730 roll off the front.
59
+
60
+ The test suite pins `DARIO_LEDGER_PATH` to a temp directory, so a suite run
61
+ does not add stub traffic to the operator's file.
62
+
63
+ ## Reading it
64
+
65
+ ```
66
+ $ dario usage
67
+ API-equivalent spend (since 2026-09-11, 3 days, 1,515 requests):
68
+ $413 would have been billed on the metered API — covered by subscriptions
69
+ Claude $388 1,204 reqs (Opus 5 $301 · Sonnet 5 $86.68)
70
+ ChatGPT $24.75 311 reqs (gpt-5.6-terra $24.75)
71
+ Today $48.20 · Last 7d $413 · Last 30d $413
72
+ Paid per token on top (API key / extra usage): $1.10
73
+ ```
74
+
75
+ The proxy's view is preferred (it holds records the debounce has not flushed
76
+ yet); with no proxy on the port, the command reads the file the proxy on that
77
+ port would write. `--card[=file.svg]` renders the headline as a 640×320 SVG
78
+ (default `dario-api-equivalent.svg`) — plain system monospace, nothing to
79
+ fetch, so it looks the same in a README and a screenshot. `--json` is the raw
80
+ `/analytics` payload, `lifetime` included.
81
+
82
+ `GET /analytics` → `lifetime`:
83
+
84
+ ```json
85
+ {
86
+ "path": "/root/.dario/ledger.json",
87
+ "since": "2026-09-11T02:14:09.000Z",
88
+ "days": 3,
89
+ "requests": 1515,
90
+ "apiEquivalentCost": 412.87,
91
+ "meteredCost": 1.1,
92
+ "tokens": { "input": 1204000, "output": 388000, "cacheRead": 91200000, "cacheCreate": 4100000 },
93
+ "perProvider": { "anthropic": { "requests": 1204, "apiEquivalentCost": 388.12 }, "openai": { "requests": 311, "apiEquivalentCost": 24.75 } },
94
+ "perModel": { "claude-opus-5": { "provider": "anthropic", "requests": 900, "apiEquivalentCost": 301.44, "meteredCost": 1.1, "...": "token totals" } },
95
+ "recent": { "today": 48.2, "last7d": 412.87, "last30d": 412.87 }
96
+ }
97
+ ```
98
+
99
+ `GET /analytics/ledger` is the file itself: `{ path, version, since, updated,
100
+ days: { "YYYY-MM-DD": { "<model>": { covered: {…}, metered: {…} } } } }`.
101
+
102
+ ## Pricing on the ChatGPT leg
103
+
104
+ Before 6.6 a `gpt-*` row fell through to the Claude fallback rate and the
105
+ "would-be API cost" of a ChatGPT-plan request was Anthropic's Sonnet 4.6
106
+ price for a model Anthropic does not sell. `OPENAI_PRICING` carries OpenAI's
107
+ published standard-tier rates for the models the codex backend serves, read
108
+ off `developers.openai.com/api/docs/pricing` on 2026-09-11. OpenAI charges
109
+ nothing to write a cache entry, so cache-write tokens (which the codex path
110
+ never reports anyway) are priced at the input rate. Unknown `gpt-*` ids take
111
+ gpt-5.6-terra's rate, dario's default codex model.
112
+
113
+ `scripts/check-pricing-drift.mjs` watches the Claude table against
114
+ Anthropic's page. Nothing watches the OpenAI table yet — an entry that is
115
+ correct today goes wrong the moment OpenAI changes it, and the only signal
116
+ would be this number moving.
@@ -161,6 +161,32 @@ its own answer; the other subscription takes over only when that model cannot
161
161
  serve the resume. dario warns loudly at startup while the tap is set; it is a
162
162
  demo and test affordance, never a default.
163
163
 
164
+ ## Seeing it after the fact
165
+
166
+ A continuation leaves three traces. The SSE comment on the wire
167
+ (`: dario continuation gpt-5.6-terra (codex live) after 1204 chars`) is the
168
+ one a raw capture shows; every SSE parser ignores it. The request log
169
+ (`--log-file`) tells the hop-by-hop story: the row of a request whose stream
170
+ died carries `continued` (`continued`, `continued-unfinished`,
171
+ `resume-failed`, `no-target`), `continued_by` (the leg that served the rest)
172
+ and `continued_after` (characters the client already had); every resume leg
173
+ has a row of its own with `continuation_depth` (1, or 2 for the resume of a
174
+ resume) and `continuation_of`, the number of the request it resumed — and,
175
+ when it died too, its own `continued_*` fields. A two-hop resume is three
176
+ rows that point at each other. `/analytics` tallies per window under
177
+ `continuations`: `attempted`, split into `finished`, `unfinished`, `failed`
178
+ and `noTarget`. Only the client's own request counts there — a resume leg is
179
+ a loopback request with a guard of its own, and counting its attempt too
180
+ would show one dying stream as two. `dario usage` prints the tally as one
181
+ line when anything died:
182
+
183
+ ```
184
+ Continuations: 3 streams died mid-answer: 2 finished, 1 unfinished
185
+ ```
186
+
187
+ `noTarget` above zero is the line to act on: streams are dying and there is
188
+ no `--pool-fallback` entry for the other provider to finish them.
189
+
164
190
  ## How it was proven
165
191
 
166
192
  `test/midstream-continuation-wiring.mjs` runs a real proxy against a fake
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/dario",
3
- "version": "6.5.0",
3
+ "version": "6.6.1",
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": {