@askalf/dario 6.5.0 → 6.6.0
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 +38 -3
- package/dist/analytics.js +57 -8
- package/dist/cli.js +61 -4
- package/dist/ledger.d.ts +175 -0
- package/dist/ledger.js +411 -0
- package/dist/proxy.d.ts +8 -0
- package/dist/proxy.js +28 -2
- 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/package.json +1 -1
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 · ~
|
|
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` |
|
|
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).
|
package/dist/analytics.d.ts
CHANGED
|
@@ -139,7 +139,7 @@ export declare function consumerFromBody(body: Record<string, unknown> | null |
|
|
|
139
139
|
* the proxy on every transient non-200/stream-abort.
|
|
140
140
|
*/
|
|
141
141
|
export declare function isNonSubscriptionBilling(claim: string | null | undefined): boolean;
|
|
142
|
-
interface Rate {
|
|
142
|
+
export interface Rate {
|
|
143
143
|
input: number;
|
|
144
144
|
output: number;
|
|
145
145
|
cacheRead: number;
|
|
@@ -168,15 +168,50 @@ interface PricingEntry extends Rate {
|
|
|
168
168
|
* is why the watcher exists (#1048).
|
|
169
169
|
*/
|
|
170
170
|
export declare const PRICING: Record<string, PricingEntry>;
|
|
171
|
+
/**
|
|
172
|
+
* OpenAI's published per-1M-token rates for the models the codex backend
|
|
173
|
+
* serves, standard tier, read off developers.openai.com/api/docs/pricing on
|
|
174
|
+
* 2026-09-11. Kept apart from PRICING because scripts/check-pricing-drift.mjs
|
|
175
|
+
* diffs that table against Anthropic's page and would report every row here
|
|
176
|
+
* as "absent upstream". OpenAI charges nothing to write a cache entry, so
|
|
177
|
+
* cacheCreate is the input rate (the codex path reports no cache writes
|
|
178
|
+
* anyway — `cached_tokens` lands in cacheReadTokens, the rest in inputTokens).
|
|
179
|
+
*
|
|
180
|
+
* Before this table every `gpt-*` row was priced at the sonnet-4-6 fallback:
|
|
181
|
+
* a ChatGPT-plan request showed up in "would-be API cost" at Anthropic's
|
|
182
|
+
* rate for a model Anthropic does not sell. Nothing watches this table yet.
|
|
183
|
+
*/
|
|
184
|
+
export declare const OPENAI_PRICING: Record<string, Rate>;
|
|
185
|
+
export type PricingProvider = 'anthropic' | 'openai';
|
|
186
|
+
/**
|
|
187
|
+
* Which price list a model id belongs to. Every id the codex backend serves
|
|
188
|
+
* starts `gpt-`; the rest of the pattern covers the older OpenAI families a
|
|
189
|
+
* `--model-alias` might name. Anything else is priced as Claude.
|
|
190
|
+
*/
|
|
191
|
+
export declare function providerOfModel(model: string): PricingProvider;
|
|
171
192
|
/**
|
|
172
193
|
* The per-1M-token rate for `model` in effect at `atMs` (epoch ms): the intro
|
|
173
194
|
* rate while within its window, otherwise the standard rate. A trailing context
|
|
174
195
|
* tag (`claude-sonnet-5[1m]`, `claude-opus-4-7[1m]`) is stripped before lookup —
|
|
175
196
|
* the [1m] ids used to fall through to the sonnet fallback and bill at the wrong
|
|
176
|
-
* family's rate
|
|
177
|
-
*
|
|
197
|
+
* family's rate — and so are an effort suffix (`gpt-5.6-terra:high`) and the
|
|
198
|
+
* dated form the response echoes (`claude-haiku-4-5-20251001`, which priced
|
|
199
|
+
* at the sonnet fallback until the ledger's first live run caught it).
|
|
200
|
+
* Unknown Claude models fall back to the sonnet-4-6 rate, unknown OpenAI
|
|
201
|
+
* models to gpt-5.6-terra's. Exported for tests.
|
|
178
202
|
*/
|
|
179
203
|
export declare function pricingRateFor(model: string, atMs: number): Rate;
|
|
204
|
+
/**
|
|
205
|
+
* USD the four token buckets would bill at `model`'s rate in effect at `atMs`.
|
|
206
|
+
* The ledger prices its per-day rows through this too, so a pricing
|
|
207
|
+
* correction reprices history instead of freezing the old number in.
|
|
208
|
+
*/
|
|
209
|
+
export declare function costOfTokens(model: string, atMs: number, t: {
|
|
210
|
+
inputTokens: number;
|
|
211
|
+
outputTokens: number;
|
|
212
|
+
cacheReadTokens: number;
|
|
213
|
+
cacheCreateTokens: number;
|
|
214
|
+
}): number;
|
|
180
215
|
export declare class Analytics extends EventEmitter {
|
|
181
216
|
private records;
|
|
182
217
|
private maxRecords;
|
package/dist/analytics.js
CHANGED
|
@@ -206,16 +206,57 @@ export const PRICING = {
|
|
|
206
206
|
// LOW. Found by check-pricing-drift.mjs on its very first run (#1048).
|
|
207
207
|
'claude-haiku-4-5': { input: 1, output: 5, cacheRead: 0.1, cacheCreate: 1.25 },
|
|
208
208
|
};
|
|
209
|
+
/**
|
|
210
|
+
* OpenAI's published per-1M-token rates for the models the codex backend
|
|
211
|
+
* serves, standard tier, read off developers.openai.com/api/docs/pricing on
|
|
212
|
+
* 2026-09-11. Kept apart from PRICING because scripts/check-pricing-drift.mjs
|
|
213
|
+
* diffs that table against Anthropic's page and would report every row here
|
|
214
|
+
* as "absent upstream". OpenAI charges nothing to write a cache entry, so
|
|
215
|
+
* cacheCreate is the input rate (the codex path reports no cache writes
|
|
216
|
+
* anyway — `cached_tokens` lands in cacheReadTokens, the rest in inputTokens).
|
|
217
|
+
*
|
|
218
|
+
* Before this table every `gpt-*` row was priced at the sonnet-4-6 fallback:
|
|
219
|
+
* a ChatGPT-plan request showed up in "would-be API cost" at Anthropic's
|
|
220
|
+
* rate for a model Anthropic does not sell. Nothing watches this table yet.
|
|
221
|
+
*/
|
|
222
|
+
export const OPENAI_PRICING = {
|
|
223
|
+
'gpt-6-astra': { input: 10, output: 50, cacheRead: 1, cacheCreate: 10 },
|
|
224
|
+
'gpt-5.6-sol': { input: 4, output: 20, cacheRead: 0.4, cacheCreate: 4 },
|
|
225
|
+
'gpt-5.6-terra': { input: 2, output: 12, cacheRead: 0.2, cacheCreate: 2 },
|
|
226
|
+
'gpt-5.6-luna': { input: 0.2, output: 1.2, cacheRead: 0.02, cacheCreate: 0.2 },
|
|
227
|
+
'gpt-5.5': { input: 5, output: 30, cacheRead: 0.5, cacheCreate: 5 },
|
|
228
|
+
'gpt-5.4': { input: 2.5, output: 15, cacheRead: 0.25, cacheCreate: 2.5 },
|
|
229
|
+
'gpt-5.4-mini': { input: 0.75, output: 4.5, cacheRead: 0.075, cacheCreate: 0.75 },
|
|
230
|
+
'gpt-5.4-nano': { input: 0.2, output: 1.25, cacheRead: 0.02, cacheCreate: 0.2 },
|
|
231
|
+
'gpt-5.3-codex': { input: 1.75, output: 14, cacheRead: 0.175, cacheCreate: 1.75 },
|
|
232
|
+
};
|
|
233
|
+
/** The unknown-model rate on the OpenAI side: dario's default codex model. */
|
|
234
|
+
const OPENAI_FALLBACK_MODEL = 'gpt-5.6-terra';
|
|
235
|
+
/**
|
|
236
|
+
* Which price list a model id belongs to. Every id the codex backend serves
|
|
237
|
+
* starts `gpt-`; the rest of the pattern covers the older OpenAI families a
|
|
238
|
+
* `--model-alias` might name. Anything else is priced as Claude.
|
|
239
|
+
*/
|
|
240
|
+
export function providerOfModel(model) {
|
|
241
|
+
return /^(gpt-|o\d|codex|chatgpt)/i.test(model) ? 'openai' : 'anthropic';
|
|
242
|
+
}
|
|
209
243
|
/**
|
|
210
244
|
* The per-1M-token rate for `model` in effect at `atMs` (epoch ms): the intro
|
|
211
245
|
* rate while within its window, otherwise the standard rate. A trailing context
|
|
212
246
|
* tag (`claude-sonnet-5[1m]`, `claude-opus-4-7[1m]`) is stripped before lookup —
|
|
213
247
|
* the [1m] ids used to fall through to the sonnet fallback and bill at the wrong
|
|
214
|
-
* family's rate
|
|
215
|
-
*
|
|
248
|
+
* family's rate — and so are an effort suffix (`gpt-5.6-terra:high`) and the
|
|
249
|
+
* dated form the response echoes (`claude-haiku-4-5-20251001`, which priced
|
|
250
|
+
* at the sonnet fallback until the ledger's first live run caught it).
|
|
251
|
+
* Unknown Claude models fall back to the sonnet-4-6 rate, unknown OpenAI
|
|
252
|
+
* models to gpt-5.6-terra's. Exported for tests.
|
|
216
253
|
*/
|
|
217
254
|
export function pricingRateFor(model, atMs) {
|
|
218
|
-
const baseModel = model.replace(/\[[^\]]*\]$/, '');
|
|
255
|
+
const baseModel = model.replace(/\[[^\]]*\]$/, '').replace(/:[a-z]+$/i, '').replace(/-\d{8}$/, '');
|
|
256
|
+
if (providerOfModel(baseModel) === 'openai') {
|
|
257
|
+
const rate = OPENAI_PRICING[baseModel] ?? OPENAI_PRICING[OPENAI_FALLBACK_MODEL];
|
|
258
|
+
return { ...rate };
|
|
259
|
+
}
|
|
219
260
|
const entry = PRICING[baseModel] ?? PRICING['claude-sonnet-4-6'];
|
|
220
261
|
if (entry.intro && atMs <= Date.parse(`${entry.intro.until}T23:59:59.999Z`)) {
|
|
221
262
|
const { until: _until, ...introRate } = entry.intro;
|
|
@@ -223,16 +264,24 @@ export function pricingRateFor(model, atMs) {
|
|
|
223
264
|
}
|
|
224
265
|
return { input: entry.input, output: entry.output, cacheRead: entry.cacheRead, cacheCreate: entry.cacheCreate };
|
|
225
266
|
}
|
|
267
|
+
/**
|
|
268
|
+
* USD the four token buckets would bill at `model`'s rate in effect at `atMs`.
|
|
269
|
+
* The ledger prices its per-day rows through this too, so a pricing
|
|
270
|
+
* correction reprices history instead of freezing the old number in.
|
|
271
|
+
*/
|
|
272
|
+
export function costOfTokens(model, atMs, t) {
|
|
273
|
+
const p = pricingRateFor(model, atMs);
|
|
274
|
+
return ((t.inputTokens * p.input) +
|
|
275
|
+
(t.outputTokens * p.output) +
|
|
276
|
+
(t.cacheReadTokens * p.cacheRead) +
|
|
277
|
+
(t.cacheCreateTokens * p.cacheCreate)) / 1_000_000;
|
|
278
|
+
}
|
|
226
279
|
function estimateCost(record) {
|
|
227
280
|
// Price each record at the rate effective at ITS OWN timestamp, so a window
|
|
228
281
|
// that spans a pricing cutover (no model currently has one — Sonnet 5's
|
|
229
282
|
// scheduled increase was cancelled and its $2/$10 made permanent)
|
|
230
283
|
// estimates each side correctly rather than repricing history at today's rate.
|
|
231
|
-
|
|
232
|
-
return ((record.inputTokens * p.input) +
|
|
233
|
-
(record.outputTokens * p.output) +
|
|
234
|
-
(record.cacheReadTokens * p.cacheRead) +
|
|
235
|
-
(record.cacheCreateTokens * p.cacheCreate)) / 1_000_000;
|
|
284
|
+
return costOfTokens(record.model, record.timestamp, record);
|
|
236
285
|
}
|
|
237
286
|
export class Analytics extends EventEmitter {
|
|
238
287
|
records = [];
|
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).');
|
package/dist/ledger.d.ts
ADDED
|
@@ -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;
|
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/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;
|
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';
|
|
@@ -1445,6 +1446,19 @@ export async function startProxy(opts = {}) {
|
|
|
1445
1446
|
// : null` — that gated the /analytics endpoint, but burn-rate /
|
|
1446
1447
|
// per-request visibility is useful for a pool of one too.
|
|
1447
1448
|
const analytics = new Analytics();
|
|
1449
|
+
// The lifetime ledger rides the same record stream analytics emits, so
|
|
1450
|
+
// every site that records a request feeds it without a second call. Off,
|
|
1451
|
+
// /analytics reports `lifetime: null`.
|
|
1452
|
+
const ledgerOn = opts.ledger !== false && !ledgerDisabledByEnv();
|
|
1453
|
+
const ledger = ledgerOn ? await Ledger.open(resolveLedgerPath(port), (line) => console.log(line)) : null;
|
|
1454
|
+
if (ledger) {
|
|
1455
|
+
analytics.on('record', (r) => { ledger.add(r); });
|
|
1456
|
+
if (verbose)
|
|
1457
|
+
console.log(`[dario] ledger: ${ledger.path}`);
|
|
1458
|
+
}
|
|
1459
|
+
else {
|
|
1460
|
+
console.log('[dario] ledger: disabled (--no-ledger)');
|
|
1461
|
+
}
|
|
1448
1462
|
// Per-alias request counts for GET /codex — the pool has requestCount per
|
|
1449
1463
|
// account; the codex accounts had nothing until now.
|
|
1450
1464
|
const codexRequestCounts = new Map();
|
|
@@ -2569,7 +2583,19 @@ export async function startProxy(opts = {}) {
|
|
|
2569
2583
|
// `queue` rides along the summary (dario#905): request-queue.ts always
|
|
2570
2584
|
// documented snapshot() as "exposed for /analytics", but it was never
|
|
2571
2585
|
// actually wired in, so slot exhaustion was invisible from outside.
|
|
2572
|
-
res.end(JSON.stringify({ ...analytics.summary(), queue: queue.snapshot() }));
|
|
2586
|
+
res.end(JSON.stringify({ ...analytics.summary(), queue: queue.snapshot(), lifetime: ledger ? ledger.summary() : null }));
|
|
2587
|
+
return;
|
|
2588
|
+
}
|
|
2589
|
+
// The ledger's per-day table, for anyone charting it. `lifetime` on
|
|
2590
|
+
// /analytics is the summary; this is the data behind it.
|
|
2591
|
+
if (urlPath === '/analytics/ledger' && req.method === 'GET') {
|
|
2592
|
+
if (!ledger) {
|
|
2593
|
+
res.writeHead(404, JSON_HEADERS);
|
|
2594
|
+
res.end(JSON.stringify({ error: 'ledger disabled', hint: 'start without --no-ledger / DARIO_LEDGER=0' }));
|
|
2595
|
+
return;
|
|
2596
|
+
}
|
|
2597
|
+
res.writeHead(200, JSON_HEADERS);
|
|
2598
|
+
res.end(JSON.stringify({ path: ledger.path, ...ledger.snapshot() }));
|
|
2573
2599
|
return;
|
|
2574
2600
|
}
|
|
2575
2601
|
// Analytics live stream — SSE of new RequestRecord JSON, one event
|
|
@@ -5532,7 +5558,7 @@ export async function startProxy(opts = {}) {
|
|
|
5532
5558
|
// Flush tokens first (best-effort, bounded), then close the server. The
|
|
5533
5559
|
// flush is fire-and-forget under the same 5s force-exit guard below so a
|
|
5534
5560
|
// hung fsync can't wedge shutdown.
|
|
5535
|
-
void flushPoolTokens().finally(() => {
|
|
5561
|
+
void Promise.all([flushPoolTokens(), ledger?.close()]).finally(() => {
|
|
5536
5562
|
server.close(() => process.exit(0));
|
|
5537
5563
|
});
|
|
5538
5564
|
// 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
|
-
//
|
|
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.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@askalf/dario",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.6.0",
|
|
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": {
|