@askalf/dario 6.19.1 → 6.20.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/dist/cli.js CHANGED
@@ -19,7 +19,7 @@
19
19
  import { unlink, writeFile } from 'node:fs/promises';
20
20
  import { formatLedgerSummary, formatLedgerConsumers, formatUsd, renderLedgerCard, readLedgerFile, resolveLedgerPath, summarizeLedger } from './ledger.js';
21
21
  import { renderSpendDonuts } from './donuts.js';
22
- import { KeyStore, createKey, revokeKey, rotateKey, deleteKey, parseExpiry, publicKey, resolveKeysPath, KEY_NAME_RE, parseKeySocketSpec, setKeyBudget, parseUsdBudget, parseTokenBudget, formatBudget } from './keys.js';
22
+ import { KeyStore, createKey, revokeKey, rotateKey, deleteKey, parseExpiry, publicKey, resolveKeysPath, KEY_NAME_RE, parseKeySocketSpec, setKeyBudget, setKeyEffort, isKeyEffort, KEY_EFFORT_VALUES, parseUsdBudget, parseTokenBudget, formatBudget } from './keys.js';
23
23
  /**
24
24
  * `--budget=$5/day` / `--budget-tokens=2M/day` → a KeyBudget, or undefined when
25
25
  * neither flag is present. A flag that does not parse exits 1 with the accepted
@@ -875,9 +875,9 @@ async function keys() {
875
875
  return;
876
876
  }
877
877
  const w = Math.max(4, ...list.map((k) => k.name.length));
878
- console.log(` ${'NAME'.padEnd(w)} ${'STATUS'.padEnd(7)} ${'SEAT'.padEnd(12)} ${'LAST USED'.padEnd(10)} ${'EXPIRES'.padEnd(10)} ${'BUDGET'.padEnd(20)} MODELS`);
878
+ console.log(` ${'NAME'.padEnd(w)} ${'STATUS'.padEnd(7)} ${'SEAT'.padEnd(12)} ${'EFFORT'.padEnd(6)} ${'LAST USED'.padEnd(10)} ${'EXPIRES'.padEnd(10)} ${'BUDGET'.padEnd(20)} MODELS`);
879
879
  for (const k of list) {
880
- console.log(` ${k.name.padEnd(w)} ${k.status.padEnd(7)} ${(k.seat ?? '-').padEnd(12)} ${fmtAgo(k.last_used).padEnd(10)} ${fmtDay(k.expires).padEnd(10)} ${formatBudget(k.budget ? { usdPerDay: k.budget.usd_per_day ?? undefined, tokensPerDay: k.budget.tokens_per_day ?? undefined } : null).padEnd(20)} ${k.models.length ? k.models.join(', ') : 'any'}`);
880
+ console.log(` ${k.name.padEnd(w)} ${k.status.padEnd(7)} ${(k.seat ?? '-').padEnd(12)} ${(k.effort ?? '-').padEnd(6)} ${fmtAgo(k.last_used).padEnd(10)} ${fmtDay(k.expires).padEnd(10)} ${formatBudget(k.budget ? { usdPerDay: k.budget.usd_per_day ?? undefined, tokensPerDay: k.budget.tokens_per_day ?? undefined } : null).padEnd(20)} ${k.models.length ? k.models.join(', ') : 'any'}`);
881
881
  }
882
882
  console.log('');
883
883
  console.log(` ${list.length} key${list.length === 1 ? '' : 's'} in ${path}. Spend per key: dario usage --by-key`);
@@ -888,7 +888,7 @@ async function keys() {
888
888
  const name = args[2];
889
889
  if (!name || name.startsWith('--')) {
890
890
  console.error('');
891
- console.error(' Usage: dario keys create <name> [--seat=<alias>] [--models=a,b,prefix*] [--expires=30d|12h|2w|<ISO date>] [--budget=$5/day] [--budget-tokens=2M/day]');
891
+ console.error(' Usage: dario keys create <name> [--seat=<alias>] [--models=a,b,prefix*] [--expires=30d|12h|2w|<ISO date>] [--budget=$5/day] [--budget-tokens=2M/day] [--effort=low|medium|high|xhigh|max]');
892
892
  console.error('');
893
893
  process.exit(1);
894
894
  }
@@ -907,8 +907,14 @@ async function keys() {
907
907
  expiresAt = parsed;
908
908
  }
909
909
  const budget = readBudgetFlags(args);
910
+ const effortArg = args.find((a) => a.startsWith('--effort='));
911
+ const effort = effortArg ? effortArg.slice('--effort='.length).trim().toLowerCase() : undefined;
912
+ if (effort !== undefined && !isKeyEffort(effort)) {
913
+ console.error(`[dario] --effort: "${effort}" is not one of ${KEY_EFFORT_VALUES.join(', ')}.`);
914
+ process.exit(1);
915
+ }
910
916
  try {
911
- const made = store.mutate((file) => createKey(file, name, { seat, models, expiresAt, budget, now }));
917
+ const made = store.mutate((file) => createKey(file, name, { seat, models, expiresAt, budget, effort, now }));
912
918
  printSecret('created', publicKey(made.record, now), made.secret);
913
919
  }
914
920
  catch (err) {
@@ -917,6 +923,34 @@ async function keys() {
917
923
  }
918
924
  return;
919
925
  }
926
+ // dario keys effort <name> low|medium|high|xhigh|max | --clear
927
+ if (sub === 'effort') {
928
+ const name = args[2];
929
+ const raw = args.includes('--clear') ? null : (args[3] ?? '').trim().toLowerCase();
930
+ if (!name || !KEY_NAME_RE.test(name) || (raw !== null && !isKeyEffort(raw))) {
931
+ console.error('');
932
+ console.error(` Usage: dario keys effort <name> ${KEY_EFFORT_VALUES.join('|')} | --clear`);
933
+ console.error('');
934
+ console.error(' The effort this key\'s requests run at when they name none themselves (no model-name');
935
+ console.error(' suffix, no effort field in the body). --effort / DARIO_EFFORT still wins over it.');
936
+ console.error('');
937
+ process.exit(1);
938
+ }
939
+ const level = raw !== null && isKeyEffort(raw) ? raw : null;
940
+ try {
941
+ const updated = store.mutate((file) => setKeyEffort(file, name, level));
942
+ if (!updated) {
943
+ console.error(`[dario] No key named "${name}".`);
944
+ process.exit(1);
945
+ }
946
+ console.log(level ? `[dario] Key "${name}" default effort: ${level}` : `[dario] Key "${name}" default effort cleared.`);
947
+ }
948
+ catch (err) {
949
+ console.error(`[dario] ${err instanceof Error ? err.message : String(err)}`);
950
+ process.exit(1);
951
+ }
952
+ return;
953
+ }
920
954
  // dario keys budget <name> --budget=$5/day --budget-tokens=2M/day | --clear
921
955
  if (sub === 'budget') {
922
956
  const name = args[2];
@@ -985,7 +1019,7 @@ async function keys() {
985
1019
  return;
986
1020
  }
987
1021
  console.error(`[dario] Unknown keys subcommand: ${sub}`);
988
- console.error('Usage: dario keys [list|create <name> [--seat=..] [--models=..] [--expires=..] [--budget=..] [--budget-tokens=..]|budget <name> ..|revoke <name>|rotate <name>|remove <name>] [--json] [--keys-path=<file>]');
1022
+ console.error('Usage: dario keys [list|create <name> [--seat=..] [--models=..] [--expires=..] [--budget=..] [--budget-tokens=..] [--effort=..]|budget <name> ..|effort <name> <level>|--clear|revoke <name>|rotate <name>|remove <name>] [--json] [--keys-path=<file>]');
989
1023
  process.exit(1);
990
1024
  }
991
1025
  /**
@@ -2025,8 +2059,8 @@ async function help() {
2025
2059
  miss. Needs \`dario proxy\` running; costs
2026
2060
  at most a few tiny subscription requests.
2027
2061
  dario doctor --json Emit the check report as structured JSON
2028
- for machine consumption (claude-bridge
2029
- /status, CI scripts, etc.) instead of the
2062
+ for machine consumption (status bots,
2063
+ CI scripts, etc.) instead of the
2030
2064
  human-readable table.
2031
2065
  dario doctor --auth-check
2032
2066
  One-shot listener on an ephemeral loopback
@@ -2759,8 +2793,8 @@ async function doctor() {
2759
2793
  }
2760
2794
  const checks = await runChecks({ probe, usage, obedience });
2761
2795
  if (asJson) {
2762
- // JSON mode is meant for machine consumption (claude-bridge /status,
2763
- // deepdive health checks, CI scripts) — no decorative header, no
2796
+ // JSON mode is meant for machine consumption (status bots, health
2797
+ // checks, CI scripts): no decorative header, no
2764
2798
  // trailing prose, stable shape. Exit code is also surfaced in the
2765
2799
  // JSON envelope for callers that can't read process exit codes.
2766
2800
  process.stdout.write(formatChecksJson(checks) + '\n');
@@ -108,7 +108,7 @@ export declare function formatChecks(checks: Check[]): string;
108
108
  export declare function exitCodeFor(checks: Check[]): number;
109
109
  /**
110
110
  * Serialize a check report as structured JSON. Lets other tools
111
- * (claude-bridge's /status command, deepdive, CI scripts) consume
111
+ * (chat-bot /status commands, health probes, CI scripts) consume
112
112
  * dario's health programmatically instead of scraping the formatted
113
113
  * text. Emitted by `dario doctor --json`.
114
114
  */
@@ -227,7 +227,7 @@ export function exitCodeFor(checks) {
227
227
  }
228
228
  /**
229
229
  * Serialize a check report as structured JSON. Lets other tools
230
- * (claude-bridge's /status command, deepdive, CI scripts) consume
230
+ * (chat-bot /status commands, health probes, CI scripts) consume
231
231
  * dario's health programmatically instead of scraping the formatted
232
232
  * text. Emitted by `dario doctor --json`.
233
233
  */
@@ -1019,7 +1019,7 @@ export async function runChecks(opts = {}) {
1019
1019
  }
1020
1020
  }
1021
1021
  // ---- Client-system obedience probe (opt-in, --obedience).
1022
- // dario#509 / the 2026-06-12 deepdive planner outage: Anthropic's serving
1022
+ // dario#509 / the 2026-06-12 downstream planner outage: Anthropic's serving
1023
1023
  // side changed how claude-sonnet-4-6 weighed client system text merged
1024
1024
  // after the CC persona in block 3 — wire bytes identical, every existing
1025
1025
  // check green — and the only symptom was a downstream consumer failing to
package/dist/effort.d.ts CHANGED
@@ -32,6 +32,24 @@ export declare const VALID_EFFORT_VALUES: ReadonlyArray<EffortValue>;
32
32
  * and the request's own thinking budget decides, as before.
33
33
  */
34
34
  export declare function effortForCodex(effort: EffortValue | undefined): 'low' | 'medium' | 'high' | 'xhigh' | 'max' | undefined;
35
+ /**
36
+ * Did the request name an effort of its own? Three places a client can say it:
37
+ * `output_config.effort` (Messages shape), `reasoning_effort` (chat completions),
38
+ * `reasoning.effort` (Responses). A named key's default effort fills in only when
39
+ * this is false, so a client that chose is never overruled by a default. The
40
+ * model-name suffix is checked separately by the caller (it is parsed off the
41
+ * model before this runs).
42
+ */
43
+ export declare function requestNamesEffort(body: Record<string, unknown> | null | undefined): boolean;
44
+ /**
45
+ * A Responses body for the codex passthrough with `reasoning.effort` filled in.
46
+ * Returned as is when there is no effort to add or when the client already
47
+ * named one (a non-empty `reasoning.effort`); otherwise a copy carries the
48
+ * level and keeps every other `reasoning` field, since Codex CLI sends
49
+ * `summary` there. The passthrough forwards the client's body as written, so
50
+ * this is the only place a suffix or a key default can reach that route.
51
+ */
52
+ export declare function withResponsesEffort(body: Record<string, unknown>, effort: 'low' | 'medium' | 'high' | 'xhigh' | 'max' | undefined): Record<string, unknown>;
35
53
  export declare function parseEffortSuffix(model: string): {
36
54
  model: string;
37
55
  effort?: EffortValue;
package/dist/effort.js CHANGED
@@ -52,6 +52,44 @@ export function effortForCodex(effort) {
52
52
  default: return undefined;
53
53
  }
54
54
  }
55
+ /**
56
+ * Did the request name an effort of its own? Three places a client can say it:
57
+ * `output_config.effort` (Messages shape), `reasoning_effort` (chat completions),
58
+ * `reasoning.effort` (Responses). A named key's default effort fills in only when
59
+ * this is false, so a client that chose is never overruled by a default. The
60
+ * model-name suffix is checked separately by the caller (it is parsed off the
61
+ * model before this runs).
62
+ */
63
+ export function requestNamesEffort(body) {
64
+ if (!body || typeof body !== 'object')
65
+ return false;
66
+ const oc = body.output_config;
67
+ if (oc && typeof oc === 'object' && typeof oc.effort === 'string')
68
+ return true;
69
+ if (typeof body.reasoning_effort === 'string' && body.reasoning_effort.length > 0)
70
+ return true;
71
+ const reasoning = body.reasoning;
72
+ if (reasoning && typeof reasoning === 'object' && typeof reasoning.effort === 'string')
73
+ return true;
74
+ return false;
75
+ }
76
+ /**
77
+ * A Responses body for the codex passthrough with `reasoning.effort` filled in.
78
+ * Returned as is when there is no effort to add or when the client already
79
+ * named one (a non-empty `reasoning.effort`); otherwise a copy carries the
80
+ * level and keeps every other `reasoning` field, since Codex CLI sends
81
+ * `summary` there. The passthrough forwards the client's body as written, so
82
+ * this is the only place a suffix or a key default can reach that route.
83
+ */
84
+ export function withResponsesEffort(body, effort) {
85
+ if (!effort)
86
+ return body;
87
+ const r = body.reasoning;
88
+ const existing = r !== null && typeof r === 'object' && !Array.isArray(r) ? r : {};
89
+ if (typeof existing.effort === 'string' && existing.effort.length > 0)
90
+ return body;
91
+ return { ...body, reasoning: { ...existing, effort } };
92
+ }
55
93
  export function parseEffortSuffix(model) {
56
94
  for (const e of SUFFIX_EFFORTS) {
57
95
  for (const sep of [':', '-']) {
package/dist/keys.d.ts CHANGED
@@ -3,6 +3,14 @@ export declare const KEY_PREFIX = "dk_";
3
3
  /** Same charset as a pool alias — a key name is printed next to one. */
4
4
  export declare const KEY_NAME_RE: RegExp;
5
5
  export declare const KEYS_FLUSH_DELAY_MS = 3000;
6
+ /**
7
+ * The effort levels a named key may carry as its default: the wire-valid set,
8
+ * and nothing that means "ask the client" (`client`) or names a client mode
9
+ * (`ultracode`). Applied per request when the request named no effort itself.
10
+ */
11
+ export type KeyEffort = 'low' | 'medium' | 'high' | 'xhigh' | 'max';
12
+ export declare const KEY_EFFORT_VALUES: ReadonlyArray<KeyEffort>;
13
+ export declare function isKeyEffort(v: unknown): v is KeyEffort;
6
14
  export interface KeyRecord {
7
15
  /** Stable short id (8 hex), for logs and rotation; never the secret. */
8
16
  id: string;
@@ -23,6 +31,12 @@ export interface KeyRecord {
23
31
  models?: string[];
24
32
  /** Daily caps, UTC day, enforced from the ledger (dario#1318 follow-up). Absent = unlimited. */
25
33
  budget?: KeyBudget;
34
+ /**
35
+ * Default effort for this key's requests when the request names none itself
36
+ * (no model-name suffix, no effort field in the body). Absent = the proxy's
37
+ * usual resolution. The operator's --effort pin still wins over it.
38
+ */
39
+ effort?: KeyEffort;
26
40
  }
27
41
  /**
28
42
  * A key's daily budget. Both caps are per UTC day and both are read from the
@@ -126,6 +140,8 @@ export declare function budgetVerdict(budget: KeyBudget, usage: KeyBudgetUsage,
126
140
  export declare function budgetHeaders(v: KeyBudgetVerdict, keyName: string): Record<string, string>;
127
141
  /** Replace (or clear, with null) a key's budget. Null result: no such key. */
128
142
  export declare function setKeyBudget(file: KeysFile, name: string, budget: KeyBudget | null): KeyRecord | null;
143
+ /** Set or clear a key's default effort. Returns null when there is no such key; throws on a level outside KEY_EFFORT_VALUES. */
144
+ export declare function setKeyEffort(file: KeysFile, name: string, effort: KeyEffort | string | null): KeyRecord | null;
129
145
  export interface KeysFile {
130
146
  version: number;
131
147
  keys: KeyRecord[];
@@ -161,6 +177,8 @@ export interface CreateKeyOptions {
161
177
  budget?: KeyBudget;
162
178
  /** Absolute expiry, epoch ms. */
163
179
  expiresAt?: number;
180
+ /** Default effort; one of KEY_EFFORT_VALUES, validated. */
181
+ effort?: string;
164
182
  now?: number;
165
183
  }
166
184
  /** Mint a key. Returns the record (stored) and the secret (shown once). Mutates `file`. */
@@ -203,6 +221,8 @@ export interface KeyPublic {
203
221
  usd_per_day: number | null;
204
222
  tokens_per_day: number | null;
205
223
  } | null;
224
+ /** Default effort, or null when the key has none. */
225
+ effort: KeyEffort | null;
206
226
  }
207
227
  export declare function publicKey(k: KeyRecord, now?: number): KeyPublic;
208
228
  /** `--expires=30d` / `12h` / `2026-12-31` → epoch ms, or null when unparseable. */
package/dist/keys.js CHANGED
@@ -37,6 +37,10 @@ export const KEY_PREFIX = 'dk_';
37
37
  export const KEY_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_\-.]{0,63}$/;
38
38
  export const KEYS_FLUSH_DELAY_MS = 3_000;
39
39
  const SECRET_BYTES = 24;
40
+ export const KEY_EFFORT_VALUES = ['low', 'medium', 'high', 'xhigh', 'max'];
41
+ export function isKeyEffort(v) {
42
+ return typeof v === 'string' && KEY_EFFORT_VALUES.includes(v);
43
+ }
40
44
  /** A conservative bytes-per-token for the reservation: prose is ~4, code and CJK are lower. */
41
45
  export const BUDGET_BYTES_PER_TOKEN = 3;
42
46
  /** Reserved output when the client sends no max_tokens / max_completion_tokens / max_output_tokens. */
@@ -166,6 +170,20 @@ export function setKeyBudget(file, name, budget) {
166
170
  delete k.budget;
167
171
  return k;
168
172
  }
173
+ /** Set or clear a key's default effort. Returns null when there is no such key; throws on a level outside KEY_EFFORT_VALUES. */
174
+ export function setKeyEffort(file, name, effort) {
175
+ const k = file.keys.find((x) => x.name === name);
176
+ if (!k)
177
+ return null;
178
+ if (effort === null || effort === undefined || effort === '') {
179
+ delete k.effort;
180
+ return k;
181
+ }
182
+ if (!isKeyEffort(effort))
183
+ throw new Error(`invalid effort "${effort}": one of ${KEY_EFFORT_VALUES.join(', ')}`);
184
+ k.effort = effort;
185
+ return k;
186
+ }
169
187
  export function keysPathFor(home = homedir()) {
170
188
  return join(home, '.dario', 'keys.json');
171
189
  }
@@ -239,6 +257,9 @@ export function parseKeysFile(text) {
239
257
  }
240
258
  catch { /* a malformed cap is dropped, never a reason to refuse the whole file */ }
241
259
  }
260
+ // A default effort outside the wire-valid set is dropped, like a malformed cap.
261
+ if (isKeyEffort(k.effort))
262
+ rec.effort = k.effort;
242
263
  seen.add(rec.name);
243
264
  keys.push(rec);
244
265
  }
@@ -294,6 +315,11 @@ export function createKey(file, name, opts = {}) {
294
315
  const budget = normalizeBudget(opts.budget);
295
316
  if (budget)
296
317
  record.budget = budget;
318
+ if (opts.effort !== undefined && opts.effort !== '') {
319
+ if (!isKeyEffort(opts.effort))
320
+ throw new Error(`invalid effort "${opts.effort}": one of ${KEY_EFFORT_VALUES.join(', ')}`);
321
+ record.effort = opts.effort;
322
+ }
297
323
  if (opts.expiresAt !== undefined) {
298
324
  if (!Number.isFinite(opts.expiresAt) || opts.expiresAt <= (opts.now ?? Date.now()))
299
325
  throw new Error('expiry must be in the future');
@@ -371,7 +397,7 @@ export function keyAllowsModel(k, model) {
371
397
  export function publicKey(k, now = Date.now()) {
372
398
  const status = k.disabled ? 'revoked' : k.expires && Date.parse(k.expires) <= now ? 'expired' : 'active';
373
399
  const budget = k.budget ? { usd_per_day: k.budget.usdPerDay ?? null, tokens_per_day: k.budget.tokensPerDay ?? null } : null;
374
- return { id: k.id, name: k.name, created: k.created, last_used: k.lastUsed ?? null, status, expires: k.expires ?? null, seat: k.seat ?? null, budget, models: k.models ?? [] };
400
+ return { id: k.id, name: k.name, created: k.created, last_used: k.lastUsed ?? null, status, expires: k.expires ?? null, seat: k.seat ?? null, budget, effort: k.effort ?? null, models: k.models ?? [] };
375
401
  }
376
402
  /** `--expires=30d` / `12h` / `2026-12-31` → epoch ms, or null when unparseable. */
377
403
  export function parseExpiry(value, now = Date.now()) {
package/dist/ledger.d.ts CHANGED
@@ -38,6 +38,12 @@ export interface LedgerCell {
38
38
  outputTokens: number;
39
39
  cacheReadTokens: number;
40
40
  cacheCreateTokens: number;
41
+ /**
42
+ * Thinking tokens INSIDE outputTokens, a share and never an addition (the
43
+ * model's reasoning is billed as output). Counted from the usage block the
44
+ * backend returns; rows written before the field existed read as 0.
45
+ */
46
+ thinkingTokens: number;
41
47
  }
42
48
  export type LedgerRow = Partial<Record<LedgerBucket, LedgerCell>>;
43
49
  export interface LedgerFile {
@@ -67,6 +73,9 @@ export interface LedgerConsumerSummary {
67
73
  outputTokens: number;
68
74
  cacheReadTokens: number;
69
75
  cacheCreateTokens: number;
76
+ /** Of outputTokens, how many were thinking; and what that thinking would have cost at the model's output rate (both buckets). */
77
+ thinkingTokens: number;
78
+ thinkingCost: number;
70
79
  recent: {
71
80
  today: number;
72
81
  last7d: number;
@@ -84,6 +93,8 @@ export interface LedgerModelSummary {
84
93
  outputTokens: number;
85
94
  cacheReadTokens: number;
86
95
  cacheCreateTokens: number;
96
+ /** Of the covered outputTokens, how many were thinking. */
97
+ thinkingTokens: number;
87
98
  /** API-equivalent cost of this model's covered traffic, USD. */
88
99
  apiEquivalentCost: number;
89
100
  /** What this model's metered traffic cost at list price, USD. */
@@ -106,13 +117,16 @@ export interface LedgerSummary {
106
117
  apiEquivalentCost: number;
107
118
  /** What metered traffic (API key, paid overage) actually cost at list price, USD. */
108
119
  meteredCost: number;
109
- /** Covered token totals. */
120
+ /** Covered token totals. `thinking` is the share of `output` that was reasoning. */
110
121
  tokens: {
111
122
  input: number;
112
123
  output: number;
113
124
  cacheRead: number;
114
125
  cacheCreate: number;
126
+ thinking: number;
115
127
  };
128
+ /** The part of apiEquivalentCost that paid for thinking: covered thinking tokens at each model's output rate, USD. */
129
+ thinkingCost: number;
116
130
  perProvider: Record<PricingProvider, {
117
131
  requests: number;
118
132
  apiEquivalentCost: number;
@@ -238,6 +252,8 @@ export declare function shortModelName(model: string): string;
238
252
  * to match the rest of that command's output.
239
253
  */
240
254
  export declare function formatLedgerSummary(s: LedgerSummary): string[];
255
+ /** Thinking as a whole percent of output; 0 when there was no output. */
256
+ export declare function thinkingPercent(thinking: number, output: number): number;
241
257
  /**
242
258
  * `dario usage --by-key`: the lifetime number per consumer, biggest first.
243
259
  * A consumer is a named key's name, an `x-dario-consumer` header, or the
package/dist/ledger.js CHANGED
@@ -52,7 +52,7 @@ export function resolveLedgerPath(port, env = process.env) {
52
52
  export function ledgerDisabledByEnv(env = process.env) {
53
53
  return ['0', 'false', 'no', 'off'].includes((env['DARIO_LEDGER'] ?? '').toLowerCase());
54
54
  }
55
- const emptyCell = () => ({ requests: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0 });
55
+ const emptyCell = () => ({ requests: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0, thinkingTokens: 0 });
56
56
  export function emptyLedger(now = Date.now()) {
57
57
  const iso = new Date(now).toISOString();
58
58
  return { version: LEDGER_VERSION, since: iso, updated: iso, days: {} };
@@ -90,6 +90,17 @@ function isCell(v) {
90
90
  return ['requests', 'inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheCreateTokens']
91
91
  .every((k) => typeof c[k] === 'number' && Number.isFinite(c[k]) && c[k] >= 0);
92
92
  }
93
+ /**
94
+ * A cell as read from disk, with the fields added after the file was written
95
+ * filled in: `thinkingTokens` is 0 on rows older than the field (and never
96
+ * above the output count it is a share of).
97
+ */
98
+ function cleanCell(c) {
99
+ const raw = c.thinkingTokens;
100
+ const thinking = typeof raw === 'number' && Number.isFinite(raw) && raw >= 0 ? Math.min(raw, c.outputTokens) : 0;
101
+ return { requests: c.requests, inputTokens: c.inputTokens, outputTokens: c.outputTokens,
102
+ cacheReadTokens: c.cacheReadTokens, cacheCreateTokens: c.cacheCreateTokens, thinkingTokens: thinking };
103
+ }
93
104
  /**
94
105
  * Parse a ledger file's text, keeping only well-formed rows. A file that is
95
106
  * not a ledger at all throws; the caller moves it aside and starts fresh.
@@ -109,9 +120,9 @@ export function parseLedger(text) {
109
120
  continue;
110
121
  const r = {};
111
122
  if (isCell(row.covered))
112
- r.covered = { ...row.covered };
123
+ r.covered = cleanCell(row.covered);
113
124
  if (isCell(row.metered))
114
- r.metered = { ...row.metered };
125
+ r.metered = cleanCell(row.metered);
115
126
  if (r.covered || r.metered)
116
127
  clean[model] = r;
117
128
  }
@@ -136,9 +147,9 @@ export function parseLedger(text) {
136
147
  continue;
137
148
  const r = {};
138
149
  if (isCell(row.covered))
139
- r.covered = { ...row.covered };
150
+ r.covered = cleanCell(row.covered);
140
151
  if (isCell(row.metered))
141
- r.metered = { ...row.metered };
152
+ r.metered = cleanCell(row.metered);
142
153
  if (r.covered || r.metered)
143
154
  clean[model] = r;
144
155
  }
@@ -160,6 +171,8 @@ function addCell(row, bucket, record) {
160
171
  cell.outputTokens += record.outputTokens;
161
172
  cell.cacheReadTokens += record.cacheReadTokens;
162
173
  cell.cacheCreateTokens += record.cacheCreateTokens;
174
+ const thinking = Number.isFinite(record.thinkingTokens) && record.thinkingTokens > 0 ? record.thinkingTokens : 0;
175
+ cell.thinkingTokens = (cell.thinkingTokens ?? 0) + Math.min(thinking, record.outputTokens);
163
176
  }
164
177
  /** Add one record's tokens to the file in place. Returns false when it was not counted. */
165
178
  export function addToLedger(file, record) {
@@ -203,13 +216,14 @@ export function summarizeLedgerConsumers(file, now = Date.now()) {
203
216
  for (const [day, byConsumer] of Object.entries(file.consumers ?? {})) {
204
217
  const at = dayMs(day);
205
218
  for (const [consumer, models] of Object.entries(byConsumer)) {
206
- const c = (out[consumer] ??= { requests: 0, apiEquivalentCost: 0, meteredCost: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0, recent: { today: 0, last7d: 0, last30d: 0 }, lastDay: day, models: [], _models: {} });
219
+ const c = (out[consumer] ??= { requests: 0, apiEquivalentCost: 0, meteredCost: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0, thinkingTokens: 0, thinkingCost: 0, recent: { today: 0, last7d: 0, last30d: 0 }, lastDay: day, models: [], _models: {} });
207
220
  if (day > c.lastDay)
208
221
  c.lastDay = day;
209
222
  for (const [model, row] of Object.entries(models)) {
210
223
  if (row.covered) {
211
224
  const cost = costOfTokens(model, at, row.covered);
212
225
  c.apiEquivalentCost += cost;
226
+ c.thinkingCost += costOfTokens(model, at, thinkingOnly(row.covered));
213
227
  c.requests += row.covered.requests;
214
228
  addTokens(c, row.covered);
215
229
  c._models[model] = (c._models[model] ?? 0) + row.covered.requests;
@@ -222,6 +236,7 @@ export function summarizeLedgerConsumers(file, now = Date.now()) {
222
236
  }
223
237
  if (row.metered) {
224
238
  c.meteredCost += costOfTokens(model, at, row.metered);
239
+ c.thinkingCost += costOfTokens(model, at, thinkingOnly(row.metered));
225
240
  c.requests += row.metered.requests;
226
241
  addTokens(c, row.metered);
227
242
  c._models[model] = (c._models[model] ?? 0) + row.metered.requests;
@@ -239,6 +254,8 @@ export function summarizeLedgerConsumers(file, now = Date.now()) {
239
254
  outputTokens: c.outputTokens,
240
255
  cacheReadTokens: c.cacheReadTokens,
241
256
  cacheCreateTokens: c.cacheCreateTokens,
257
+ thinkingTokens: c.thinkingTokens,
258
+ thinkingCost: round(c.thinkingCost),
242
259
  recent: { today: round(c.recent.today), last7d: round(c.recent.last7d), last30d: round(c.recent.last30d) },
243
260
  lastDay: c.lastDay,
244
261
  models: Object.entries(c._models).sort((a, b) => b[1] - a[1]).map(([m]) => m),
@@ -276,6 +293,11 @@ function addTokens(into, cell) {
276
293
  into.outputTokens += cell.outputTokens;
277
294
  into.cacheReadTokens += cell.cacheReadTokens;
278
295
  into.cacheCreateTokens += cell.cacheCreateTokens;
296
+ into.thinkingTokens += cell.thinkingTokens ?? 0;
297
+ }
298
+ /** The cell's thinking alone, shaped for the price list: output tokens and nothing else. */
299
+ function thinkingOnly(cell) {
300
+ return { inputTokens: 0, outputTokens: cell.thinkingTokens ?? 0, cacheReadTokens: 0, cacheCreateTokens: 0 };
279
301
  }
280
302
  // Round BEFORE choosing the unit. Picking the unit on the raw value and
281
303
  // rounding afterwards let 999_600 print as "1000k": once the rounded
@@ -305,32 +327,36 @@ export function summarizeLedger(file, path, now = Date.now()) {
305
327
  anthropic: { requests: 0, apiEquivalentCost: 0 },
306
328
  openai: { requests: 0, apiEquivalentCost: 0 },
307
329
  };
308
- const tokens = { input: 0, output: 0, cacheRead: 0, cacheCreate: 0 };
330
+ const tokens = { input: 0, output: 0, cacheRead: 0, cacheCreate: 0, thinking: 0 };
309
331
  let requests = 0;
310
332
  let covered = 0;
311
333
  let metered = 0;
334
+ let thinkingCost = 0;
312
335
  const recent = { today: 0, last7d: 0, last30d: 0 };
313
336
  for (const [day, models] of Object.entries(file.days)) {
314
337
  const at = dayMs(day);
315
338
  for (const [model, row] of Object.entries(models)) {
316
339
  const m = (perModel[model] ??= {
317
340
  provider: providerOfModel(model), requests: 0,
318
- inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0,
341
+ inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0, thinkingTokens: 0,
319
342
  apiEquivalentCost: 0, meteredCost: 0,
320
343
  });
321
344
  if (row.covered) {
322
345
  const cost = costOfTokens(model, at, row.covered);
323
346
  covered += cost;
347
+ thinkingCost += costOfTokens(model, at, thinkingOnly(row.covered));
324
348
  m.apiEquivalentCost += cost;
325
349
  m.requests += row.covered.requests;
326
350
  m.inputTokens += row.covered.inputTokens;
327
351
  m.outputTokens += row.covered.outputTokens;
328
352
  m.cacheReadTokens += row.covered.cacheReadTokens;
329
353
  m.cacheCreateTokens += row.covered.cacheCreateTokens;
354
+ m.thinkingTokens += row.covered.thinkingTokens ?? 0;
330
355
  tokens.input += row.covered.inputTokens;
331
356
  tokens.output += row.covered.outputTokens;
332
357
  tokens.cacheRead += row.covered.cacheReadTokens;
333
358
  tokens.cacheCreate += row.covered.cacheCreateTokens;
359
+ tokens.thinking += row.covered.thinkingTokens ?? 0;
334
360
  perProvider[m.provider].requests += row.covered.requests;
335
361
  perProvider[m.provider].apiEquivalentCost += cost;
336
362
  requests += row.covered.requests;
@@ -364,6 +390,7 @@ export function summarizeLedger(file, path, now = Date.now()) {
364
390
  apiEquivalentCost: round(covered),
365
391
  meteredCost: round(metered),
366
392
  tokens,
393
+ thinkingCost: round(thinkingCost),
367
394
  perProvider,
368
395
  perModel,
369
396
  recent: { today: round(recent.today), last7d: round(recent.last7d), last30d: round(recent.last30d) },
@@ -559,8 +586,14 @@ export function formatLedgerSummary(s) {
559
586
  lines.push(` Today ${formatUsd(s.recent.today)} · Last 7d ${formatUsd(s.recent.last7d)} · Last 30d ${formatUsd(s.recent.last30d)}`);
560
587
  if (s.meteredCost > 0)
561
588
  lines.push(` Paid per token on top (API key / extra usage): ${formatUsd(s.meteredCost)}`);
589
+ if (s.tokens.thinking > 0)
590
+ lines.push(` Thinking: ${formatTokenCount(s.tokens.thinking)} of ${formatTokenCount(s.tokens.output)} output tokens (${thinkingPercent(s.tokens.thinking, s.tokens.output)}%) · ${formatUsd(s.thinkingCost)} of the number above`);
562
591
  return lines;
563
592
  }
593
+ /** Thinking as a whole percent of output; 0 when there was no output. */
594
+ export function thinkingPercent(thinking, output) {
595
+ return output > 0 ? Math.round((100 * thinking) / output) : 0;
596
+ }
564
597
  /**
565
598
  * `dario usage --by-key`: the lifetime number per consumer, biggest first.
566
599
  * A consumer is a named key's name, an `x-dario-consumer` header, or the
@@ -576,7 +609,8 @@ export function formatLedgerConsumers(s, limit = 20) {
576
609
  for (const [consumer, c] of entries.slice(0, limit)) {
577
610
  const models = c.models.slice(0, 2).map(shortModelName).join(', ');
578
611
  lines.push(` ${consumer.slice(0, width).padEnd(width)} ${formatUsd(c.apiEquivalentCost).padStart(9)} · ${formatUsd(c.recent.today).padStart(8)} · ${formatUsd(c.recent.last7d).padStart(8)} · ${formatUsd(c.recent.last30d).padStart(8)} ${c.requests.toLocaleString('en-US')} req${c.requests === 1 ? '' : 's'}${models ? `, ${models}` : ''}${c.meteredCost > 0 ? `, ${formatUsd(c.meteredCost)} metered` : ''}`);
579
- lines.push(` ${' '.repeat(width)} in ${formatTokenCount(c.inputTokens)} · out ${formatTokenCount(c.outputTokens)} · cache read ${formatTokenCount(c.cacheReadTokens)} · cache write ${formatTokenCount(c.cacheCreateTokens)}`);
612
+ const thinking = c.thinkingTokens > 0 ? ` · thinking ${formatTokenCount(c.thinkingTokens)} (${thinkingPercent(c.thinkingTokens, c.outputTokens)}% of out, ${formatUsd(c.thinkingCost)})` : '';
613
+ lines.push(` ${' '.repeat(width)} in ${formatTokenCount(c.inputTokens)} · out ${formatTokenCount(c.outputTokens)} · cache read ${formatTokenCount(c.cacheReadTokens)} · cache write ${formatTokenCount(c.cacheCreateTokens)}${thinking}`);
580
614
  }
581
615
  if (entries.length > limit)
582
616
  lines.push(` … and ${entries.length - limit} more`);
@@ -451,7 +451,7 @@ export function loadTemplate(_options) {
451
451
  // Stale cache: prefer whichever of the live cache and the bundled
452
452
  // snapshot was captured more recently — do NOT blindly keep the cache.
453
453
  // A frozen live cache must not shadow a newer bundled template, which is
454
- // exactly what happens in a no-CC deployment (e.g. the Hetzner container):
454
+ // exactly what happens in a no-CC deployment (e.g. a container deploy):
455
455
  // the async refresh can never run there, so the cache stays pinned at its
456
456
  // last capture while shipped releases move the bundle ahead. Without this
457
457
  // comparison, every bundled-template update is silently ignored until the
@@ -1069,7 +1069,7 @@ const STATIC_HEADER_EXCLUDE = new Set([
1069
1069
  // Found 2026-07-25: the bundled template had been baked on Windows for
1070
1070
  // several releases (#820/#828/#840/#849/#851 -> x-stainless-os: Windows,
1071
1071
  // 30 tools incl. PowerShell), while cc-drift-template-watch runs its live
1072
- // capture every 30 min on the Linux Hetzner runner. So the Linux box served
1072
+ // capture every 30 min on a Linux self-hosted runner. So the Linux box served
1073
1073
  // `x-stainless-os: Windows`, and the watch saw permanent drift against a
1074
1074
  // bundle it could never match -- auto-rebaking to Linux (#852), which the next
1075
1075
  // Windows-side bake (#854) would flip straight back. Excluding these ends