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.
- package/README.ko.md +4 -0
- package/README.md +4 -0
- package/bin/cli.js +2 -2
- package/docs/CODEX.ko.md +40 -5
- package/docs/CODEX.md +69 -12
- package/package.json +1 -1
- package/src/claude-price.js +28 -0
- package/src/codex-brief.js +20 -0
- package/src/codex-cache-policy.js +26 -41
- package/src/codex-delegation.js +45 -5
- package/src/codex-doctor.js +1 -1
- package/src/codex-harness.js +20 -9
- package/src/codex-hooks.js +106 -18
- package/src/codex-ledger.js +94 -16
- package/src/codex-panel.js +12 -7
- package/src/codex-parser.js +41 -4
- package/src/codex-route-scan.js +14 -7
- package/src/codex-rule-health.js +18 -0
- package/src/commands/codex-delegate.js +12 -3
- package/src/commands/codex.js +18 -10
- package/src/commands/route-scan.js +4 -3
- package/src/cost.js +57 -3
- package/src/formatters/statusline.js +18 -7
- package/src/formatters/table.js +1 -0
- package/src/gateway-prices.js +39 -0
- package/src/litellm-models.js +72 -10
- package/src/model-rules.js +2 -1
- package/src/money.js +9 -0
- package/src/month-spend.js +10 -5
- package/src/openai-prices.js +33 -0
- package/src/route-scan.js +16 -8
- package/src/savings-ledger.js +10 -6
|
@@ -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
|
-
|
|
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
|
}
|
package/src/commands/codex.js
CHANGED
|
@@ -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
|
|
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 {
|
|
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) ? ` (
|
|
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.
|
|
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 {
|
|
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)}% · 절감
|
|
158
|
-
: `measured ×${r.delegatedRuns} · err ${Math.round((r.delegatedErrRate || 0) * 100)}% · saved
|
|
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 =
|
|
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
|
|
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
|
-
|
|
556
|
-
|
|
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)
|
|
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(
|
|
600
|
-
`${c(
|
|
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
|
|
package/src/formatters/table.js
CHANGED
|
@@ -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
|
+
|
package/src/litellm-models.js
CHANGED
|
@@ -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
|
},
|
package/src/model-rules.js
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/src/month-spend.js
CHANGED
|
@@ -4,10 +4,12 @@
|
|
|
4
4
|
* LiteLLM·Bedrock처럼 5h/7d cap 이 아예 없는 게이트웨이 사용자도
|
|
5
5
|
* "이번 달에 얼마나 썼는지"는 늘 궁금하므로, 세션 로그를 기반으로
|
|
6
6
|
* 달력 월 단위 지출을 통계선에 상시 노출합니다. 모델 단가는 세션마다
|
|
7
|
-
* 다르므로 세션별로
|
|
7
|
+
* 다르므로 세션별로 호출 경로에 맞는 가격(sessionCost)을 적용해 합산합니다.
|
|
8
|
+
* LiteLLM 게이트웨이 세션은 게이트웨이 가격표로만 계산하고, 표에 없는 모델은
|
|
9
|
+
* 합계에서 빼고 unpriced 로 셉니다. 정가표로 대신 채우지 않습니다.
|
|
8
10
|
*/
|
|
9
11
|
|
|
10
|
-
import {
|
|
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
|
-
|
|
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) }]));
|