@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 +17 -3
- package/dist/analytics.d.ts +72 -3
- package/dist/analytics.js +82 -8
- package/dist/cli.js +74 -4
- package/dist/ledger.d.ts +175 -0
- package/dist/ledger.js +411 -0
- package/dist/midstream.d.ts +21 -0
- package/dist/midstream.js +29 -4
- package/dist/proxy.d.ts +17 -0
- package/dist/proxy.js +53 -3
- package/dist/tui/tabs/analytics.d.ts +8 -0
- package/dist/tui/tabs/analytics.js +11 -2
- package/docs/api-equivalent-spend.md +116 -0
- package/docs/midstream-continuation.md +26 -0
- package/package.json +1 -1
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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
|
+
}
|
package/dist/midstream.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
812
|
+
this.outcome = 'no-target';
|
|
813
|
+
return this.outcome;
|
|
790
814
|
}
|
|
791
815
|
cleanEnd();
|
|
792
|
-
|
|
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;
|