sprag-cli 3.55.0 → 3.57.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 +5 -1
- package/README.md +5 -1
- package/bin/cli.js +60 -19
- package/bin/postinstall.js +22 -0
- package/docs/CODEX.ko.md +44 -5
- package/docs/CODEX.md +74 -12
- package/package.json +2 -2
- package/src/agents.js +16 -2
- package/src/brief.js +17 -8
- package/src/claude-price.js +49 -0
- package/src/codex-brief.js +55 -2
- package/src/codex-cache-policy.js +26 -41
- package/src/codex-delegation.js +56 -8
- package/src/codex-doctor.js +1 -1
- package/src/codex-harness.js +63 -18
- package/src/codex-hooks.js +129 -20
- package/src/codex-ledger.js +150 -16
- package/src/codex-panel.js +51 -26
- package/src/codex-parser.js +41 -4
- package/src/codex-route-scan.js +14 -7
- package/src/codex-rule-health.js +41 -0
- package/src/commands/brief.js +14 -3
- package/src/commands/codex-delegate.js +17 -5
- package/src/commands/codex.js +20 -10
- package/src/commands/harness.js +6 -0
- package/src/commands/route-scan.js +4 -3
- package/src/compact-window.js +12 -4
- package/src/config.js +3 -3
- package/src/cost.js +62 -5
- package/src/ctx-window-cache.js +61 -0
- package/src/delegation-guard.js +15 -9
- package/src/doc2md.cjs +22 -8
- package/src/fig2md.cjs +7 -1
- package/src/formatters/statusline.js +22 -11
- package/src/formatters/table.js +19 -10
- package/src/gateway-auth.js +31 -3
- package/src/gateway-prices.js +39 -0
- package/src/harness.js +43 -12
- package/src/history.js +6 -2
- package/src/hook-manager.js +54 -32
- package/src/installer.js +286 -153
- package/src/korean-lint.cjs +40 -7
- package/src/litellm-budget.js +10 -1
- package/src/litellm-models.js +72 -10
- package/src/model-rules.js +90 -42
- package/src/money.js +9 -0
- package/src/month-spend.js +23 -12
- package/src/openai-prices.js +33 -0
- package/src/parser.js +232 -56
- package/src/postinstall.js +32 -0
- package/src/route-caps.js +74 -0
- package/src/route-inject.js +31 -7
- package/src/route-scan.js +93 -33
- package/src/savings-ledger.js +43 -11
- package/src/seed-rules.js +3 -4
- package/src/session-cache.js +35 -1
- package/src/state-file.js +43 -0
- package/src/stats.js +20 -5
- package/src/stdin-payload.js +18 -4
package/src/codex-parser.js
CHANGED
|
@@ -7,16 +7,45 @@ import { codexUserDir } from './agent.js';
|
|
|
7
7
|
const count = (n) => Number.isSafeInteger(n) && n >= 0 ? n : 0;
|
|
8
8
|
const emptyTotals = () => ({ input: 0, cacheRead: 0, cacheCreation: 0, ephemeral5m: 0, ephemeral1h: 0, output: 0 });
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* A child spawned with fork_turns "all" writes its own session_meta first,
|
|
12
|
+
* then a copy of the parent's session_meta and history, then its own work.
|
|
13
|
+
* The copy belongs to the parent: identity comes from the first session_meta,
|
|
14
|
+
* and the copied lines are skipped so the parent's request is not counted a
|
|
15
|
+
* second time. The copy ends at the child's own thread_settings_applied, or
|
|
16
|
+
* at the first line written after the batch copy, whichever comes first.
|
|
17
|
+
*/
|
|
18
|
+
export function forkedCopyFilter() {
|
|
19
|
+
let ownId = null, copyStamp = null;
|
|
20
|
+
return (e) => {
|
|
21
|
+
const p = e?.payload;
|
|
22
|
+
if (e?.type === 'session_meta') {
|
|
23
|
+
const id = p?.id || p?.session_id || null;
|
|
24
|
+
if (ownId === null) { ownId = id; return false; }
|
|
25
|
+
// A resumed rollout can repeat its own meta; only another thread's is a copy.
|
|
26
|
+
if (id && id !== ownId) { copyStamp = e.timestamp ?? null; return true; }
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
if (copyStamp === null) return false;
|
|
30
|
+
const own = e?.type === 'event_msg' && p?.type === 'thread_settings_applied' && p.thread_id === ownId;
|
|
31
|
+
if (own || e?.timestamp !== copyStamp) { copyStamp = null; return false; }
|
|
32
|
+
return true;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
10
36
|
function updateSnapshot(session, e) {
|
|
11
37
|
const p = e?.payload;
|
|
12
38
|
if (!p || typeof p !== 'object') return;
|
|
13
39
|
const activity = Date.parse(e.timestamp);
|
|
14
40
|
if (Number.isFinite(activity)) session.lastActivity = new Date(activity);
|
|
15
41
|
if (e.type === 'session_meta') {
|
|
42
|
+
// Callers drop forked copies, so a later meta can only be this thread's own repeat.
|
|
43
|
+
if (session.sessionId) return;
|
|
16
44
|
session.sessionId = p.id || p.session_id || null;
|
|
17
45
|
session.projectDir = typeof p.cwd === 'string' ? p.cwd : '';
|
|
18
46
|
session.source = p.source ?? null;
|
|
19
47
|
session.isSubagent = typeof p.source === 'object' && p.source !== null && 'subagent' in p.source;
|
|
48
|
+
session.forkedFrom = typeof p.forked_from_id === 'string' ? p.forked_from_id : null;
|
|
20
49
|
session.branch = typeof p.git?.branch === 'string' ? p.git.branch : null;
|
|
21
50
|
session.provider = typeof p.model_provider === 'string' ? p.model_provider : null;
|
|
22
51
|
}
|
|
@@ -63,8 +92,12 @@ export function readCodexSnapshot(filePath, { tailBytes = 256 * 1024 } = {}) {
|
|
|
63
92
|
const lines = readCodexTailLines(filePath, tailBytes);
|
|
64
93
|
if (!lines) return null;
|
|
65
94
|
const snapshot = {};
|
|
95
|
+
const copied = forkedCopyFilter();
|
|
66
96
|
for (const line of lines) {
|
|
67
|
-
try {
|
|
97
|
+
try {
|
|
98
|
+
const e = JSON.parse(line);
|
|
99
|
+
if (!copied(e)) updateSnapshot(snapshot, e);
|
|
100
|
+
} catch { /* Partial or malformed record. */ }
|
|
68
101
|
}
|
|
69
102
|
return snapshot;
|
|
70
103
|
}
|
|
@@ -164,7 +197,8 @@ const emptyTurn = (turnId) => ({ turnId, startedAt: null, endedAt: null, aborted
|
|
|
164
197
|
* structured event described, so a call is never counted twice.
|
|
165
198
|
*/
|
|
166
199
|
export async function parseCodexTurns(filePath, { cutoffMs = -Infinity } = {}) {
|
|
167
|
-
const session = { filePath, sessionId: null, projectDir: '', isSubagent: false, parentThreadId: null, provider: null, turns: [] };
|
|
200
|
+
const session = { filePath, sessionId: null, projectDir: '', isSubagent: false, parentThreadId: null, forkedFrom: null, provider: null, turns: [] };
|
|
201
|
+
const copied = forkedCopyFilter();
|
|
168
202
|
const turns = new Map();
|
|
169
203
|
const turnOf = (id) => {
|
|
170
204
|
if (!id) return null;
|
|
@@ -184,12 +218,14 @@ export async function parseCodexTurns(filePath, { cutoffMs = -Infinity } = {}) {
|
|
|
184
218
|
let e;
|
|
185
219
|
try { e = JSON.parse(line); } catch { continue; }
|
|
186
220
|
const p = e?.payload;
|
|
187
|
-
if (!p || typeof p !== 'object') continue;
|
|
221
|
+
if (!p || typeof p !== 'object' || copied(e)) continue;
|
|
188
222
|
if (e.type === 'session_meta') {
|
|
223
|
+
if (session.sessionId) continue;
|
|
189
224
|
session.sessionId = p.id || p.session_id || null;
|
|
190
225
|
session.projectDir = typeof p.cwd === 'string' ? p.cwd : '';
|
|
191
226
|
session.isSubagent = typeof p.source === 'object' && p.source !== null && 'subagent' in p.source;
|
|
192
227
|
session.parentThreadId = p.source?.subagent?.thread_spawn?.parent_thread_id || null;
|
|
228
|
+
session.forkedFrom = typeof p.forked_from_id === 'string' ? p.forked_from_id : null;
|
|
193
229
|
session.provider = typeof p.model_provider === 'string' ? p.model_provider : null;
|
|
194
230
|
continue;
|
|
195
231
|
}
|
|
@@ -303,6 +339,7 @@ export async function parseCodexSessionFile(filePath, { cutoffMs = -Infinity } =
|
|
|
303
339
|
lastContextTokens: null, rateLimits: null, lastActivity: null, effort: null,
|
|
304
340
|
cacheActivity: null,
|
|
305
341
|
};
|
|
342
|
+
const copied = forkedCopyFilter();
|
|
306
343
|
let previous = null;
|
|
307
344
|
const stream = createReadStream(filePath, { encoding: 'utf8' });
|
|
308
345
|
const lines = createInterface({ input: stream, crlfDelay: Infinity });
|
|
@@ -310,7 +347,7 @@ export async function parseCodexSessionFile(filePath, { cutoffMs = -Infinity } =
|
|
|
310
347
|
let e;
|
|
311
348
|
try { e = JSON.parse(line); } catch { continue; }
|
|
312
349
|
const p = e?.payload;
|
|
313
|
-
if (!p) continue;
|
|
350
|
+
if (!p || copied(e)) continue;
|
|
314
351
|
updateSnapshot(session, e);
|
|
315
352
|
if (e.type !== 'event_msg' || p.type !== 'token_count') continue;
|
|
316
353
|
if (!p.info) continue;
|
package/src/codex-route-scan.js
CHANGED
|
@@ -8,7 +8,8 @@ import { discoverCodexSessionFiles, parseCodexTurns } from './codex-parser.js';
|
|
|
8
8
|
import { categorize, isSkippable, tierOf, calibrateThresholds, MIN_RECURRENCE,
|
|
9
9
|
RESCAN_MIN_INTERVAL_MS, RESCAN_BIG_DELTA_BYTES, RESCAN_MAX_AGE_MS } from './route-scan.js';
|
|
10
10
|
import { codexPromptCategory, loadCodexModelRules } from './codex-delegation.js';
|
|
11
|
-
import { codexRunCost, readCodexPrices } from './codex-cache-policy.js';
|
|
11
|
+
import { codexRunCost, codexPriceBook, isDirectOpenAI, readCodexPrices } from './codex-cache-policy.js';
|
|
12
|
+
import { OPENAI_PRICES_CHECKED_AT } from './openai-prices.js';
|
|
12
13
|
import { refreshCodexLedger } from './codex-ledger.js';
|
|
13
14
|
|
|
14
15
|
/*
|
|
@@ -81,11 +82,12 @@ export async function shouldRescanCodex(cache, { home, days = 14, now = Date.now
|
|
|
81
82
|
* a rule target, so a gateway that also serves unrelated models (embeddings,
|
|
82
83
|
* other vendors) never has one suggested blind.
|
|
83
84
|
*/
|
|
84
|
-
function pickTarget(group, { prices, known }) {
|
|
85
|
-
const
|
|
85
|
+
function pickTarget(group, { prices, known, home }) {
|
|
86
|
+
const book = codexPriceBook(group.provider, prices, { home });
|
|
87
|
+
const cost = (model) => book ? codexRunCost(group.tokens, book.prices[model]) : null;
|
|
86
88
|
const parent = cost(group.from);
|
|
87
89
|
if (parent === null) return { suggestedModel: null, alternatives: [], estSavedUsd: null };
|
|
88
|
-
const cheaper = Object.keys(
|
|
90
|
+
const cheaper = Object.keys(book.prices).filter((m) => m !== group.from && book.prices[m])
|
|
89
91
|
.map((m) => ({ model: m, usd: cost(m) })).filter((x) => x.usd !== null && x.usd < parent)
|
|
90
92
|
.sort((a, b) => a.usd - b.usd);
|
|
91
93
|
const eligible = cheaper.filter((x) => known.has(`${group.provider}|${x.model}`));
|
|
@@ -151,7 +153,11 @@ export async function runCodexRouteScan({ days = 14, now = Date.now(), home, dir
|
|
|
151
153
|
(!r.provider || r.provider === g.provider) &&
|
|
152
154
|
(r.scope === 'global' || r.targetRoot === g.projectRoot));
|
|
153
155
|
const known = new Set([...observed, ...rules.filter((r) => r.provider).map((r) => `${r.provider}|${r.model}`)]);
|
|
154
|
-
|
|
156
|
+
const target = cfg?.codex?.delegateTarget?.model;
|
|
157
|
+
if (target) {
|
|
158
|
+
if (prices?.provider) known.add(`${prices.provider}|${target}`);
|
|
159
|
+
if (isDirectOpenAI('openai', { home })) known.add(`openai|${target}`);
|
|
160
|
+
}
|
|
155
161
|
const prev = readCodexRouteScan({ dir });
|
|
156
162
|
const resolved = new Set(prev?.resolved || []);
|
|
157
163
|
const candidates = [...groups.values()]
|
|
@@ -160,9 +166,10 @@ export async function runCodexRouteScan({ days = 14, now = Date.now(), home, dir
|
|
|
160
166
|
.slice(0, MAX_CANDIDATES)
|
|
161
167
|
.map((g, i) => ({ id: i + 1, signature: g.signature, category: g.category, label: g.label, labelEn: g.labelEn,
|
|
162
168
|
from: g.from, provider: g.provider, projectRoot: g.projectRoot, count: g.count, tiers: g.tiers, example: g.example.slice(0, 160),
|
|
163
|
-
lastSeen: g.lastSeen || null, suggestedScope: 'project', ...pickTarget(g, { prices, known }) }));
|
|
169
|
+
lastSeen: g.lastSeen || null, suggestedScope: 'project', ...pickTarget(g, { prices, known, home }) }));
|
|
170
|
+
const priceCheckedAt = prices?.checkedAt ?? ([...groups.values()].some((g) => isDirectOpenAI(g.provider, { home })) ? OPENAI_PRICES_CHECKED_AT : null);
|
|
164
171
|
const data = { version: 1, scannedAt: new Date(now).toISOString(), days, dataBytes, totalEpisodes: episodes.length,
|
|
165
|
-
thresholds, observedModels: [...observed], priceCheckedAt
|
|
172
|
+
thresholds, observedModels: [...observed], priceCheckedAt, candidates, resolved: [...resolved] };
|
|
166
173
|
writeCache(data, dir);
|
|
167
174
|
if (refreshLedger) {
|
|
168
175
|
try { await refreshCodexLedger({ dir, home, now, prices }); } catch { /* The ledger catches up on the next scan. */ }
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { loadCodexLedger } from './codex-ledger.js';
|
|
3
|
+
import { isFailedRun } from './route-scan.js';
|
|
4
|
+
import { wilsonLowerBound, HEALTH_ERR_RATE, HEALTH_MIN_SAMPLE_DELEGATED } from './model-rules.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Measured outcome of one Codex rule, judged as Claude's rule health judges
|
|
8
|
+
* its delegations: a run fails when interrupted or when its tool-error density
|
|
9
|
+
* crosses isFailedRun's line, and the rule goes to review only when the Wilson
|
|
10
|
+
* lower bound of the failure rate clears HEALTH_ERR_RATE over enough runs.
|
|
11
|
+
*/
|
|
12
|
+
export function codexRuleHealth(rule, { events = Object.values(loadCodexLedger().events) } = {}) {
|
|
13
|
+
const since = Date.parse(rule.createdAt);
|
|
14
|
+
const runs = events.filter((e) => e && e.category === rule.category && e.from === rule.from && e.to === rule.model &&
|
|
15
|
+
(!rule.provider || !e.provider || e.provider === rule.provider) && (e.complete || e.aborted) &&
|
|
16
|
+
(e.scope == null || e.scope === rule.scope) &&
|
|
17
|
+
// A project run counts only for the project it was recorded in; events from before the field existed cannot be placed.
|
|
18
|
+
(rule.scope !== 'project' || (typeof e.targetRoot === 'string' && e.targetRoot === rule.targetRoot)) &&
|
|
19
|
+
// A rule deleted and added again starts a fresh record.
|
|
20
|
+
(!Number.isFinite(since) || (Number.isFinite(e.ts) && e.ts >= since)));
|
|
21
|
+
const errs = runs.filter((e) => e.aborted || isFailedRun({ toolErrors: e.toolErrors, calls: e.calls })).length;
|
|
22
|
+
const saved = runs.filter((e) => Number.isFinite(e.usd)).reduce((sum, e) => sum + e.usd, 0);
|
|
23
|
+
const review = runs.length >= HEALTH_MIN_SAMPLE_DELEGATED && wilsonLowerBound(errs, runs.length) > HEALTH_ERR_RATE;
|
|
24
|
+
return { runs: runs.length, errs, rate: runs.length ? errs / runs.length : 0, saved, status: review ? 'review' : 'active' };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Rules in review that apply to the project at `root`: global rules, and
|
|
29
|
+
* project rules whose targetRoot is `root`; a null `root` lists every rule. `index` is the 1-based number the
|
|
30
|
+
* rule has in `delegate rules --agent codex`, so every surface names it alike.
|
|
31
|
+
*/
|
|
32
|
+
export function codexRulesInReview({ root, rules, events }) {
|
|
33
|
+
const here = root ? resolve(root) : null;
|
|
34
|
+
const out = [];
|
|
35
|
+
(rules || []).forEach((rule, i) => {
|
|
36
|
+
if (here && rule.scope === 'project' && rule.targetRoot !== here) return;
|
|
37
|
+
const health = codexRuleHealth(rule, { events });
|
|
38
|
+
if (health.status === 'review') out.push({ index: i + 1, rule, health });
|
|
39
|
+
});
|
|
40
|
+
return out;
|
|
41
|
+
}
|
package/src/commands/brief.js
CHANGED
|
@@ -25,7 +25,10 @@ export async function run({ hasFlag }) {
|
|
|
25
25
|
const parts = [];
|
|
26
26
|
try {
|
|
27
27
|
const { runBrief } = await import('../brief.js');
|
|
28
|
-
|
|
28
|
+
// `root` is the session's cwd for the same reason routeHint takes it below:
|
|
29
|
+
// the settings that name the model and the compaction window are the
|
|
30
|
+
// project's, not those of whatever directory the hook was spawned in.
|
|
31
|
+
const out = await runBrief({ sessionId: ctx.session_id, transcriptPath: ctx.transcript_path, root: ctx.cwd || process.cwd() });
|
|
29
32
|
if (out) parts.push(out);
|
|
30
33
|
} catch (e) { debug('brief:hook', e); /* briefing is best-effort — never block a prompt */ }
|
|
31
34
|
// State the matching delegation rule as a fact about this request. Registered
|
|
@@ -34,7 +37,7 @@ export async function run({ hasFlag }) {
|
|
|
34
37
|
// the match here removes both steps. Silent unless it is sure — see
|
|
35
38
|
// src/route-inject.js.
|
|
36
39
|
try {
|
|
37
|
-
const { routeHint, sessionModelRank } = await import('../route-inject.js');
|
|
40
|
+
const { routeHint, routeMatch, matchCaps, sessionModelRank } = await import('../route-inject.js');
|
|
38
41
|
// The payload names the session but not its model, so the rank comes from
|
|
39
42
|
// the transcript: without it a Sonnet session is told to delegate to
|
|
40
43
|
// Sonnet, which saves nothing.
|
|
@@ -46,8 +49,16 @@ export async function run({ hasFlag }) {
|
|
|
46
49
|
// differ. Claude Code sends `cwd` for that reason and the PreToolUse hook
|
|
47
50
|
// already reads it (src/delegation-guard.js). A payload without the field
|
|
48
51
|
// leaves root undefined, which is the behaviour this had before.
|
|
49
|
-
const
|
|
52
|
+
const match = routeMatch(ctx.prompt, { sessionRank });
|
|
53
|
+
const hint = routeHint(ctx.prompt, { sessionRank, root: ctx.cwd, match });
|
|
50
54
|
if (hint) parts.push(hint);
|
|
55
|
+
// The delegation guard repeats these caps to the subagent, so the limit it
|
|
56
|
+
// works under is the one this hint states. A prompt with no match clears
|
|
57
|
+
// the record of the turn before it.
|
|
58
|
+
try {
|
|
59
|
+
const { recordRouteCaps } = await import('../route-caps.js');
|
|
60
|
+
recordRouteCaps(ctx.session_id, matchCaps(match));
|
|
61
|
+
} catch (e) { debug('brief:route-caps', e); }
|
|
51
62
|
} catch (e) { debug('brief:route-hint', e); }
|
|
52
63
|
if (parts.length) console.log(parts.join('\n'));
|
|
53
64
|
return;
|
|
@@ -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, codexRulesInReview } 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 }) {
|
|
@@ -13,7 +15,6 @@ export function run({ args, getArg, hasFlag, root }) {
|
|
|
13
15
|
throw new Error(`${flag} requires a value.`);
|
|
14
16
|
}
|
|
15
17
|
}
|
|
16
|
-
const rules = loadCodexModelRules();
|
|
17
18
|
if (sub === 'rules') {
|
|
18
19
|
if (args[2] === 'add') {
|
|
19
20
|
if (hasFlag('--global') === hasFlag('--project')) throw new Error('Choose --global or --project for the rule.');
|
|
@@ -31,7 +32,15 @@ export function run({ args, getArg, hasFlag, root }) {
|
|
|
31
32
|
else if (args[2]) throw new Error('Usage: delegate rules [add <category|R<N>> --from <model> --model <model> --global|--project | rm <N>]');
|
|
32
33
|
const rules = loadCodexModelRules();
|
|
33
34
|
if (!rules.length) console.log('No Codex model rules. Claude rules are not imported.');
|
|
34
|
-
|
|
35
|
+
let events = [];
|
|
36
|
+
try { events = Object.values(loadCodexLedger().events); } catch { /* No ledger yet: rules list without outcomes. */ }
|
|
37
|
+
const inReview = new Set(codexRulesInReview({ rules, events, root: null }).map((x) => x.index));
|
|
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 = inReview.has(i + 1) ? ` | 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');
|
|
@@ -50,10 +59,13 @@ export function run({ args, getArg, hasFlag, root }) {
|
|
|
50
59
|
}
|
|
51
60
|
console.log(`Codex delegate: ${cfg.codex?.delegate === true ? 'on' : 'off'} (model routing and guidance)`);
|
|
52
61
|
console.log(`Default target: ${cfg.codex?.delegateTarget?.model || 'inherit (no override)'}`);
|
|
53
|
-
|
|
62
|
+
// Turning delegation off must work even when the rule file is damaged.
|
|
63
|
+
let ruleCount;
|
|
64
|
+
try { ruleCount = String(loadCodexModelRules().length); } catch (e) { ruleCount = `unreadable (${e.message})`; }
|
|
65
|
+
console.log(`Codex rules: ${ruleCount}`);
|
|
54
66
|
console.log('Explicit spawn models and custom roles are preserved. Targets must be available from your Codex provider.');
|
|
55
67
|
const saved = codexRoutingSavedTotals();
|
|
56
68
|
console.log(saved.priced
|
|
57
|
-
? `Routing saved: $${saved.total.toFixed(4)} over ${saved.priced} priced run(s). Details: sprag route-scan savings --agent codex`
|
|
69
|
+
? `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
70
|
: `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
71
|
}
|
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
|
|
@@ -149,13 +151,15 @@ export async function run({ args, version }) {
|
|
|
149
151
|
if (args[1] === 'savings') {
|
|
150
152
|
const { refreshCodexLedger, loadCodexLedger, codexRoutingSavedTotals } = await import('../codex-ledger.js');
|
|
151
153
|
if (hasFlag('--refresh')) await refreshCodexLedger();
|
|
154
|
+
// The prompt hook runs this detached to price finished delegations.
|
|
155
|
+
if (hasFlag('--quiet')) return;
|
|
152
156
|
const events = Object.entries(loadCodexLedger().events).map(([id, e]) => ({ id, ...e })).sort((a, b) => b.ts - a.ts);
|
|
153
157
|
const totals = codexRoutingSavedTotals();
|
|
154
158
|
if (format === 'json') { console.log(JSON.stringify({ agent: 'codex', totals, events }, null, 2)); return; }
|
|
155
159
|
const money = (usd) => Number.isFinite(usd) ? `${usd < 0 ? '-' : ''}$${Math.abs(usd).toFixed(4)}` : 'n/a (unpriced)';
|
|
156
160
|
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
161
|
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)'}`);
|
|
162
|
+
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
163
|
}
|
|
160
164
|
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
165
|
return;
|
|
@@ -172,7 +176,7 @@ export async function run({ args, version }) {
|
|
|
172
176
|
if (hasFlag('--refresh') || !cache) {
|
|
173
177
|
// Prices live in the gateway snapshot; refresh it when stale so candidates can name a target.
|
|
174
178
|
const { readCodexPrices, refreshCodexPrices } = await import('../codex-cache-policy.js');
|
|
175
|
-
if (!readCodexPrices()) { try { await refreshCodexPrices(); } catch {
|
|
179
|
+
if (!readCodexPrices()) { try { await refreshCodexPrices(); } catch (e) { debug('codex:prices', e); } }
|
|
176
180
|
cache = await rs.runCodexRouteScan({ days });
|
|
177
181
|
}
|
|
178
182
|
if (hasFlag('--quiet')) return;
|
|
@@ -181,7 +185,7 @@ export async function run({ args, version }) {
|
|
|
181
185
|
console.log(`Codex route-scan: ${cache.totalEpisodes} turns in ${cache.days} days, scanned ${cache.scannedAt}${cache.priceCheckedAt ? '' : ' | prices unavailable'}`);
|
|
182
186
|
if (!open.length) console.log('No recurring delegable pattern. Needs 3+ similar simple turns per project and parent model.');
|
|
183
187
|
for (const c of open) {
|
|
184
|
-
const target = c.suggestedModel ? `-> ${c.suggestedModel}${Number.isFinite(c.estSavedUsd) ? ` (
|
|
188
|
+
const target = c.suggestedModel ? `-> ${c.suggestedModel}${Number.isFinite(c.estSavedUsd) ? ` (~${signedUsd(c.estSavedUsd, 4)} over ${c.count} turns)` : ''}`
|
|
185
189
|
: `-> choose --model${c.alternatives.length ? ` (priced cheaper: ${c.alternatives.join(', ')})` : ''}`;
|
|
186
190
|
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
191
|
console.log(` ${c.projectRoot}`);
|
|
@@ -225,7 +229,7 @@ export async function run({ args, version }) {
|
|
|
225
229
|
sessionId: getArg('--session'), project: getArg('--project') || getArg('-p') });
|
|
226
230
|
if (cmd === 'last') events = events.slice(-1);
|
|
227
231
|
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.
|
|
232
|
+
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
233
|
else if (hasFlag('--list')) console.log([...new Set(events.map((event) => event.at.slice(0, 10)))].reverse().join('\n'));
|
|
230
234
|
else for (const event of events) {
|
|
231
235
|
console.log(terminalText(`${event.at} [${event.sessionId}] ${event.message}`));
|
|
@@ -239,6 +243,11 @@ export async function run({ args, version }) {
|
|
|
239
243
|
const cwd = getArg('--cwd') || process.cwd();
|
|
240
244
|
const data = await createPanelReader({ root: findProjectRoot(resolve(cwd), { agent: 'codex' }), sessionId: getArg('--session'), days: numArg('--days', { dflt: 7, min: 0 }) })();
|
|
241
245
|
const result = writeHandoff({ cwd, agent: 'codex', session: data.session });
|
|
246
|
+
try {
|
|
247
|
+
const { recordCodexHistoryEvent } = await import('../codex-brief.js');
|
|
248
|
+
recordCodexHistoryEvent({ sessionId: data.session?.sessionId, project: cwd, key: 'handoff',
|
|
249
|
+
message: `Handoff written: ${result.path}` });
|
|
250
|
+
} catch (e) { debug('codex:handoff-record', e); }
|
|
242
251
|
console.log(`Handoff written: ${result.path}`);
|
|
243
252
|
console.log('Fill in the remaining-work sections, then ask the next Codex session to read this file.');
|
|
244
253
|
return;
|
|
@@ -324,7 +333,7 @@ export async function run({ args, version }) {
|
|
|
324
333
|
})() : { read: createCodexBudgetReader() };
|
|
325
334
|
if (hasFlag('--once')) {
|
|
326
335
|
const provider = codexBudgetProvider();
|
|
327
|
-
if (provider) { try { readBudget.set(await fetchCodexBudget(provider)); } catch {
|
|
336
|
+
if (provider) { try { readBudget.set(await fetchCodexBudget(provider)); } catch (e) { debug('codex:budget', e); } }
|
|
328
337
|
}
|
|
329
338
|
const { createInlinePanelResizer } = await import('../codex-panel-runner.js');
|
|
330
339
|
const rawInterval = getArg('--interval');
|
|
@@ -340,7 +349,7 @@ export async function run({ args, version }) {
|
|
|
340
349
|
readCachePolicy: hasFlag('--once') ? async (session) => {
|
|
341
350
|
const { readCodexCachePolicy } = await import('../codex-cache-policy.js');
|
|
342
351
|
try { return readCodexCachePolicy(session) || await refreshCodexCachePolicy(session); }
|
|
343
|
-
catch { return null; }
|
|
352
|
+
catch (e) { debug('codex:cache-policy', e); return null; }
|
|
344
353
|
} : createCodexCachePolicyReader(), hasFlag }),
|
|
345
354
|
interval: interval * 1000, once: hasFlag('--once'),
|
|
346
355
|
color: !hasFlag('--no-color') && !process.env.NO_COLOR && !!process.stdout.isTTY, version, compact: hasFlag('--compact'),
|
|
@@ -377,7 +386,7 @@ export async function run({ args, version }) {
|
|
|
377
386
|
}
|
|
378
387
|
const out = await codexHookOutput(getArg('--event'), payload);
|
|
379
388
|
if (out) console.log(JSON.stringify(out));
|
|
380
|
-
} catch { /* Hook failures must not break the agent loop. */ }
|
|
389
|
+
} catch (e) { debug('codex:hook', e); /* Hook failures must not break the agent loop. */ }
|
|
381
390
|
return;
|
|
382
391
|
}
|
|
383
392
|
if (cmd === 'install') {
|
|
@@ -396,7 +405,7 @@ export async function run({ args, version }) {
|
|
|
396
405
|
const { preparePresetRatchet } = await import('../preset-ratchet.js');
|
|
397
406
|
const p = preparePresetRatchet({ agent: 'codex' });
|
|
398
407
|
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. */ }
|
|
408
|
+
} catch (e) { debug('codex:preset', e); /* Presets are optional; `seed` still registers them. */ }
|
|
400
409
|
console.log('Open /hooks in Codex and review/trust the Sprag hooks before they can run.');
|
|
401
410
|
console.log(`Panel auto-start: ${panelAuto ? 'registered with the other hooks' : 'off'}. Hook registration is complete; runtime activation is not verified.`);
|
|
402
411
|
console.log('After approval, start or resume Codex and run: sprag panel doctor --agent codex');
|
|
@@ -508,7 +517,8 @@ export async function run({ args, version }) {
|
|
|
508
517
|
console.log(`Cache: ${doc2md.cacheDir()}`);
|
|
509
518
|
try {
|
|
510
519
|
for (const hook of codexHookStatus().filter((h) => ['PreToolUse', 'UserPromptSubmit'].includes(h.event))) console.log(`${hook.event}: ${hook.registered ? 'registered' : 'missing/outdated'}`);
|
|
511
|
-
} catch {
|
|
520
|
+
} catch (e) {
|
|
521
|
+
debug('codex:hook-status', e);
|
|
512
522
|
console.log('Hooks: cannot read Codex hooks.json; file left unchanged. Run sprag doctor --agent codex for diagnostics.');
|
|
513
523
|
}
|
|
514
524
|
console.log('Prompt conversion, supported reads, and document/cache write guards require trusted hooks. Review in Codex /hooks.');
|
package/src/commands/harness.js
CHANGED
|
@@ -106,6 +106,12 @@ export async function run({ args, hasFlag }) {
|
|
|
106
106
|
for (const p of r.backedUp) console.log(`Backed up: ${p}`);
|
|
107
107
|
for (const p of r.wrote) console.log(`Wrote: ${p}`);
|
|
108
108
|
for (const p of r.skipped) console.log(`Skipped: ${p}`);
|
|
109
|
+
if (r.brokenBlock) {
|
|
110
|
+
// Nothing was written to CLAUDE.md, so claiming success would hide it.
|
|
111
|
+
console.log('\n🅷 Harness not initialized: repair the block markers in CLAUDE.md, then run this again.');
|
|
112
|
+
process.exitCode = 1;
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
109
115
|
console.log('\n🅷 Harness initialized. Statusline will show 🅷 5/5 on next refresh.');
|
|
110
116
|
return;
|
|
111
117
|
}
|
|
@@ -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/compact-window.js
CHANGED
|
@@ -111,6 +111,10 @@ export function isOneMillionModel(model) {
|
|
|
111
111
|
return typeof model === 'string' && ONE_M_RE.test(model);
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
// A window Claude Code itself reported for the session (statusline payload).
|
|
115
|
+
// Anything this large can only be the 1M window.
|
|
116
|
+
export const LIVE_1M_MIN = 900_000;
|
|
117
|
+
|
|
114
118
|
/** Effective autoCompactWindow, with the source that won. */
|
|
115
119
|
export function effectiveWindow(root) {
|
|
116
120
|
const env = process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW;
|
|
@@ -137,10 +141,14 @@ export function effectiveWindow(root) {
|
|
|
137
141
|
*
|
|
138
142
|
* `reason` names why it is not ok: 'unset' (no autoCompactWindow anywhere) or
|
|
139
143
|
* 'too-large' (set, but above 700k — still lets context run away).
|
|
144
|
+
*
|
|
145
|
+
* `liveWindow` is the window Claude Code reported for the running session. The
|
|
146
|
+
* model id alone misses every model that is 1M without a `[1m]` suffix, so a
|
|
147
|
+
* reported size settles it whenever the caller has one.
|
|
140
148
|
*/
|
|
141
|
-
export function compactWindowStatus({ root = process.cwd() } = {}) {
|
|
149
|
+
export function compactWindowStatus({ root = process.cwd(), liveWindow = null } = {}) {
|
|
142
150
|
const { model, source: modelSource } = resolveModelId(root);
|
|
143
|
-
const is1m = isOneMillionModel(model);
|
|
151
|
+
const is1m = isOneMillionModel(model) || (Number.isFinite(liveWindow) && liveWindow >= LIVE_1M_MIN);
|
|
144
152
|
const win = effectiveWindow(root);
|
|
145
153
|
const base = {
|
|
146
154
|
model,
|
|
@@ -163,10 +171,10 @@ export function compactWindowStatus({ root = process.cwd() } = {}) {
|
|
|
163
171
|
* Statusline chip text, or null when there is nothing to say. Kept to the same
|
|
164
172
|
* short shape as the other 🅷⚠ warnings so the line stays single-width.
|
|
165
173
|
*/
|
|
166
|
-
export function compactWindowWarningForStatusline(root = process.cwd(), cfg = null) {
|
|
174
|
+
export function compactWindowWarningForStatusline(root = process.cwd(), cfg = null, { liveWindow = null } = {}) {
|
|
167
175
|
if (cfg && cfg.compactWindow && cfg.compactWindow.enabled === false) return null;
|
|
168
176
|
try {
|
|
169
|
-
const s = compactWindowStatus({ root });
|
|
177
|
+
const s = compactWindowStatus({ root, liveWindow });
|
|
170
178
|
return s.ok ? null : 'compact-window?';
|
|
171
179
|
} catch (e) {
|
|
172
180
|
debug('compact-window:statusline', e);
|
package/src/config.js
CHANGED
|
@@ -9,10 +9,11 @@
|
|
|
9
9
|
* Linux: $XDG_CONFIG_HOME/claude-token-saver/config.json or ~/.config/...
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { readFileSync,
|
|
12
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
13
13
|
import { join } from 'node:path';
|
|
14
14
|
import { userDataDir } from './paths.js';
|
|
15
15
|
import { labelPreference } from './statusline-mode.js';
|
|
16
|
+
import { writeStateFile } from './state-file.js';
|
|
16
17
|
|
|
17
18
|
const CONFIG_DIR = userDataDir();
|
|
18
19
|
const CONFIG_PATH = join(CONFIG_DIR, 'config.json');
|
|
@@ -31,8 +32,7 @@ export function loadConfig() {
|
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
export function saveConfig(cfg) {
|
|
34
|
-
|
|
35
|
-
writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + '\n');
|
|
35
|
+
writeStateFile(CONFIG_PATH, JSON.stringify(cfg, null, 2) + '\n');
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
// Each keyword maps to a single statusline option toggle.
|
package/src/cost.js
CHANGED
|
@@ -91,8 +91,11 @@ function detectPricingTier(model) {
|
|
|
91
91
|
|
|
92
92
|
if (m.includes('haiku')) {
|
|
93
93
|
if (/haiku[-_.]?4[-_.]?5/.test(m)) return 'claude-haiku-4-5';
|
|
94
|
-
|
|
95
|
-
|
|
94
|
+
// The 3.x ids put the version first (`claude-3-5-haiku-20241022`,
|
|
95
|
+
// `claude-3-haiku-20240307`), so both orders are matched. With only the
|
|
96
|
+
// family-first order, 3.5 Haiku fell through to the 4.5 rate.
|
|
97
|
+
if (/haiku[-_.]?3[-_.]?5|3[-_.]?5[-_.]?haiku/.test(m)) return 'claude-haiku-3-5';
|
|
98
|
+
if (/haiku[-_.]?3\b|\b3[-_.]?haiku/.test(m)) return 'claude-haiku-3';
|
|
96
99
|
return 'claude-haiku-4-5';
|
|
97
100
|
}
|
|
98
101
|
|
|
@@ -197,7 +200,8 @@ function tokensToMillions(n) {
|
|
|
197
200
|
* ephemeral1h — portion of cacheCreation billed at 1h rate (2x input)
|
|
198
201
|
* output — output tokens
|
|
199
202
|
*/
|
|
200
|
-
export function estimateCost(totals, model) {
|
|
203
|
+
export function estimateCost(totals, model, { price } = {}) {
|
|
204
|
+
if (price) return estimateCostAtPrice(totals, price);
|
|
201
205
|
const tier = detectPricingTier(model);
|
|
202
206
|
const p = PRICING[tier];
|
|
203
207
|
|
|
@@ -248,20 +252,73 @@ export function estimateCost(totals, model) {
|
|
|
248
252
|
};
|
|
249
253
|
}
|
|
250
254
|
|
|
255
|
+
/**
|
|
256
|
+
* Same shape as the list-price estimate, but priced at a gateway's per-token
|
|
257
|
+
* rates (LiteLLM /model/info). A token class the gateway does not price makes
|
|
258
|
+
* the whole estimate unknown (null) rather than free or list-priced.
|
|
259
|
+
*/
|
|
260
|
+
function estimateCostAtPrice(totals, price) {
|
|
261
|
+
const write5m = totals.ephemeral5m ?? 0;
|
|
262
|
+
const write1h = totals.ephemeral1h ?? 0;
|
|
263
|
+
const untracked = Math.max(0, (totals.cacheCreation ?? 0) - write5m - write1h);
|
|
264
|
+
const read = totals.cacheRead ?? 0;
|
|
265
|
+
if (read > 0 && price.cacheRead == null) return null;
|
|
266
|
+
if (write5m + untracked > 0 && price.cacheWrite == null) return null;
|
|
267
|
+
if (write1h > 0 && price.cacheWrite1h == null) return null;
|
|
268
|
+
if (price.input == null || price.output == null) return null;
|
|
269
|
+
const perM = (rate) => (rate ?? 0) * 1e6;
|
|
270
|
+
const input = perM(price.input), output = perM(price.output), cacheRead = perM(price.cacheRead);
|
|
271
|
+
const cacheWrite5m = perM(price.cacheWrite), cacheWrite1h = perM(price.cacheWrite1h);
|
|
272
|
+
|
|
273
|
+
const actual =
|
|
274
|
+
tokensToMillions(totals.input) * input +
|
|
275
|
+
tokensToMillions(write5m + untracked) * cacheWrite5m +
|
|
276
|
+
tokensToMillions(write1h) * cacheWrite1h +
|
|
277
|
+
tokensToMillions(read) * cacheRead +
|
|
278
|
+
tokensToMillions(totals.output) * output;
|
|
279
|
+
const totalInput = totals.input + (totals.cacheCreation ?? 0) + read;
|
|
280
|
+
const noCacheCost = tokensToMillions(totalInput) * input + tokensToMillions(totals.output) * output;
|
|
281
|
+
|
|
282
|
+
// The 5m scenario needs a 5m write rate; without one it is not computable, so
|
|
283
|
+
// it collapses to the actual cost and the "extra cost" row stays hidden.
|
|
284
|
+
let scenario5mCost = actual;
|
|
285
|
+
if (price.cacheWrite != null) {
|
|
286
|
+
const extra5mCreation = write1h * 2;
|
|
287
|
+
scenario5mCost =
|
|
288
|
+
tokensToMillions(totals.input) * input +
|
|
289
|
+
tokensToMillions(write5m + write1h + untracked + extra5mCreation) * cacheWrite5m +
|
|
290
|
+
tokensToMillions(Math.max(0, read - extra5mCreation)) * cacheRead +
|
|
291
|
+
tokensToMillions(totals.output) * output;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
tier: 'gateway',
|
|
296
|
+
actual: round(actual),
|
|
297
|
+
noCacheCost: round(noCacheCost),
|
|
298
|
+
savings: round(noCacheCost - actual),
|
|
299
|
+
savingsRate: noCacheCost > 0 ? (noCacheCost - actual) / noCacheCost : 0,
|
|
300
|
+
scenario5mCost: round(scenario5mCost),
|
|
301
|
+
extraCostIf5m: round(scenario5mCost - actual),
|
|
302
|
+
extraCostIf5mApplicable: price.cacheWrite != null && write1h > 0,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
251
306
|
/**
|
|
252
307
|
* Price a set of sessions each at its own model and sum. One estimateCost over
|
|
253
308
|
* the summed totals priced every token at whichever session came first in the
|
|
254
309
|
* list — sorted by mtime, so a haiku scratch session touched earliest set the
|
|
255
310
|
* rate for a day of Fable work and understated the saving about tenfold.
|
|
256
311
|
* Sessions whose model has no known rate are skipped, as month-spend does.
|
|
312
|
+
* `estimate` lets a caller price by path (see claude-price.js); null skips a session.
|
|
257
313
|
*/
|
|
258
|
-
export function estimateCostAcross(sessions) {
|
|
314
|
+
export function estimateCostAcross(sessions, estimate = estimateCost) {
|
|
259
315
|
const acc = { actual: 0, noCacheCost: 0, scenario5mCost: 0, extraCostIf5mApplicable: false };
|
|
260
316
|
const tiers = new Map();
|
|
261
317
|
for (const s of sessions || []) {
|
|
262
318
|
if (!s?.totals) continue;
|
|
263
319
|
let c;
|
|
264
|
-
try { c =
|
|
320
|
+
try { c = estimate(s.totals, s.model); } catch { continue; }
|
|
321
|
+
if (!c) continue;
|
|
265
322
|
acc.actual += c.actual;
|
|
266
323
|
acc.noCacheCost += c.noCacheCost;
|
|
267
324
|
acc.scenario5mCost += c.scenario5mCost;
|