@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/ledger.js ADDED
@@ -0,0 +1,411 @@
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 { readFile, mkdir, rename } from 'node:fs/promises';
29
+ import { dirname, join } from 'node:path';
30
+ import { homedir } from 'node:os';
31
+ import { durableWriteFile } from './durable-write.js';
32
+ import { billingBucketFromClaim, costOfTokens, providerOfModel } from './analytics.js';
33
+ export const LEDGER_VERSION = 1;
34
+ /** The default proxy port; any other port gets its own ledger file. */
35
+ const DEFAULT_PORT = 3456;
36
+ /** Days kept before the oldest roll off — two years at one row per model per day. */
37
+ export const LEDGER_MAX_DAYS = 730;
38
+ /** How long after the last record the file is rewritten. */
39
+ export const LEDGER_FLUSH_DELAY_MS = 3_000;
40
+ export function ledgerPathFor(port, home = homedir()) {
41
+ return join(home, '.dario', port === DEFAULT_PORT ? 'ledger.json' : `ledger-${port}.json`);
42
+ }
43
+ /**
44
+ * `DARIO_LEDGER_PATH` names the file; `DARIO_LEDGER=0` (or `--no-ledger`)
45
+ * turns the ledger off. Off, /analytics reports `lifetime: null` and the
46
+ * usage command says so.
47
+ */
48
+ export function resolveLedgerPath(port, env = process.env) {
49
+ const explicit = env['DARIO_LEDGER_PATH'];
50
+ return explicit && explicit.trim().length > 0 ? explicit.trim() : ledgerPathFor(port);
51
+ }
52
+ export function ledgerDisabledByEnv(env = process.env) {
53
+ return ['0', 'false', 'no', 'off'].includes((env['DARIO_LEDGER'] ?? '').toLowerCase());
54
+ }
55
+ const emptyCell = () => ({ requests: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0 });
56
+ export function emptyLedger(now = Date.now()) {
57
+ const iso = new Date(now).toISOString();
58
+ return { version: LEDGER_VERSION, since: iso, updated: iso, days: {} };
59
+ }
60
+ /** UTC calendar day of an epoch-ms timestamp. */
61
+ export function dayKey(atMs) {
62
+ return new Date(atMs).toISOString().slice(0, 10);
63
+ }
64
+ /** Noon UTC of a day key — inside any intro window that ends that day. */
65
+ function dayMs(day) {
66
+ return Date.parse(`${day}T12:00:00Z`);
67
+ }
68
+ /**
69
+ * Which bucket a record lands in, or null when it should not be counted.
70
+ * `api` and `extra_usage` are metered; every subscription claim, the codex
71
+ * claim, and an absent claim on a 2xx (stream aborts, api-key mode without
72
+ * the header) are covered — the request was served, and nothing says it was
73
+ * billed per token.
74
+ */
75
+ export function ledgerBucketFor(record) {
76
+ if (record.status < 200 || record.status >= 300)
77
+ return null;
78
+ const bucket = billingBucketFromClaim(record.claim);
79
+ return bucket === 'api' || bucket === 'extra_usage' ? 'metered' : 'covered';
80
+ }
81
+ function isCell(v) {
82
+ if (!v || typeof v !== 'object')
83
+ return false;
84
+ const c = v;
85
+ return ['requests', 'inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheCreateTokens']
86
+ .every((k) => typeof c[k] === 'number' && Number.isFinite(c[k]) && c[k] >= 0);
87
+ }
88
+ /**
89
+ * Parse a ledger file's text, keeping only well-formed rows. A file that is
90
+ * not a ledger at all throws; the caller moves it aside and starts fresh.
91
+ */
92
+ export function parseLedger(text) {
93
+ const raw = JSON.parse(text);
94
+ if (!raw || typeof raw !== 'object' || raw.version !== LEDGER_VERSION || !raw.days || typeof raw.days !== 'object') {
95
+ throw new Error('not a dario ledger');
96
+ }
97
+ const days = {};
98
+ for (const [day, models] of Object.entries(raw.days)) {
99
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(day) || !models || typeof models !== 'object')
100
+ continue;
101
+ const clean = {};
102
+ for (const [model, row] of Object.entries(models)) {
103
+ if (!row || typeof row !== 'object')
104
+ continue;
105
+ const r = {};
106
+ if (isCell(row.covered))
107
+ r.covered = { ...row.covered };
108
+ if (isCell(row.metered))
109
+ r.metered = { ...row.metered };
110
+ if (r.covered || r.metered)
111
+ clean[model] = r;
112
+ }
113
+ if (Object.keys(clean).length > 0)
114
+ days[day] = clean;
115
+ }
116
+ const since = typeof raw.since === 'string' && !Number.isNaN(Date.parse(raw.since)) ? raw.since : new Date().toISOString();
117
+ const updated = typeof raw.updated === 'string' && !Number.isNaN(Date.parse(raw.updated)) ? raw.updated : since;
118
+ return { version: LEDGER_VERSION, since, updated, days };
119
+ }
120
+ /** Add one record's tokens to the file in place. Returns false when it was not counted. */
121
+ export function addToLedger(file, record) {
122
+ const bucket = ledgerBucketFor(record);
123
+ if (!bucket)
124
+ return false;
125
+ const day = dayKey(record.timestamp);
126
+ const model = record.model || 'unknown';
127
+ const models = (file.days[day] ??= {});
128
+ const row = (models[model] ??= {});
129
+ const cell = (row[bucket] ??= emptyCell());
130
+ cell.requests += 1;
131
+ cell.inputTokens += record.inputTokens;
132
+ cell.outputTokens += record.outputTokens;
133
+ cell.cacheReadTokens += record.cacheReadTokens;
134
+ cell.cacheCreateTokens += record.cacheCreateTokens;
135
+ if (Date.parse(file.since) > record.timestamp)
136
+ file.since = new Date(record.timestamp).toISOString();
137
+ pruneLedger(file);
138
+ return true;
139
+ }
140
+ /** Drop the oldest days past LEDGER_MAX_DAYS. */
141
+ export function pruneLedger(file, maxDays = LEDGER_MAX_DAYS) {
142
+ const days = Object.keys(file.days).sort();
143
+ for (const day of days.slice(0, Math.max(0, days.length - maxDays)))
144
+ delete file.days[day];
145
+ }
146
+ // Six places, not the window's four: a handful of gpt-5.6-luna requests is
147
+ // real money in the millionths and "$0 for 2 requests" reads as free.
148
+ const round = (usd) => Math.round(usd * 1_000_000) / 1_000_000;
149
+ export function summarizeLedger(file, path, now = Date.now()) {
150
+ const today = dayKey(now);
151
+ const cutoff7 = dayKey(now - 6 * 86_400_000);
152
+ const cutoff30 = dayKey(now - 29 * 86_400_000);
153
+ const perModel = {};
154
+ const perProvider = {
155
+ anthropic: { requests: 0, apiEquivalentCost: 0 },
156
+ openai: { requests: 0, apiEquivalentCost: 0 },
157
+ };
158
+ const tokens = { input: 0, output: 0, cacheRead: 0, cacheCreate: 0 };
159
+ let requests = 0;
160
+ let covered = 0;
161
+ let metered = 0;
162
+ const recent = { today: 0, last7d: 0, last30d: 0 };
163
+ for (const [day, models] of Object.entries(file.days)) {
164
+ const at = dayMs(day);
165
+ for (const [model, row] of Object.entries(models)) {
166
+ const m = (perModel[model] ??= {
167
+ provider: providerOfModel(model), requests: 0,
168
+ inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0,
169
+ apiEquivalentCost: 0, meteredCost: 0,
170
+ });
171
+ if (row.covered) {
172
+ const cost = costOfTokens(model, at, row.covered);
173
+ covered += cost;
174
+ m.apiEquivalentCost += cost;
175
+ m.requests += row.covered.requests;
176
+ m.inputTokens += row.covered.inputTokens;
177
+ m.outputTokens += row.covered.outputTokens;
178
+ m.cacheReadTokens += row.covered.cacheReadTokens;
179
+ m.cacheCreateTokens += row.covered.cacheCreateTokens;
180
+ tokens.input += row.covered.inputTokens;
181
+ tokens.output += row.covered.outputTokens;
182
+ tokens.cacheRead += row.covered.cacheReadTokens;
183
+ tokens.cacheCreate += row.covered.cacheCreateTokens;
184
+ perProvider[m.provider].requests += row.covered.requests;
185
+ perProvider[m.provider].apiEquivalentCost += cost;
186
+ requests += row.covered.requests;
187
+ if (day === today)
188
+ recent.today += cost;
189
+ if (day >= cutoff7)
190
+ recent.last7d += cost;
191
+ if (day >= cutoff30)
192
+ recent.last30d += cost;
193
+ }
194
+ if (row.metered) {
195
+ const cost = costOfTokens(model, at, row.metered);
196
+ metered += cost;
197
+ m.meteredCost += cost;
198
+ m.requests += row.metered.requests;
199
+ requests += row.metered.requests;
200
+ }
201
+ }
202
+ }
203
+ for (const m of Object.values(perModel)) {
204
+ m.apiEquivalentCost = round(m.apiEquivalentCost);
205
+ m.meteredCost = round(m.meteredCost);
206
+ }
207
+ for (const p of Object.values(perProvider))
208
+ p.apiEquivalentCost = round(p.apiEquivalentCost);
209
+ return {
210
+ path,
211
+ since: file.since,
212
+ days: Object.keys(file.days).length,
213
+ requests,
214
+ apiEquivalentCost: round(covered),
215
+ meteredCost: round(metered),
216
+ tokens,
217
+ perProvider,
218
+ perModel,
219
+ recent: { today: round(recent.today), last7d: round(recent.last7d), last30d: round(recent.last30d) },
220
+ };
221
+ }
222
+ /**
223
+ * Read a ledger file for display without a running proxy (`dario usage`
224
+ * when the proxy is down). Missing file → null; unreadable → null with the
225
+ * reason, never a throw.
226
+ */
227
+ export async function readLedgerFile(path) {
228
+ let text;
229
+ try {
230
+ text = await readFile(path, 'utf8');
231
+ }
232
+ catch (err) {
233
+ const code = err.code;
234
+ return code === 'ENOENT' ? { file: null } : { file: null, error: err.message };
235
+ }
236
+ try {
237
+ return { file: parseLedger(text) };
238
+ }
239
+ catch (err) {
240
+ return { file: null, error: err.message };
241
+ }
242
+ }
243
+ export class Ledger {
244
+ path;
245
+ log;
246
+ file;
247
+ dirty = false;
248
+ timer = null;
249
+ writing = Promise.resolve();
250
+ closed = false;
251
+ constructor(path, file, log) {
252
+ this.path = path;
253
+ this.log = log;
254
+ this.file = file;
255
+ }
256
+ /**
257
+ * Load the ledger at `path`, or start one. A file that cannot be parsed is
258
+ * moved aside (`<path>.corrupt-<ts>`) rather than overwritten, so a bad
259
+ * write never silently zeroes two years of history.
260
+ */
261
+ static async open(path, log = () => { }) {
262
+ const { file, error } = await readLedgerFile(path);
263
+ if (file)
264
+ return new Ledger(path, file, log);
265
+ if (error) {
266
+ const aside = `${path}.corrupt-${Date.now()}`;
267
+ try {
268
+ await rename(path, aside);
269
+ }
270
+ catch { /* best effort — the next flush overwrites */ }
271
+ log(`[dario] ledger: could not read ${path} (${error}); moved aside to ${aside}, starting fresh`);
272
+ }
273
+ return new Ledger(path, emptyLedger(), log);
274
+ }
275
+ /** Count a request. Returns false when it was not ledger material. */
276
+ add(record) {
277
+ if (this.closed)
278
+ return false;
279
+ const counted = addToLedger(this.file, record);
280
+ if (counted)
281
+ this.scheduleFlush();
282
+ return counted;
283
+ }
284
+ summary(now = Date.now()) {
285
+ return summarizeLedger(this.file, this.path, now);
286
+ }
287
+ /** The raw per-day table, for /analytics/ledger. */
288
+ snapshot() {
289
+ return JSON.parse(JSON.stringify(this.file));
290
+ }
291
+ scheduleFlush() {
292
+ this.dirty = true;
293
+ if (this.timer)
294
+ return;
295
+ this.timer = setTimeout(() => { this.timer = null; void this.flush(); }, LEDGER_FLUSH_DELAY_MS);
296
+ this.timer.unref();
297
+ }
298
+ /** Write now if anything changed. Serialized; a failure is logged, not thrown. */
299
+ flush() {
300
+ if (!this.dirty)
301
+ return this.writing;
302
+ this.dirty = false;
303
+ if (this.timer) {
304
+ clearTimeout(this.timer);
305
+ this.timer = null;
306
+ }
307
+ this.file.updated = new Date().toISOString();
308
+ const text = JSON.stringify(this.file);
309
+ this.writing = this.writing.then(async () => {
310
+ try {
311
+ await mkdir(dirname(this.path), { recursive: true, mode: 0o700 });
312
+ await durableWriteFile(this.path, text, 0o600);
313
+ }
314
+ catch (err) {
315
+ this.dirty = true;
316
+ this.log(`[dario] ledger: write to ${this.path} failed: ${err.message}`);
317
+ }
318
+ });
319
+ return this.writing;
320
+ }
321
+ /** Final flush for the shutdown hook. */
322
+ async close() {
323
+ await this.flush();
324
+ this.closed = true;
325
+ }
326
+ }
327
+ // ─────────────────────────────────────────────────────────────────────────
328
+ // Presentation — the number, formatted for a terminal and for a share card
329
+ // ─────────────────────────────────────────────────────────────────────────
330
+ export function formatUsd(usd) {
331
+ if (usd >= 100)
332
+ return `$${Math.round(usd).toLocaleString('en-US')}`;
333
+ if (usd >= 1)
334
+ return `$${usd.toFixed(2)}`;
335
+ if (usd === 0)
336
+ return '$0';
337
+ if (usd < 0.0001)
338
+ return '<$0.0001';
339
+ return `$${usd.toFixed(usd >= 0.01 ? 2 : 4)}`;
340
+ }
341
+ /** `claude-opus-5` → `Opus 5`, `claude-haiku-4-5-20251001` → `Haiku 4.5`, `gpt-5.6-terra` → `gpt-5.6-terra`. */
342
+ export function shortModelName(model) {
343
+ const m = /^claude-([a-z]+)-(\d+)(?:-(\d+))?(?:-\d{8})?(\[[^\]]*\])?$/i.exec(model);
344
+ if (!m)
345
+ return model;
346
+ const family = m[1].charAt(0).toUpperCase() + m[1].slice(1);
347
+ return `${family} ${m[2]}${m[3] ? `.${m[3]}` : ''}${m[4] ?? ''}`;
348
+ }
349
+ const providerLabel = { anthropic: 'Claude', openai: 'ChatGPT' };
350
+ /**
351
+ * The block `dario usage` prints above the rolling window. Two-space indent
352
+ * to match the rest of that command's output.
353
+ */
354
+ export function formatLedgerSummary(s) {
355
+ const since = s.since.slice(0, 10);
356
+ const lines = [];
357
+ lines.push(` API-equivalent spend (since ${since}, ${s.days} day${s.days === 1 ? '' : 's'}, ${s.requests.toLocaleString('en-US')} request${s.requests === 1 ? '' : 's'}):`);
358
+ lines.push(` ${formatUsd(s.apiEquivalentCost)} would have been billed on the metered API — covered by subscriptions`);
359
+ const providers = Object.entries(s.perProvider)
360
+ .filter(([, p]) => p.requests > 0)
361
+ .sort((a, b) => b[1].apiEquivalentCost - a[1].apiEquivalentCost);
362
+ for (const [provider, p] of providers) {
363
+ const models = Object.entries(s.perModel)
364
+ .filter(([, m]) => m.provider === provider && m.apiEquivalentCost > 0)
365
+ .sort((a, b) => b[1].apiEquivalentCost - a[1].apiEquivalentCost)
366
+ .slice(0, 4)
367
+ .map(([id, m]) => `${shortModelName(id)} ${formatUsd(m.apiEquivalentCost)}`);
368
+ lines.push(` ${providerLabel[provider].padEnd(8)} ${formatUsd(p.apiEquivalentCost).padStart(9)} ${p.requests.toLocaleString('en-US')} req${p.requests === 1 ? '' : 's'}${models.length > 0 ? ` (${models.join(' · ')})` : ''}`);
369
+ }
370
+ lines.push(` Today ${formatUsd(s.recent.today)} · Last 7d ${formatUsd(s.recent.last7d)} · Last 30d ${formatUsd(s.recent.last30d)}`);
371
+ if (s.meteredCost > 0)
372
+ lines.push(` Paid per token on top (API key / extra usage): ${formatUsd(s.meteredCost)}`);
373
+ return lines;
374
+ }
375
+ const escapeXml = (s) => s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
376
+ /**
377
+ * A share card: one SVG, 640×320, dark, the number in the middle. Plain
378
+ * system monospace so it renders the same in a README, a tweet screenshot
379
+ * and an <img> tag with nothing to fetch.
380
+ */
381
+ export function renderLedgerCard(s) {
382
+ const providers = Object.entries(s.perProvider)
383
+ .filter(([, p]) => p.requests > 0)
384
+ .sort((a, b) => b[1].apiEquivalentCost - a[1].apiEquivalentCost)
385
+ .map(([provider, p]) => `${providerLabel[provider]} ${formatUsd(p.apiEquivalentCost)}`)
386
+ .join(' · ');
387
+ const since = s.since.slice(0, 10);
388
+ const headline = formatUsd(s.apiEquivalentCost);
389
+ const size = headline.length > 9 ? 56 : headline.length > 7 ? 68 : 80;
390
+ const meta = `${s.requests.toLocaleString('en-US')} requests · since ${since} · ${s.days} day${s.days === 1 ? '' : 's'}`;
391
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="640" height="320" viewBox="0 0 640 320" role="img" aria-label="${escapeXml(headline)} of API-equivalent usage covered by subscriptions through dario">
392
+ <defs>
393
+ <linearGradient id="accent" x1="0" y1="0" x2="1" y2="0">
394
+ <stop offset="0" stop-color="#7c3aed"/>
395
+ <stop offset="1" stop-color="#db2777"/>
396
+ </linearGradient>
397
+ <clipPath id="card"><rect width="640" height="320" rx="20"/></clipPath>
398
+ </defs>
399
+ <rect width="640" height="320" rx="20" fill="#0a0a0f"/>
400
+ <rect x="0" y="0" width="640" height="6" fill="url(#accent)" clip-path="url(#card)"/>
401
+ <g font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace" fill="#e5e7eb">
402
+ <text x="40" y="66" font-size="15" fill="#9ca3af" letter-spacing="2">API-EQUIVALENT SPEND · COVERED BY SUBSCRIPTIONS</text>
403
+ <text x="40" y="160" font-size="${size}" font-weight="700" fill="#ffffff">${escapeXml(headline)}</text>
404
+ <text x="40" y="200" font-size="17" fill="#d1d5db">would have been billed on the metered API</text>
405
+ <text x="40" y="244" font-size="15" fill="#a78bfa">${escapeXml(providers)}</text>
406
+ <text x="40" y="284" font-size="13" fill="#6b7280">${escapeXml(meta)}</text>
407
+ <text x="600" y="284" font-size="13" fill="#6b7280" text-anchor="end">dario</text>
408
+ </g>
409
+ </svg>
410
+ `;
411
+ }
@@ -65,6 +65,14 @@ export declare const CONTINUATION_HEADER = "x-dario-continuation";
65
65
  export declare const MAX_CONTINUATION_DEPTH = 2;
66
66
  /** The depth a request carries, 0 for an ordinary client request. */
67
67
  export declare function continuationDepth(headerValue: string | string[] | undefined): number;
68
+ /**
69
+ * The loopback also names the request it resumes (the guard's request
70
+ * number), so the resume leg's own log row can point back at the row whose
71
+ * stream died. Log-side only: nothing routes on it.
72
+ */
73
+ export declare const CONTINUATION_OF_HEADER = "x-dario-continuation-of";
74
+ /** The request number a resume leg names, or undefined when absent / not a number. */
75
+ export declare function continuationOfRequest(headerValue: string | string[] | undefined): number | undefined;
68
76
  /** Characters of the partial the model is asked to repeat verbatim (the seam anchor). */
69
77
  export declare const ANCHOR_CHARS = 40;
70
78
  export interface SseFrame {
@@ -304,11 +312,24 @@ export interface MidstreamGuardOptions {
304
312
  log?: (line: string) => void;
305
313
  }
306
314
  export type FinishOutcome = 'clean' | 'continued' | 'continued-unfinished' | 'ended' | 'not-continuable' | 'no-target' | 'resume-failed';
315
+ /**
316
+ * What a continuation attempt came to, for the request's analytics row and
317
+ * log line: the four FinishOutcomes where the guard actually acted. A clean
318
+ * stream, one the client left, and one that was never continuable are not
319
+ * attempts and are not recorded.
320
+ */
321
+ export type ContinuationOutcome = 'continued' | 'continued-unfinished' | 'resume-failed' | 'no-target';
307
322
  export declare class MidstreamGuard {
308
323
  private readonly o;
309
324
  readonly state: ClientStreamState;
310
325
  private readonly splitter;
311
326
  private finished;
327
+ /** Set by finish(): the attempt's outcome, or null when the stream needed none. */
328
+ outcome: ContinuationOutcome | null;
329
+ /** The label of the leg that put content on the wire (`gpt-5.6-terra (codex)`, `claude-opus-5 (same model)`). */
330
+ continuedBy: string | null;
331
+ /** Characters the client had when the stream died — where the seam sits. */
332
+ partialChars: number;
312
333
  constructor(o: MidstreamGuardOptions);
313
334
  /**
314
335
  * The site knows the upstream turn failed (a codex `response.failed`, a
package/dist/midstream.js CHANGED
@@ -69,6 +69,20 @@ export function continuationDepth(headerValue) {
69
69
  const n = Number.parseInt(v, 10);
70
70
  return Number.isFinite(n) && n > 0 ? n : 1;
71
71
  }
72
+ /**
73
+ * The loopback also names the request it resumes (the guard's request
74
+ * number), so the resume leg's own log row can point back at the row whose
75
+ * stream died. Log-side only: nothing routes on it.
76
+ */
77
+ export const CONTINUATION_OF_HEADER = 'x-dario-continuation-of';
78
+ /** The request number a resume leg names, or undefined when absent / not a number. */
79
+ export function continuationOfRequest(headerValue) {
80
+ const v = Array.isArray(headerValue) ? headerValue[0] : headerValue;
81
+ if (v === undefined)
82
+ return undefined;
83
+ const n = Number.parseInt(v, 10);
84
+ return Number.isFinite(n) && n >= 0 ? n : undefined;
85
+ }
72
86
  /** Characters of the partial the model is asked to repeat verbatim (the seam anchor). */
73
87
  export const ANCHOR_CHARS = 40;
74
88
  /** Upper bound on continuation text held back while looking for the anchor. */
@@ -702,6 +716,12 @@ export class MidstreamGuard {
702
716
  state;
703
717
  splitter = new SseFrameSplitter();
704
718
  finished = false;
719
+ /** Set by finish(): the attempt's outcome, or null when the stream needed none. */
720
+ outcome = null;
721
+ /** The label of the leg that put content on the wire (`gpt-5.6-terra (codex)`, `claude-opus-5 (same model)`). */
722
+ continuedBy = null;
723
+ /** Characters the client had when the stream died — where the seam sits. */
724
+ partialChars = 0;
705
725
  constructor(o) {
706
726
  this.o = o;
707
727
  this.state = new ClientStreamState(o.shape);
@@ -762,6 +782,7 @@ export class MidstreamGuard {
762
782
  // dead before its first byte) hands over to the next; the first one that
763
783
  // puts content on the wire ends the search, finished or not.
764
784
  const partial = s.textSoFar;
785
+ this.partialChars = partial.length;
765
786
  let tried = 0;
766
787
  for (let choice = (this.o.depth ?? 0) + 1; choice <= MAX_CONTINUATION_DEPTH; choice++) {
767
788
  let target = null;
@@ -781,15 +802,19 @@ export class MidstreamGuard {
781
802
  continue;
782
803
  }
783
804
  this.o.end();
784
- return outcome === 'finished' ? 'continued' : 'continued-unfinished';
805
+ this.continuedBy = target.label;
806
+ this.outcome = outcome === 'finished' ? 'continued' : 'continued-unfinished';
807
+ return this.outcome;
785
808
  }
786
809
  if (tried === 0) {
787
810
  this.log(`#${this.o.requestNo} stream died after ${partial.length} chars — no continuation target (set --pool-fallback with an entry for the other provider)`);
788
811
  cleanEnd();
789
- return 'no-target';
812
+ this.outcome = 'no-target';
813
+ return this.outcome;
790
814
  }
791
815
  cleanEnd();
792
- return 'resume-failed';
816
+ this.outcome = 'resume-failed';
817
+ return this.outcome;
793
818
  }
794
819
  /**
795
820
  * 'failed': nothing of the resume reached the client — the site ends the
@@ -817,7 +842,7 @@ export class MidstreamGuard {
817
842
  r.onBeforeResume?.();
818
843
  const res = await fetchImpl(`${r.loopbackBase}${path}`, {
819
844
  method: 'POST',
820
- headers: { 'content-type': 'application/json', [CONTINUATION_HEADER]: String((this.o.depth ?? 0) + 1), ...r.loopbackHeaders },
845
+ headers: { 'content-type': 'application/json', [CONTINUATION_HEADER]: String((this.o.depth ?? 0) + 1), [CONTINUATION_OF_HEADER]: String(this.o.requestNo), ...r.loopbackHeaders },
821
846
  body: JSON.stringify(body),
822
847
  signal: abort.signal,
823
848
  });
package/dist/proxy.d.ts CHANGED
@@ -326,6 +326,14 @@ interface ProxyOptions {
326
326
  * it off; with no fallback chain it is inert and says so on the first miss.
327
327
  */
328
328
  midstreamContinue?: boolean;
329
+ /**
330
+ * Keep the lifetime ledger (v6.6, src/ledger.ts): per-day, per-model token
331
+ * totals on disk, priced at read time, so /analytics and `dario usage` can
332
+ * say what the traffic would have cost on the metered API since the first
333
+ * request — across restarts. On by default; `--no-ledger` /
334
+ * `DARIO_LEDGER=0` turns it off, `DARIO_LEDGER_PATH` moves the file.
335
+ */
336
+ ledger?: boolean;
329
337
  sessionIdleRotateMs?: number;
330
338
  sessionRotateJitterMs?: number;
331
339
  sessionMaxAgeMs?: number;
@@ -578,6 +586,15 @@ export interface ProxyLogEntry {
578
586
  client?: string;
579
587
  preserve_tools?: boolean;
580
588
  stream?: boolean;
589
+ /** Mid-stream continuation outcome, when this request's guard acted (see analytics RequestContinuation). */
590
+ continued?: 'continued' | 'continued-unfinished' | 'resume-failed' | 'no-target';
591
+ continued_by?: string;
592
+ /** Characters the client already had when the stream died. */
593
+ continued_after?: number;
594
+ /** On a resume leg: how deep it sits (1 = resume of a client request, 2 = resume of a resume). */
595
+ continuation_depth?: number;
596
+ /** On a resume leg: the request number whose stream it resumes (the guard's number). */
597
+ continuation_of?: number;
581
598
  reject?: string;
582
599
  error?: string;
583
600
  event?: string;