sprag-cli 3.55.0 → 3.56.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.
@@ -2,7 +2,9 @@ import { loadConfig, saveConfig } from '../config.js';
2
2
  import { configureCodexHooks } from '../codex-installer.js';
3
3
  import { validateCodexTarget, loadCodexModelRules, addCodexModelRule, removeCodexModelRule } from '../codex-delegation.js';
4
4
  import { findCodexCandidate, resolveCodexCandidate } from '../codex-route-scan.js';
5
- import { codexRoutingSavedTotals } from '../codex-ledger.js';
5
+ import { codexRoutingSavedTotals, loadCodexLedger } from '../codex-ledger.js';
6
+ import { codexRuleHealth } from '../codex-rule-health.js';
7
+ import { signedUsd } from '../money.js';
6
8
  import { codexProviderName } from '../agent.js';
7
9
 
8
10
  export function run({ args, getArg, hasFlag, root }) {
@@ -31,7 +33,14 @@ export function run({ args, getArg, hasFlag, root }) {
31
33
  else if (args[2]) throw new Error('Usage: delegate rules [add <category|R<N>> --from <model> --model <model> --global|--project | rm <N>]');
32
34
  const rules = loadCodexModelRules();
33
35
  if (!rules.length) console.log('No Codex model rules. Claude rules are not imported.');
34
- rules.forEach((r, i) => console.log(`#${i + 1} ${r.category} | ${r.from} -> ${r.model} | ${r.scope}${r.targetRoot ? ` ${r.targetRoot}` : ''}`));
36
+ let events = [];
37
+ try { events = Object.values(loadCodexLedger().events); } catch { /* No ledger yet: rules list without outcomes. */ }
38
+ rules.forEach((r, i) => {
39
+ const h = codexRuleHealth(r, { events });
40
+ const outcome = h.runs ? ` | measured x${h.runs}, err ${Math.round(h.rate * 100)}%, saved ${signedUsd(h.saved, 4)}` : '';
41
+ const warn = h.status === 'review' ? ` | rule-health: ${h.errs}/${h.runs} runs failed, narrow the condition or remove (delegate rules rm ${i + 1} --agent codex)` : '';
42
+ console.log(`#${i + 1} ${r.category} | ${r.from} -> ${r.model} | ${r.scope}${r.targetRoot ? ` ${r.targetRoot}` : ''}${outcome}${warn}`);
43
+ });
35
44
  return;
36
45
  }
37
46
  if (!['on', 'off', 'status', 'model'].includes(sub)) throw new Error('Usage: delegate on|off|status|model <model>|rules --agent codex');
@@ -54,6 +63,6 @@ export function run({ args, getArg, hasFlag, root }) {
54
63
  console.log('Explicit spawn models and custom roles are preserved. Targets must be available from your Codex provider.');
55
64
  const saved = codexRoutingSavedTotals();
56
65
  console.log(saved.priced
57
- ? `Routing saved: $${saved.total.toFixed(4)} over ${saved.priced} priced run(s). Details: sprag route-scan savings --agent codex`
66
+ ? `Routing saved: ${saved.total < 0 ? '-' : ''}$${Math.abs(saved.total).toFixed(4)} over ${saved.priced} priced run(s). Details: sprag route-scan savings --agent codex`
58
67
  : `Routing saved: n/a (${saved.runs ? `${saved.runs} attributed run(s), none priced` : 'no attributed runs yet'}). Review/trust the Sprag hooks in Codex /hooks.`);
59
68
  }
@@ -4,6 +4,8 @@ import { initCodexHarness, uninitCodexHarness, codexHarnessStatus } from '../cod
4
4
  import { findProjectRoot, harnessPromote, harnessListRules, harnessRmRule, harnessPrune } from '../harness.js';
5
5
  import { loadConfig, saveConfig } from '../config.js';
6
6
  import { resolve } from 'node:path';
7
+ import { debug } from '../debug.js';
8
+ import { signedUsd } from '../money.js';
7
9
 
8
10
  const HELP = `Sprag / Codex
9
11
 
@@ -19,7 +21,7 @@ Usage: sprag <command> --agent codex [options]
19
21
  doctor [--format json] Config, hooks, harness, logs, and capabilities
20
22
  capabilities [--format json] Support matrix, including native/unsupported areas
21
23
  brief [on|off|status] Current warnings or prompt-briefing preference
22
- last / history Codex warning history; --days, --session, --project
24
+ last / history Context/rate-limit warnings and handoffs; --days, --session, --project
23
25
  handoff [--cwd path] Write Git state and Codex usage to HANDOFF-*.md
24
26
  delegate on|off|status Native model routing and SubagentStart guidance
25
27
  delegate model <model> Default target for spawns without an explicit model
@@ -155,7 +157,7 @@ export async function run({ args, version }) {
155
157
  const money = (usd) => Number.isFinite(usd) ? `${usd < 0 ? '-' : ''}$${Math.abs(usd).toFixed(4)}` : 'n/a (unpriced)';
156
158
  console.log(`Codex routing saved: ${totals.priced ? `${money(totals.week)} (7d) / ${money(totals.total)} total` : 'n/a'} | ${totals.runs} attributed run(s), ${totals.runs - totals.priced} unpriced`);
157
159
  for (const e of events.slice(0, 20)) {
158
- console.log(`${new Date(e.ts).toISOString().slice(0, 16)} ${e.source}${e.category ? `/${e.category}` : ''} ${e.from} -> ${e.to} ${money(e.usd)}${e.complete ? '' : ' (incomplete)'}`);
160
+ console.log(`${new Date(e.ts).toISOString().slice(0, 16)} ${e.source}${e.category ? `/${e.category}` : ''} ${e.from} -> ${e.to} ${money(e.usd)}${e.aborted ? ' (interrupted: whole cost counted as a loss)' : e.complete ? '' : ' (incomplete)'}`);
159
161
  }
160
162
  console.log('Counterfactual: the child run\'s own tokens priced at the parent model. Only Sprag-routed spawns with a recorded route id count.');
161
163
  return;
@@ -172,7 +174,7 @@ export async function run({ args, version }) {
172
174
  if (hasFlag('--refresh') || !cache) {
173
175
  // Prices live in the gateway snapshot; refresh it when stale so candidates can name a target.
174
176
  const { readCodexPrices, refreshCodexPrices } = await import('../codex-cache-policy.js');
175
- if (!readCodexPrices()) { try { await refreshCodexPrices(); } catch { /* Candidates still list; targets need --model. */ } }
177
+ if (!readCodexPrices()) { try { await refreshCodexPrices(); } catch (e) { debug('codex:prices', e); } }
176
178
  cache = await rs.runCodexRouteScan({ days });
177
179
  }
178
180
  if (hasFlag('--quiet')) return;
@@ -181,7 +183,7 @@ export async function run({ args, version }) {
181
183
  console.log(`Codex route-scan: ${cache.totalEpisodes} turns in ${cache.days} days, scanned ${cache.scannedAt}${cache.priceCheckedAt ? '' : ' | prices unavailable'}`);
182
184
  if (!open.length) console.log('No recurring delegable pattern. Needs 3+ similar simple turns per project and parent model.');
183
185
  for (const c of open) {
184
- const target = c.suggestedModel ? `-> ${c.suggestedModel}${Number.isFinite(c.estSavedUsd) ? ` (~$${c.estSavedUsd.toFixed(4)} over ${c.count} turns)` : ''}`
186
+ const target = c.suggestedModel ? `-> ${c.suggestedModel}${Number.isFinite(c.estSavedUsd) ? ` (~${signedUsd(c.estSavedUsd, 4)} over ${c.count} turns)` : ''}`
185
187
  : `-> choose --model${c.alternatives.length ? ` (priced cheaper: ${c.alternatives.join(', ')})` : ''}`;
186
188
  console.log(`R${c.id} ${c.category} x${c.count} [T2 ${c.tiers.T2} / T1 ${c.tiers.T1}] ${c.provider || 'unknown provider'} / ${c.from} ${target}`);
187
189
  console.log(` ${c.projectRoot}`);
@@ -225,7 +227,7 @@ export async function run({ args, version }) {
225
227
  sessionId: getArg('--session'), project: getArg('--project') || getArg('-p') });
226
228
  if (cmd === 'last') events = events.slice(-1);
227
229
  if (format === 'json') { console.log(JSON.stringify({ agent: 'codex', events }, null, 2)); return; }
228
- if (!events.length) console.log('No Codex warning history in this window. Records are written by the trusted prompt hook.');
230
+ if (!events.length) console.log('No Codex warning history in this window. The trusted prompt hook records context and rate-limit warnings; handoffs are recorded when written.');
229
231
  else if (hasFlag('--list')) console.log([...new Set(events.map((event) => event.at.slice(0, 10)))].reverse().join('\n'));
230
232
  else for (const event of events) {
231
233
  console.log(terminalText(`${event.at} [${event.sessionId}] ${event.message}`));
@@ -239,6 +241,11 @@ export async function run({ args, version }) {
239
241
  const cwd = getArg('--cwd') || process.cwd();
240
242
  const data = await createPanelReader({ root: findProjectRoot(resolve(cwd), { agent: 'codex' }), sessionId: getArg('--session'), days: numArg('--days', { dflt: 7, min: 0 }) })();
241
243
  const result = writeHandoff({ cwd, agent: 'codex', session: data.session });
244
+ try {
245
+ const { recordCodexHistoryEvent } = await import('../codex-brief.js');
246
+ recordCodexHistoryEvent({ sessionId: data.session?.sessionId, project: cwd, key: 'handoff',
247
+ message: `Handoff written: ${result.path}` });
248
+ } catch (e) { debug('codex:handoff-record', e); }
242
249
  console.log(`Handoff written: ${result.path}`);
243
250
  console.log('Fill in the remaining-work sections, then ask the next Codex session to read this file.');
244
251
  return;
@@ -324,7 +331,7 @@ export async function run({ args, version }) {
324
331
  })() : { read: createCodexBudgetReader() };
325
332
  if (hasFlag('--once')) {
326
333
  const provider = codexBudgetProvider();
327
- if (provider) { try { readBudget.set(await fetchCodexBudget(provider)); } catch { /* No verified budget. */ } }
334
+ if (provider) { try { readBudget.set(await fetchCodexBudget(provider)); } catch (e) { debug('codex:budget', e); } }
328
335
  }
329
336
  const { createInlinePanelResizer } = await import('../codex-panel-runner.js');
330
337
  const rawInterval = getArg('--interval');
@@ -340,7 +347,7 @@ export async function run({ args, version }) {
340
347
  readCachePolicy: hasFlag('--once') ? async (session) => {
341
348
  const { readCodexCachePolicy } = await import('../codex-cache-policy.js');
342
349
  try { return readCodexCachePolicy(session) || await refreshCodexCachePolicy(session); }
343
- catch { return null; }
350
+ catch (e) { debug('codex:cache-policy', e); return null; }
344
351
  } : createCodexCachePolicyReader(), hasFlag }),
345
352
  interval: interval * 1000, once: hasFlag('--once'),
346
353
  color: !hasFlag('--no-color') && !process.env.NO_COLOR && !!process.stdout.isTTY, version, compact: hasFlag('--compact'),
@@ -377,7 +384,7 @@ export async function run({ args, version }) {
377
384
  }
378
385
  const out = await codexHookOutput(getArg('--event'), payload);
379
386
  if (out) console.log(JSON.stringify(out));
380
- } catch { /* Hook failures must not break the agent loop. */ }
387
+ } catch (e) { debug('codex:hook', e); /* Hook failures must not break the agent loop. */ }
381
388
  return;
382
389
  }
383
390
  if (cmd === 'install') {
@@ -396,7 +403,7 @@ export async function run({ args, version }) {
396
403
  const { preparePresetRatchet } = await import('../preset-ratchet.js');
397
404
  const p = preparePresetRatchet({ agent: 'codex' });
398
405
  console.log(`Codex preset rules: ${p.file} (${p.rules} registered${p.moved ? `, ${p.moved} moved from ratchet.md` : ''})`);
399
- } catch { /* Presets are optional; `seed` still registers them. */ }
406
+ } catch (e) { debug('codex:preset', e); /* Presets are optional; `seed` still registers them. */ }
400
407
  console.log('Open /hooks in Codex and review/trust the Sprag hooks before they can run.');
401
408
  console.log(`Panel auto-start: ${panelAuto ? 'registered with the other hooks' : 'off'}. Hook registration is complete; runtime activation is not verified.`);
402
409
  console.log('After approval, start or resume Codex and run: sprag panel doctor --agent codex');
@@ -508,7 +515,8 @@ export async function run({ args, version }) {
508
515
  console.log(`Cache: ${doc2md.cacheDir()}`);
509
516
  try {
510
517
  for (const hook of codexHookStatus().filter((h) => ['PreToolUse', 'UserPromptSubmit'].includes(h.event))) console.log(`${hook.event}: ${hook.registered ? 'registered' : 'missing/outdated'}`);
511
- } catch {
518
+ } catch (e) {
519
+ debug('codex:hook-status', e);
512
520
  console.log('Hooks: cannot read Codex hooks.json; file left unchanged. Run sprag doctor --agent codex for diagnostics.');
513
521
  }
514
522
  console.log('Prompt conversion, supported reads, and document/cache write guards require trusted hooks. Review in Codex /hooks.');
@@ -10,6 +10,7 @@ import { createRequire } from 'node:module';
10
10
  const require = createRequire(import.meta.url);
11
11
  import { readStdinJson } from '../stdin-payload.js';
12
12
  import { debug } from '../debug.js';
13
+ import { signedUsd } from '../money.js';
13
14
 
14
15
  /**
15
16
  * Whether the user turned doc2md on.
@@ -65,7 +66,7 @@ export async function run({ args, hasFlag, numArg }) {
65
66
  return;
66
67
  }
67
68
  const t = delegationSavedTotals();
68
- const money = (v) => `$${v.toFixed(2)}`;
69
+ const money = (v) => (v < 0 ? `-$${(-v).toFixed(2)}` : `$${v.toFixed(2)}`); // sign before the symbol
69
70
  // Lifetime leads (it is what the breakdown below adds up to); the
70
71
  // rolling windows follow as context rather than as competing headlines.
71
72
  console.log(lang === 'ko'
@@ -154,8 +155,8 @@ export async function run({ args, hasFlag, numArg }) {
154
155
  // two mean opposite things (no data vs. data showing no value).
155
156
  const measured = r.delegatedRuns
156
157
  ? (lang === 'ko'
157
- ? `실제 위임 ${r.delegatedRuns}건 · 에러율 ${Math.round((r.delegatedErrRate || 0) * 100)}% · 절감 ~$${(r.savedUsd || 0).toFixed(2)}`
158
- : `measured ×${r.delegatedRuns} · err ${Math.round((r.delegatedErrRate || 0) * 100)}% · saved ~$${(r.savedUsd || 0).toFixed(2)}`)
158
+ ? `실제 위임 ${r.delegatedRuns}건 · 에러율 ${Math.round((r.delegatedErrRate || 0) * 100)}% · 절감 ~${signedUsd(r.savedUsd || 0)}`
159
+ : `measured ×${r.delegatedRuns} · err ${Math.round((r.delegatedErrRate || 0) * 100)}% · saved ~${signedUsd(r.savedUsd || 0)}`)
159
160
  : (sessionRank !== null && !rs.worthDelegating(r.tier, sessionRank)
160
161
  ? (lang === 'ko'
161
162
  ? `이 규칙은 현재 기본 모델(${sessionModel}) 기준으로는 적용되지 않습니다. 세션 모델이 이미 위임 목표와 같은 급이어서 절감이 발생하지 않습니다.`
package/src/cost.js CHANGED
@@ -197,7 +197,8 @@ function tokensToMillions(n) {
197
197
  * ephemeral1h — portion of cacheCreation billed at 1h rate (2x input)
198
198
  * output — output tokens
199
199
  */
200
- export function estimateCost(totals, model) {
200
+ export function estimateCost(totals, model, { price } = {}) {
201
+ if (price) return estimateCostAtPrice(totals, price);
201
202
  const tier = detectPricingTier(model);
202
203
  const p = PRICING[tier];
203
204
 
@@ -248,20 +249,73 @@ export function estimateCost(totals, model) {
248
249
  };
249
250
  }
250
251
 
252
+ /**
253
+ * Same shape as the list-price estimate, but priced at a gateway's per-token
254
+ * rates (LiteLLM /model/info). A token class the gateway does not price makes
255
+ * the whole estimate unknown (null) rather than free or list-priced.
256
+ */
257
+ function estimateCostAtPrice(totals, price) {
258
+ const write5m = totals.ephemeral5m ?? 0;
259
+ const write1h = totals.ephemeral1h ?? 0;
260
+ const untracked = Math.max(0, (totals.cacheCreation ?? 0) - write5m - write1h);
261
+ const read = totals.cacheRead ?? 0;
262
+ if (read > 0 && price.cacheRead == null) return null;
263
+ if (write5m + untracked > 0 && price.cacheWrite == null) return null;
264
+ if (write1h > 0 && price.cacheWrite1h == null) return null;
265
+ if (price.input == null || price.output == null) return null;
266
+ const perM = (rate) => (rate ?? 0) * 1e6;
267
+ const input = perM(price.input), output = perM(price.output), cacheRead = perM(price.cacheRead);
268
+ const cacheWrite5m = perM(price.cacheWrite), cacheWrite1h = perM(price.cacheWrite1h);
269
+
270
+ const actual =
271
+ tokensToMillions(totals.input) * input +
272
+ tokensToMillions(write5m + untracked) * cacheWrite5m +
273
+ tokensToMillions(write1h) * cacheWrite1h +
274
+ tokensToMillions(read) * cacheRead +
275
+ tokensToMillions(totals.output) * output;
276
+ const totalInput = totals.input + (totals.cacheCreation ?? 0) + read;
277
+ const noCacheCost = tokensToMillions(totalInput) * input + tokensToMillions(totals.output) * output;
278
+
279
+ // The 5m scenario needs a 5m write rate; without one it is not computable, so
280
+ // it collapses to the actual cost and the "extra cost" row stays hidden.
281
+ let scenario5mCost = actual;
282
+ if (price.cacheWrite != null) {
283
+ const extra5mCreation = write1h * 2;
284
+ scenario5mCost =
285
+ tokensToMillions(totals.input) * input +
286
+ tokensToMillions(write5m + write1h + untracked + extra5mCreation) * cacheWrite5m +
287
+ tokensToMillions(Math.max(0, read - extra5mCreation)) * cacheRead +
288
+ tokensToMillions(totals.output) * output;
289
+ }
290
+
291
+ return {
292
+ tier: 'gateway',
293
+ actual: round(actual),
294
+ noCacheCost: round(noCacheCost),
295
+ savings: round(noCacheCost - actual),
296
+ savingsRate: noCacheCost > 0 ? (noCacheCost - actual) / noCacheCost : 0,
297
+ scenario5mCost: round(scenario5mCost),
298
+ extraCostIf5m: round(scenario5mCost - actual),
299
+ extraCostIf5mApplicable: price.cacheWrite != null && write1h > 0,
300
+ };
301
+ }
302
+
251
303
  /**
252
304
  * Price a set of sessions each at its own model and sum. One estimateCost over
253
305
  * the summed totals priced every token at whichever session came first in the
254
306
  * list — sorted by mtime, so a haiku scratch session touched earliest set the
255
307
  * rate for a day of Fable work and understated the saving about tenfold.
256
308
  * Sessions whose model has no known rate are skipped, as month-spend does.
309
+ * `estimate` lets a caller price by path (see claude-price.js); null skips a session.
257
310
  */
258
- export function estimateCostAcross(sessions) {
311
+ export function estimateCostAcross(sessions, estimate = estimateCost) {
259
312
  const acc = { actual: 0, noCacheCost: 0, scenario5mCost: 0, extraCostIf5mApplicable: false };
260
313
  const tiers = new Map();
261
314
  for (const s of sessions || []) {
262
315
  if (!s?.totals) continue;
263
316
  let c;
264
- try { c = estimateCost(s.totals, s.model); } catch { continue; }
317
+ try { c = estimate(s.totals, s.model); } catch { continue; }
318
+ if (!c) continue;
265
319
  acc.actual += c.actual;
266
320
  acc.noCacheCost += c.noCacheCost;
267
321
  acc.scenario5mCost += c.scenario5mCost;
@@ -54,6 +54,12 @@ const GRAY = fg(100, 116, 139, '\x1b[90m');
54
54
  const BOLD = '\x1b[1m';
55
55
 
56
56
  export function formatMoney(usd) {
57
+ // The sign leads the currency symbol ("-$0.12"); a loss that rounds to zero
58
+ // prints as plain "$0.00" rather than "-$0.00".
59
+ if (usd < 0) {
60
+ const abs = formatMoney(-usd);
61
+ return abs === '$0.00' ? abs : `-${abs}`;
62
+ }
57
63
  // Boundaries sit at 999.5/999500 so a value that would round up into the
58
64
  // next band's width ($999.9 → "$1000") jumps to that band's unit instead —
59
65
  // otherwise "$1000" renders visually larger than "$1.0K".
@@ -512,8 +518,8 @@ export function formatReport(data, { color = true, verbose = false, timer = true
512
518
  // Tied to MIN_VOTES rather than a literal so the two cannot drift: the chip
513
519
  // appears exactly when learning has had its chance and still came up short.
514
520
  const unresolvedRuns = Number(data.unresolvedRuns) || 0;
515
- const delegateSeg = delegationSaved > 0
516
- ? `${c(GREEN)}${delegateLabel}${c(RESET)} ${formatMoney(delegationSaved)}`
521
+ const delegateSeg = delegationSaved !== 0
522
+ ? `${c(delegationSaved < 0 ? RED : GREEN)}${delegateLabel}${c(RESET)} ${formatMoney(delegationSaved)}`
517
523
  : (unresolvedRuns >= MIN_VOTES
518
524
  ? `${c(YELLOW)}${g.routing} ${unresolvedRuns} unresolved${c(RESET)}`
519
525
  : null);
@@ -552,8 +558,12 @@ export function formatReport(data, { color = true, verbose = false, timer = true
552
558
  // verbose: "Sep spend $42.1 (since Sep 1)"
553
559
  const month = data.monthSpend;
554
560
  let monthSeg = null;
555
- if (month && Number(month.usd) > 0) {
556
- const amt = formatMoney(Number(month.usd));
561
+ // Gateway sessions whose model the gateway does not price are left out of the
562
+ // sum and counted, so a partial total says so and an all-unpriced month is n/a.
563
+ const monthUnpriced = Number(month?.unpriced) || 0;
564
+ if (month && (Number(month.usd) > 0 || monthUnpriced > 0)) {
565
+ const amt = (Number(month.usd) > 0 ? formatMoney(Number(month.usd)) : 'n/a') +
566
+ (monthUnpriced > 0 ? ` +${monthUnpriced} unpriced` : '');
557
567
  if (isIcon) {
558
568
  monthSeg = `${c(GRAY)}${g.month} ${month.label} ${amt}${c(RESET)}`;
559
569
  } else if (verbose) {
@@ -576,7 +586,7 @@ export function formatReport(data, { color = true, verbose = false, timer = true
576
586
  // text: "Routing saved $9.8 | opus→haiku 2× $6.4 · fable→sonnet 1× $3.4"
577
587
  const totals = data.delegationTotals;
578
588
  let totalsLine = null;
579
- if (!singleLine && totals && Number(totals.total) > 0) {
589
+ if (!singleLine && totals && Number(totals.total) !== 0 && Number.isFinite(Number(totals.total))) {
580
590
  const head = isIcon ? `${g.routing} Routing saved` : 'Routing saved';
581
591
  // Model changes behind the total, family-level and version-free: `opus →
582
592
  // haiku 2× $0.6`. Versions bump constantly and add nothing here — the
@@ -595,9 +605,10 @@ export function formatReport(data, { color = true, verbose = false, timer = true
595
605
  const pairText = pairs
596
606
  .map((p) => `${c(GRAY)}${p.from}→${p.to} ${p.runs}× ${formatMoney(p.usd)}${c(RESET)}`)
597
607
  .join(` ${c(GRAY)}·${c(RESET)} `);
608
+ const tone = Number(totals.total) < 0 ? RED : GREEN; // a net loss must not read as a win
598
609
  totalsLine =
599
- `${c(GREEN)}${c(BOLD)}${head}${c(RESET)} ` +
600
- `${c(GREEN)}${formatMoney(Number(totals.total) || 0)}${c(RESET)}` +
610
+ `${c(tone)}${c(BOLD)}${head}${c(RESET)} ` +
611
+ `${c(tone)}${formatMoney(Number(totals.total) || 0)}${c(RESET)}` +
601
612
  (pairText ? ` ${c(GRAY)}|${c(RESET)} ${pairText}` : '');
602
613
  }
603
614
 
@@ -253,6 +253,7 @@ export function formatReport({ summary: sum, trend, ttl, anomalies, cost, option
253
253
  lines.push(' ' + tableRow(['Extra cost if 5m-only', `+$${cost.extraCostIf5m}`], costW, costA));
254
254
  }
255
255
  lines.push(' ' + tableBot(costW));
256
+ if (cost.unpriced > 0) lines.push(` ${cost.unpriced} session(s) not priced by the gateway are left out.`);
256
257
  lines.push('');
257
258
 
258
259
  // Daily trend
@@ -0,0 +1,39 @@
1
+ // LiteLLM /model/info prices, shared by the Codex and Claude sides. Per-token USD; both sides
2
+ // price a call by the path it took, so these are only ever the gateway's own numbers.
3
+ export const PRICE_MAX_AGE_MS = 7 * 86400000;
4
+ export const rate = (value) => Number.isFinite(value) && value >= 0 && value < 1 ? value : null;
5
+
6
+ export function deploymentPrice(row) {
7
+ const info = row?.model_info || {};
8
+ const mode = typeof info.mode === 'string' ? info.mode : null;
9
+ if (mode && !['chat', 'responses', 'completion'].includes(mode)) return null;
10
+ const input = rate(info.input_cost_per_token), output = rate(info.output_cost_per_token);
11
+ if (input === null || output === null) return null;
12
+ return { input, output, cacheRead: rate(info.cache_read_input_token_cost), cacheWrite: rate(info.cache_creation_input_token_cost),
13
+ cacheWrite1h: rate(info.cache_creation_input_token_cost_above_1hr) };
14
+ }
15
+
16
+ /** Per-token prices by alias. Load-balanced deployments must agree, like cache policy. */
17
+ export function gatewayPrices(payload) {
18
+ if (!Array.isArray(payload?.data)) throw new Error('LiteLLM model/info returned no model list');
19
+ const groups = new Map();
20
+ for (const row of payload.data) {
21
+ if (typeof row?.model_name !== 'string' || row.model_name.length > 256) continue;
22
+ groups.set(row.model_name, [...(groups.get(row.model_name) || []), deploymentPrice(row)]);
23
+ }
24
+ return Object.fromEntries([...groups].map(([name, prices]) => [name,
25
+ prices[0] && prices.every((p) => JSON.stringify(p) === JSON.stringify(prices[0])) ? prices[0] : null]));
26
+ }
27
+
28
+ /**
29
+ * USD for a token mix at one model's prices, or null when a needed rate is
30
+ * missing. Cached input without a cache-read rate is unknown, not free.
31
+ */
32
+ export function codexRunCost({ input = 0, cached = 0, cacheWrite = 0, output = 0 }, price) {
33
+ if (!price || price.input === null || price.output === null) return null;
34
+ if (cached > 0 && price.cacheRead === null) return null;
35
+ // cache_write_input_tokens is billed at the creation rate when one is published, else as input.
36
+ const uncached = Math.max(0, input - cached - cacheWrite);
37
+ return uncached * price.input + cached * (price.cacheRead ?? 0) + cacheWrite * (price.cacheWrite ?? price.input) + output * price.output;
38
+ }
39
+
@@ -27,6 +27,7 @@
27
27
  import { gatewayBase } from './gateway-auth.js';
28
28
  import { loadProfileMap, updateProfileMap, resetModelAliasCache, profileIdFrom, isGatewayModelId } from './model-alias.js';
29
29
  import { isRecognizedModelId } from './cost.js';
30
+ import { deploymentPrice, PRICE_MAX_AGE_MS } from './gateway-prices.js';
30
31
  import { debug } from './debug.js';
31
32
 
32
33
  /** 게이트웨이 모델 맵의 신선도 기준. 배포 구성은 하루에 여러 번 바뀌지 않습니다. */
@@ -70,6 +71,23 @@ function normalizeCandidate(id) {
70
71
  return s.replace(/\[[^\]]*\]$/, '');
71
72
  }
72
73
 
74
+ /**
75
+ * 배포 항목 하나의 별칭을 고릅니다. model_name → model_info.base_model →
76
+ * litellm_params.model 순으로 정규화해 isRecognizedModelId()를 통과하는 첫
77
+ * 후보이며, 없으면 null입니다. 별칭 추출과 가격 키 등록이 같은 판정을 쓰도록
78
+ * 한 곳에 둡니다.
79
+ */
80
+ function pickAlias(item) {
81
+ const modelName = typeof item.model_name === 'string' ? item.model_name : '';
82
+ const baseModel = typeof item.model_info?.base_model === 'string' ? item.model_info.base_model : '';
83
+ const rawModel = typeof item.litellm_params?.model === 'string' ? item.litellm_params.model : '';
84
+ for (const candidate of [modelName, baseModel, rawModel]) {
85
+ const normalized = normalizeCandidate(candidate);
86
+ if (normalized && isRecognizedModelId(normalized)) return normalized;
87
+ }
88
+ return null;
89
+ }
90
+
73
91
  /**
74
92
  * `/model/info` 응답에서 프로파일 ID(또는 계열명 없는 사내 별칭) → 모델 계열
75
93
  * 매핑을 뽑아냅니다. 순수 함수입니다: 네트워크·파일 I/O가 없고, 잘못된 입력에도
@@ -99,17 +117,8 @@ export function deriveAliasesFromModelInfo(payload) {
99
117
  for (const item of data) {
100
118
  if (!item || typeof item !== 'object') continue;
101
119
  const modelName = typeof item.model_name === 'string' ? item.model_name : '';
102
- const baseModel = typeof item.model_info?.base_model === 'string' ? item.model_info.base_model : '';
103
120
  const rawModel = typeof item.litellm_params?.model === 'string' ? item.litellm_params.model : '';
104
-
105
- let alias = null;
106
- for (const candidate of [modelName, baseModel, rawModel]) {
107
- const normalized = normalizeCandidate(candidate);
108
- if (normalized && isRecognizedModelId(normalized)) {
109
- alias = normalized;
110
- break;
111
- }
112
- }
121
+ const alias = pickAlias(item);
113
122
 
114
123
  if (!alias) {
115
124
  if (modelName) skipped.push(modelName);
@@ -134,6 +143,58 @@ export function deriveAliasesFromModelInfo(payload) {
134
143
  return { aliases, skipped, count: data.length };
135
144
  }
136
145
 
146
+ /**
147
+ * `/model/info` 응답에서 모델 키 → 토큰당 가격 표를 만듭니다. 순수 함수입니다.
148
+ *
149
+ * 한 배포 항목의 가격을 model_name, 정규화한 model_name·base_model·
150
+ * litellm_params.model, 그리고 별칭 추출이 고른 별칭 아래에 모두 등록합니다.
151
+ * 트랜스크립트의 모델 문자열이 어느 모양으로 오든 찾을 수 있게 하려는 것입니다.
152
+ * 같은 키에 서로 다른 가격이 들어오면 어느 쪽이 맞는지 알 수 없으므로 null로
153
+ * 둡니다. 값은 숫자뿐이고, 키는 normalizeCandidate를 거쳐 ARN이 리소스 부분으로
154
+ * 줄어 있으므로 AWS 계정 ID가 파일에 남지 않습니다.
155
+ *
156
+ * @returns {Record<string, object|null>}
157
+ */
158
+ export function deriveGatewayPrices(payload) {
159
+ const data = Array.isArray(payload?.data) ? payload.data : [];
160
+ const prices = new Map();
161
+ for (const item of data) {
162
+ if (!item || typeof item !== 'object') continue;
163
+ const price = deploymentPrice(item);
164
+ const keys = new Set([
165
+ // 원문 model_name이 ARN이면 계정 ID가 있으므로 원문 키는 쓰지 않습니다.
166
+ typeof item.model_name === 'string' && !isGatewayModelId(item.model_name) ? item.model_name : '',
167
+ normalizeCandidate(item.model_name),
168
+ normalizeCandidate(item.model_info?.base_model),
169
+ normalizeCandidate(item.litellm_params?.model),
170
+ pickAlias(item) || '',
171
+ ]);
172
+ for (const key of keys) {
173
+ if (!key) continue;
174
+ if (!prices.has(key)) prices.set(key, price);
175
+ else if (JSON.stringify(prices.get(key)) !== JSON.stringify(price)) prices.set(key, null);
176
+ }
177
+ }
178
+ return Object.fromEntries(prices);
179
+ }
180
+
181
+ /**
182
+ * LiteLLM 경로로 나간 호출의 모델 가격(토큰당 USD). 게이트웨이 표에 없거나,
183
+ * 표가 PRICE_MAX_AGE_MS보다 오래됐거나, 키가 모호하면 null입니다. 정가표로
184
+ * 대신 채우지 않습니다. 호출이 실제로 지난 경로의 가격만 씁니다.
185
+ */
186
+ export function gatewayPriceFor(model, env = process.env, now = Date.now()) {
187
+ const gateway = readGatewayModelMap(env);
188
+ if (!gateway?.prices || typeof gateway.prices !== 'object') return null;
189
+ const fetchedAt = Date.parse(gateway.fetchedAt);
190
+ if (!Number.isFinite(fetchedAt) || fetchedAt > now || now - fetchedAt > PRICE_MAX_AGE_MS) return null;
191
+ if (typeof model !== 'string' || !model) return null;
192
+ for (const key of [model, normalizeCandidate(model)]) {
193
+ if (key && Object.hasOwn(gateway.prices, key)) return gateway.prices[key] || null;
194
+ }
195
+ return null;
196
+ }
197
+
137
198
  /**
138
199
  * profile-map.json의 gateway 절을 읽습니다. base가 현재 gatewayBase(env)와
139
200
  * 다르면 다른 게이트웨이의 캐시이므로 null을 돌려줍니다. 예산 캐시(budgetWindow)
@@ -240,6 +301,7 @@ export async function refreshGatewayModelMap({ base, key, fetchImpl = fetch } =
240
301
  base,
241
302
  fetchedAt: new Date().toISOString(),
242
303
  aliases: result.aliases,
304
+ prices: deriveGatewayPrices(payload),
243
305
  skipped: result.skipped,
244
306
  count: result.count,
245
307
  },
@@ -34,6 +34,7 @@ import { homedir } from 'node:os';
34
34
  import { userDataDir } from './paths.js';
35
35
  import { userLanguage } from './config.js';
36
36
  import { agentPhrase, agentPhraseEn } from './agents.js';
37
+ import { signedUsd } from './money.js';
37
38
 
38
39
  // Post-promotion delegated-category error rate above this flags the rule
39
40
  // for review (rule-health). Calibrated against local T0 avg error incidence.
@@ -356,7 +357,7 @@ export function renderModelRatchet(rules, lang = userLanguage()) {
356
357
  ? `preset (curated), registered ${r.promotedAt}`
357
358
  : `×${r.count || 0}, err ${pct(r.errRate)}%, seen ${r.lastSeen || r.promotedAt}`;
358
359
  if (!r.delegatedRuns) return base;
359
- const saved = r.savedUsd ? `, saved ~$${r.savedUsd.toFixed(2)}` : '';
360
+ const saved = r.savedUsd ? `, saved ~${signedUsd(r.savedUsd)}` : '';
360
361
  return `${base}, delegated ×${r.delegatedRuns} err ${pct(r.delegatedErrRate)}%${saved}`;
361
362
  };
362
363
  // Merged T2+T1 rules carry two caps, so they state both once rather than
package/src/money.js ADDED
@@ -0,0 +1,9 @@
1
+ /**
2
+ * A signed dollar amount for text output: "-$0.12" for a loss, "$0.12" for a
3
+ * saving. Routing savings are recorded signed, and "~$-0.12" misreads as a
4
+ * formatting glitch rather than a loss.
5
+ */
6
+ export function signedUsd(usd, digits = 2) {
7
+ const fixed = Math.abs(usd).toFixed(digits);
8
+ return usd < 0 && Number(fixed) !== 0 ? `-$${fixed}` : `$${fixed}`;
9
+ }
@@ -4,10 +4,12 @@
4
4
  * LiteLLM·Bedrock처럼 5h/7d cap 이 아예 없는 게이트웨이 사용자도
5
5
  * "이번 달에 얼마나 썼는지"는 늘 궁금하므로, 세션 로그를 기반으로
6
6
  * 달력 월 단위 지출을 통계선에 상시 노출합니다. 모델 단가는 세션마다
7
- * 다르므로 세션별로 estimateCost 를 적용해 합산합니다.
7
+ * 다르므로 세션별로 호출 경로에 맞는 가격(sessionCost)을 적용해 합산합니다.
8
+ * LiteLLM 게이트웨이 세션은 게이트웨이 가격표로만 계산하고, 표에 없는 모델은
9
+ * 합계에서 빼고 unpriced 로 셉니다. 정가표로 대신 채우지 않습니다.
8
10
  */
9
11
 
10
- import { estimateCost } from './cost.js';
12
+ import { sessionCost } from './claude-price.js';
11
13
 
12
14
  /** 이번 달 1일 00:00(로컬)의 epoch ms. */
13
15
  export function monthStartMs(now = new Date()) {
@@ -27,21 +29,24 @@ export function monthLabel(now = new Date()) {
27
29
  *
28
30
  * @param {Array} sessions parseAllSessions 결과
29
31
  * @param {Date} [now]
30
- * @returns {{usd:number, sessions:number, sinceMs:number, label:string}}
32
+ * @returns {{usd:number, sessions:number, unpriced:number, sinceMs:number, label:string}}
31
33
  */
32
34
  export function monthSpend(sessions, now = new Date()) {
33
35
  const since = monthStartMs(now);
34
36
  let usd = 0;
35
37
  let count = 0;
38
+ let unpriced = 0;
36
39
  for (const s of sessions || []) {
37
40
  if (!s || !s.endTime || s.endTime.getTime() < since) continue;
38
41
  if (!s.totals) continue;
39
42
  try {
40
- usd += estimateCost(s.totals, s.model).actual;
43
+ const c = sessionCost(s.totals, s.model);
44
+ if (!c) { unpriced += 1; continue; }
45
+ usd += c.actual;
41
46
  count += 1;
42
47
  } catch {
43
48
  // 단가를 모르는 모델은 합계에서 빠집니다. 통계선에서는 침묵이 낫습니다.
44
49
  }
45
50
  }
46
- return { usd, sessions: count, sinceMs: since, label: monthLabel(now) };
51
+ return { usd, sessions: count, unpriced, sinceMs: since, label: monthLabel(now) };
47
52
  }
@@ -0,0 +1,33 @@
1
+ // OpenAI list prices for Codex calls that go straight to OpenAI (ChatGPT/OpenAI login).
2
+ // Source: https://developers.openai.com/api/docs/pricing , "Standard" tier, short-context
3
+ // rates, checked 2026-10-01. Long-context surcharges are not modeled, so requests past the
4
+ // long-context threshold are under-priced. Calls through a LiteLLM gateway never use this table.
5
+ export const OPENAI_PRICES_CHECKED_AT = Date.parse('2026-10-01T00:00:00Z');
6
+
7
+ // Per-1M-token numbers as listed on the page; null where the page shows "-".
8
+ const PER_MILLION = {
9
+ 'gpt-6-astra': [10, 1, 12.5, 50],
10
+ 'gpt-6.1-sol': [2, 0.1, 2.5, 10],
11
+ 'gpt-6-luna': [0.1, 0.01, 0.125, 0.5],
12
+ 'gpt-6-sol': [2, 0.2, 2.5, 10],
13
+ 'gpt-5.6-sol': [4, 0.4, 5, 20],
14
+ 'gpt-5.6-terra': [2, 0.2, 2.5, 12],
15
+ 'gpt-5.6-luna': [0.2, 0.02, 0.25, 1.2],
16
+ 'gpt-5.5': [5, 0.5, null, 30],
17
+ 'gpt-5.5-pro': [30, null, null, 180],
18
+ 'gpt-5.4': [2.5, 0.25, null, 15],
19
+ 'gpt-5.4-pro': [30, null, null, 180],
20
+ 'gpt-5.4-mini': [0.75, 0.075, null, 4.5],
21
+ 'gpt-5.4-nano': [0.2, 0.02, null, 1.25],
22
+ 'gpt-5.2': [1.75, 0.175, null, 14],
23
+ 'gpt-5.2-pro': [21, null, null, 168],
24
+ 'gpt-5.1': [1.25, 0.125, null, 10],
25
+ 'gpt-5': [1.25, 0.125, null, 10],
26
+ };
27
+
28
+ // Parse "<n>e-6" instead of dividing so 0.1 per 1M is exactly 1e-7, without float noise.
29
+ const perToken = (n) => (n === null ? null : Number(`${n}e-6`));
30
+
31
+ /** model -> per-token USD `{ input, cacheRead, cacheWrite, output }`. */
32
+ export const OPENAI_LIST_PRICES = Object.fromEntries(Object.entries(PER_MILLION).map(([model, [input, cacheRead, cacheWrite, output]]) =>
33
+ [model, { input: perToken(input), cacheRead: perToken(cacheRead), cacheWrite: perToken(cacheWrite), output: perToken(output) }]));