ultracost 0.2.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/src/guard.js ADDED
@@ -0,0 +1,300 @@
1
+ import { existsSync, readFileSync, readdirSync, lstatSync, writeFileSync } from 'node:fs';
2
+ import { join, extname, sep } from 'node:path';
3
+ import { classifyModel, tierModel } from './policy.js';
4
+
5
+ export const CODES = {
6
+ NOOPTS: 'UC001', // agent(x) with no options object
7
+ MISSING: 'UC002', // options object present but no model key
8
+ BANNED: 'UC003', // model resolves to a neverUse model (e.g. haiku)
9
+ INHERIT: 'UC004', // model: 'inherit' while allowInherit is false
10
+ DYNAMIC: 'UC005' // model/options is a variable; can't verify statically
11
+ };
12
+
13
+ // agent( as a call — not subagent(, myagent(, or obj.agent(.
14
+ const SPAWN_RE = /(?<![\w.$])agent\s*\(/g;
15
+
16
+ const MODEL_LITERAL = /\bmodel\s*:\s*(['"`])([\w.\-]+)\1/;
17
+ const MODEL_DYNAMIC = /\bmodel\s*:/;
18
+ const EFFORT_LITERAL = /\beffort\s*:\s*(['"`])(\w+)\1/;
19
+ // A fan-out: the agent() sits inside an open .map/.flatMap/Array.from call, or inside a
20
+ // pipeline(items, ...stages) call (every stage runs once per item) — so its count is a
21
+ // runtime (unknown) multiple of the collection length. parallel([...]) of literal thunks
22
+ // is NOT a fan-out (fixed count); parallel(items.map(...)) is caught by the .map rule.
23
+ const FANOUT_CALLEE = /(?:^|\.)(?:map|flatMap)$/;
24
+ const isFanoutCallee = (name) => FANOUT_CALLEE.test(name) || name === 'Array.from' || name === 'pipeline';
25
+
26
+ // Spans inside string/template literals and // or /* */ comments. A literal
27
+ // "agent(" there is prose (e.g. a prompt string) and must not count as a call.
28
+ function ignorableRanges(text) {
29
+ const ranges = [];
30
+ const n = text.length;
31
+ let i = 0;
32
+ while (i < n) {
33
+ const ch = text[i];
34
+ const next = text[i + 1];
35
+ if (ch === '/' && next === '/') {
36
+ const start = i;
37
+ i += 2;
38
+ while (i < n && text[i] !== '\n') i++;
39
+ ranges.push([start, i]);
40
+ } else if (ch === '/' && next === '*') {
41
+ const start = i;
42
+ i += 2;
43
+ while (i < n && !(text[i] === '*' && text[i + 1] === '/')) i++;
44
+ i = Math.min(n, i + 2);
45
+ ranges.push([start, i]);
46
+ } else if (ch === "'" || ch === '"' || ch === '`') {
47
+ const start = i;
48
+ i++;
49
+ while (i < n) {
50
+ if (text[i] === '\\') { i += 2; continue; }
51
+ if (text[i] === ch) { i++; break; }
52
+ i++;
53
+ }
54
+ ranges.push([start, i]);
55
+ } else {
56
+ i++;
57
+ }
58
+ }
59
+ return ranges;
60
+ }
61
+
62
+ function inRanges(ranges, idx) {
63
+ let lo = 0;
64
+ let hi = ranges.length - 1;
65
+ while (lo <= hi) {
66
+ const mid = (lo + hi) >> 1;
67
+ const [s, e] = ranges[mid];
68
+ if (idx < s) hi = mid - 1;
69
+ else if (idx >= e) lo = mid + 1;
70
+ else return true;
71
+ }
72
+ return false;
73
+ }
74
+
75
+ function lineCol(text, index) {
76
+ let line = 1;
77
+ let last = 0;
78
+ for (let i = 0; i < index; i++) {
79
+ if (text[i] === '\n') {
80
+ line++;
81
+ last = i + 1;
82
+ }
83
+ }
84
+ return { line, column: index - last + 1 };
85
+ }
86
+
87
+ function lineText(text, index) {
88
+ const start = text.lastIndexOf('\n', index) + 1;
89
+ let end = text.indexOf('\n', index);
90
+ if (end === -1) end = text.length;
91
+ return text.slice(start, end).trim();
92
+ }
93
+
94
+ // From the '(' index, return the argument source up to the matching ')'.
95
+ function captureArgs(text, openIdx) {
96
+ let depth = 0;
97
+ let str = null;
98
+ for (let i = openIdx; i < text.length; i++) {
99
+ const ch = text[i];
100
+ const prev = text[i - 1];
101
+ if (str) {
102
+ if (ch === str && prev !== '\\') str = null;
103
+ continue;
104
+ }
105
+ if (ch === '"' || ch === "'" || ch === '`') {
106
+ str = ch;
107
+ continue;
108
+ }
109
+ if (ch === '(') depth++;
110
+ else if (ch === ')') {
111
+ depth--;
112
+ if (depth === 0) return { args: text.slice(openIdx + 1, i), end: i };
113
+ }
114
+ }
115
+ return { args: text.slice(openIdx + 1), end: text.length };
116
+ }
117
+
118
+ // Count every real agent() stage and collect the subset that are problems.
119
+ export function analyze(text, policy, file = '<text>') {
120
+ const ranges = ignorableRanges(text);
121
+ const findings = [];
122
+ let stages = 0;
123
+ for (const m of text.matchAll(SPAWN_RE)) {
124
+ if (inRanges(ranges, m.index)) continue;
125
+ stages++;
126
+ const openIdx = m.index + m[0].length - 1;
127
+ const { args } = captureArgs(text, openIdx);
128
+ const { line, column } = lineCol(text, m.index);
129
+ const base = { file, line, column, snippet: lineText(text, m.index) };
130
+
131
+ const literal = args.match(MODEL_LITERAL);
132
+ if (literal) {
133
+ const value = literal[2];
134
+ const verdict = classifyModel(value, policy);
135
+ if (verdict === 'banned') {
136
+ findings.push({ ...base, code: CODES.BANNED, severity: 'error', model: value, message: `stage pins banned model "${value}" (policy.neverUse)` });
137
+ } else if (verdict === 'inherit') {
138
+ findings.push({ ...base, code: CODES.INHERIT, severity: 'error', model: value, message: `stage uses model: 'inherit' (allowInherit is false)` });
139
+ }
140
+ continue;
141
+ }
142
+ if (MODEL_DYNAMIC.test(args)) continue;
143
+
144
+ const hasObjectLiteral = /\{[\s\S]*\}/.test(args);
145
+ const secondArgIsIdentifier = /,\s*[A-Za-z_$][\w$]*\s*$/.test(args.trimEnd());
146
+ if (hasObjectLiteral) {
147
+ findings.push({ ...base, code: CODES.MISSING, severity: 'error', message: 'stage options object has no model — will inherit the session model' });
148
+ } else if (secondArgIsIdentifier) {
149
+ findings.push({ ...base, code: CODES.DYNAMIC, severity: 'warn', message: 'stage options passed as a variable — cannot verify a model is pinned' });
150
+ } else {
151
+ findings.push({ ...base, code: CODES.NOOPTS, severity: 'error', message: 'stage has no options object — add { model: ... } so it does not inherit the session model' });
152
+ }
153
+ }
154
+ return { stages, findings };
155
+ }
156
+
157
+ export function scanText(text, policy, file = '<text>') {
158
+ return analyze(text, policy, file).findings;
159
+ }
160
+
161
+ // Names of call expressions whose '(' is still open at `idx` (string/comment-aware).
162
+ // e.g. inside `files.map(f => agent(...))`, at the agent( the stack holds "files.map".
163
+ function enclosingCalls(text, ranges, idx) {
164
+ const stack = [];
165
+ for (let i = 0; i < idx; i++) {
166
+ if (inRanges(ranges, i)) continue;
167
+ const ch = text[i];
168
+ if (ch === '(') {
169
+ let j = i - 1;
170
+ while (j >= 0 && /\s/.test(text[j])) j--;
171
+ const end = j;
172
+ while (j >= 0 && /[\w$.]/.test(text[j])) j--;
173
+ stack.push(text.slice(j + 1, end + 1));
174
+ } else if (ch === ')') {
175
+ stack.pop();
176
+ }
177
+ }
178
+ return stack;
179
+ }
180
+
181
+ // Per-stage descriptors for cost estimation: each real agent() stage with its
182
+ // pinned model (or null = inherits session model), pinned effort (or null), and
183
+ // whether it is a fan-out (unknown runtime multiplier).
184
+ export function stageList(text) {
185
+ const ranges = ignorableRanges(text);
186
+ const out = [];
187
+ for (const m of text.matchAll(SPAWN_RE)) {
188
+ if (inRanges(ranges, m.index)) continue;
189
+ const openIdx = m.index + m[0].length - 1;
190
+ const { args } = captureArgs(text, openIdx);
191
+ const { line } = lineCol(text, m.index);
192
+ const ml = args.match(MODEL_LITERAL);
193
+ const el = args.match(EFFORT_LITERAL);
194
+ const fanout = enclosingCalls(text, ranges, m.index).some(isFanoutCallee);
195
+ out.push({
196
+ line,
197
+ model: ml ? ml[2] : null,
198
+ dynamicModel: !ml && MODEL_DYNAMIC.test(args),
199
+ effort: el ? el[2] : null,
200
+ fanout
201
+ });
202
+ }
203
+ return out;
204
+ }
205
+
206
+ const SCAN_EXTS = new Set(['.js', '.mjs', '.cjs', '.ts']);
207
+
208
+ export function collectFiles(target) {
209
+ if (!existsSync(target)) return [];
210
+ // lstat (not stat) so a symlink is reported as a symlink rather than its
211
+ // target — never follow/recurse one (avoids symlink loops blowing the stack,
212
+ // and stops --fix writing through a link to a file outside the scanned tree).
213
+ const st = lstatSync(target);
214
+ if (st.isSymbolicLink()) return [];
215
+ if (st.isFile()) return [target];
216
+ if (!st.isDirectory()) return [];
217
+ const out = [];
218
+ for (const name of readdirSync(target)) {
219
+ if (name === 'node_modules' || name.startsWith('.')) continue;
220
+ const full = join(target, name);
221
+ const s = lstatSync(full);
222
+ if (s.isSymbolicLink()) continue;
223
+ if (s.isDirectory()) out.push(...collectFiles(full));
224
+ else if (SCAN_EXTS.has(extname(full))) out.push(full);
225
+ }
226
+ return out;
227
+ }
228
+
229
+ // **/workflows/scripts/*.js under base — the ultracode script layout.
230
+ export function collectWorkflowScripts(base) {
231
+ return collectFiles(base).filter((f) => {
232
+ const parts = f.split(sep);
233
+ const n = parts.length;
234
+ return n >= 3 && parts[n - 2] === 'scripts' && parts[n - 3] === 'workflows';
235
+ });
236
+ }
237
+
238
+ export function scan(target, policy) {
239
+ const files = collectFiles(target);
240
+ const findings = [];
241
+ for (const f of files) {
242
+ findings.push(...scanText(readFileSync(f, 'utf8'), policy, f));
243
+ }
244
+ return { findings, files };
245
+ }
246
+
247
+ export function auditScripts(base, policy) {
248
+ const files = collectWorkflowScripts(base);
249
+ const totals = { scripts: files.length, stages: 0, pinned: 0, unpinned: 0, banned: 0, inherit: 0, dynamic: 0 };
250
+ for (const f of files) {
251
+ const { stages, findings } = analyze(readFileSync(f, 'utf8'), policy, f);
252
+ totals.stages += stages;
253
+ for (const x of findings) {
254
+ if (x.code === CODES.NOOPTS || x.code === CODES.MISSING) totals.unpinned++;
255
+ else if (x.code === CODES.BANNED) totals.banned++;
256
+ else if (x.code === CODES.INHERIT) totals.inherit++;
257
+ else if (x.code === CODES.DYNAMIC) totals.dynamic++;
258
+ }
259
+ }
260
+ totals.pinned = totals.stages - totals.unpinned - totals.banned - totals.inherit - totals.dynamic;
261
+ totals.unpinnedRatio = totals.stages ? totals.unpinned / totals.stages : 0;
262
+ return { base, files, totals };
263
+ }
264
+
265
+ // Insert the default model on the unambiguous cases, back-to-front so earlier
266
+ // edits don't shift later offsets.
267
+ export function fixText(text, policy) {
268
+ const model = tierModel(policy.default, policy);
269
+ const ranges = ignorableRanges(text);
270
+ const sites = [];
271
+ for (const m of text.matchAll(SPAWN_RE)) {
272
+ if (inRanges(ranges, m.index)) continue;
273
+ const openIdx = m.index + m[0].length - 1;
274
+ const { args, end } = captureArgs(text, openIdx);
275
+ if (MODEL_DYNAMIC.test(args)) continue;
276
+ const objStart = text.indexOf('{', openIdx);
277
+ if (objStart !== -1 && objStart < end) {
278
+ sites.push({ type: 'insert', at: objStart + 1 });
279
+ } else {
280
+ sites.push({ type: 'append', at: end });
281
+ }
282
+ }
283
+ let out = text;
284
+ let count = 0;
285
+ for (const s of sites.sort((a, b) => b.at - a.at)) {
286
+ if (s.type === 'insert') {
287
+ out = out.slice(0, s.at) + ` model: '${model}',` + out.slice(s.at);
288
+ } else {
289
+ out = out.slice(0, s.at) + `, { model: '${model}' }` + out.slice(s.at);
290
+ }
291
+ count++;
292
+ }
293
+ return { text: out, count };
294
+ }
295
+
296
+ export function fixFile(file, policy) {
297
+ const { text, count } = fixText(readFileSync(file, 'utf8'), policy);
298
+ if (count) writeFileSync(file, text);
299
+ return count;
300
+ }
package/src/index.js ADDED
@@ -0,0 +1,7 @@
1
+ export { loadPolicy, normalize, classifyModel, tierModel } from './policy.js';
2
+ export { scan, scanText, analyze, stageList, fixText, fixFile, collectFiles, collectWorkflowScripts, auditScripts, CODES } from './guard.js';
3
+ export { estimateText, estimateFile, priceKey } from './estimate.js';
4
+ export { refreshPricing, parsePrices, assertPlausible, writePricingToPolicy, DEFAULT_PRICING_URL } from './pricing.js';
5
+ export { compileRules, replaceBlock, stripBlock } from './rules.js';
6
+ export { install, uninstall, readSettings } from './install.js';
7
+ export * as paths from './paths.js';
package/src/install.js ADDED
@@ -0,0 +1,113 @@
1
+ import {
2
+ existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync, rmSync
3
+ } from 'node:fs';
4
+ import {
5
+ CLAUDE_DIR, CLAUDE_MD, SETTINGS, ULTRACOST_DIR, POLICY_PATH,
6
+ HOOK_PATH, HOOK_SRC, DEFAULT_POLICY
7
+ } from './paths.js';
8
+ import { compileRules, replaceBlock, stripBlock } from './rules.js';
9
+
10
+ // Invoked via `node` so it needs no shebang, +x bit, or PATH entry.
11
+ const HOOK_COMMAND = `node "${HOOK_PATH}"`;
12
+
13
+ // null = file missing; undefined = present but invalid JSON.
14
+ export function readSettings() {
15
+ if (!existsSync(SETTINGS)) return null;
16
+ try {
17
+ return JSON.parse(readFileSync(SETTINGS, 'utf8'));
18
+ } catch {
19
+ return undefined;
20
+ }
21
+ }
22
+
23
+ const isUltracostHook = (h) => h.hooks?.some((hh) => hh.command?.includes('ultracost'));
24
+
25
+ function ensureDirs() {
26
+ for (const d of [CLAUDE_DIR, ULTRACOST_DIR]) {
27
+ if (!existsSync(d)) mkdirSync(d, { recursive: true });
28
+ }
29
+ }
30
+
31
+ function writePolicyFile(force) {
32
+ if (existsSync(POLICY_PATH) && !force) return 'kept';
33
+ copyFileSync(DEFAULT_POLICY, POLICY_PATH);
34
+ return existsSync(POLICY_PATH) ? 'written' : 'failed';
35
+ }
36
+
37
+ function writeRules(policy) {
38
+ const block = compileRules(policy);
39
+ if (!existsSync(CLAUDE_MD)) {
40
+ writeFileSync(CLAUDE_MD, block + '\n');
41
+ return 'created';
42
+ }
43
+ const existing = readFileSync(CLAUDE_MD, 'utf8');
44
+ const replaced = replaceBlock(existing, block);
45
+ if (replaced !== null) {
46
+ writeFileSync(CLAUDE_MD, replaced);
47
+ return 'updated';
48
+ }
49
+ writeFileSync(CLAUDE_MD, existing.trimEnd() + '\n\n' + block + '\n');
50
+ return 'appended';
51
+ }
52
+
53
+ function writeHook() {
54
+ copyFileSync(HOOK_SRC, HOOK_PATH);
55
+ }
56
+
57
+ function registerHook() {
58
+ const settings = readSettings();
59
+ if (settings === undefined) return 'invalid';
60
+ const conf = settings ?? {};
61
+ conf.hooks ??= {};
62
+ conf.hooks.SessionStart ??= [];
63
+ if (conf.hooks.SessionStart.some(isUltracostHook)) return 'present';
64
+ conf.hooks.SessionStart.push({
65
+ matcher: 'startup|resume|clear|compact',
66
+ hooks: [{ type: 'command', command: HOOK_COMMAND }]
67
+ });
68
+ writeFileSync(SETTINGS, JSON.stringify(conf, null, 2) + '\n');
69
+ return settings === null ? 'created' : 'registered';
70
+ }
71
+
72
+ export function install(policy, { force = false } = {}) {
73
+ ensureDirs();
74
+ return {
75
+ policy: writePolicyFile(force),
76
+ rules: writeRules(policy),
77
+ hook: (writeHook(), 'installed'),
78
+ register: registerHook()
79
+ };
80
+ }
81
+
82
+ export function uninstall() {
83
+ const result = { rules: 'absent', hook: 'absent', register: 'absent', policy: 'absent' };
84
+
85
+ if (existsSync(CLAUDE_MD)) {
86
+ const content = readFileSync(CLAUDE_MD, 'utf8');
87
+ if (content.includes('ultracost:start')) {
88
+ const stripped = stripBlock(content);
89
+ if (stripped) writeFileSync(CLAUDE_MD, stripped + '\n');
90
+ else rmSync(CLAUDE_MD);
91
+ result.rules = 'removed';
92
+ }
93
+ }
94
+ if (existsSync(HOOK_PATH)) {
95
+ rmSync(HOOK_PATH);
96
+ result.hook = 'removed';
97
+ }
98
+ const settings = readSettings();
99
+ if (settings === undefined) {
100
+ result.register = 'invalid';
101
+ } else if (settings?.hooks?.SessionStart) {
102
+ settings.hooks.SessionStart = settings.hooks.SessionStart.filter((h) => !isUltracostHook(h));
103
+ if (settings.hooks.SessionStart.length === 0) delete settings.hooks.SessionStart;
104
+ if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
105
+ writeFileSync(SETTINGS, JSON.stringify(settings, null, 2) + '\n');
106
+ result.register = 'removed';
107
+ }
108
+ if (existsSync(ULTRACOST_DIR)) {
109
+ rmSync(ULTRACOST_DIR, { recursive: true, force: true });
110
+ result.policy = 'removed';
111
+ }
112
+ return result;
113
+ }
package/src/log.js ADDED
@@ -0,0 +1,18 @@
1
+ const useColor = process.stdout.isTTY && process.env.NO_COLOR === undefined;
2
+
3
+ const wrap = (code) => (s) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : String(s));
4
+
5
+ export const c = {
6
+ bold: wrap('1'),
7
+ dim: wrap('2'),
8
+ red: wrap('31'),
9
+ green: wrap('32'),
10
+ yellow: wrap('33'),
11
+ cyan: wrap('36')
12
+ };
13
+
14
+ export const log = (msg = '') => console.log(msg);
15
+ export const ok = (msg) => log(`${c.green('OK')} ${msg}`);
16
+ export const warn = (msg) => log(`${c.yellow('!!')} ${msg}`);
17
+ export const err = (msg) => log(`${c.red('XX')} ${msg}`);
18
+ export const info = (msg) => log(c.dim(msg));
package/src/paths.js ADDED
@@ -0,0 +1,27 @@
1
+ import { homedir } from 'node:os';
2
+ import { join, dirname, resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ export const HOME = homedir();
6
+ export const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
7
+
8
+ // Claude Code can relocate its config via CLAUDE_CONFIG_DIR; everything below
9
+ // hangs off it. ~/.claude/CLAUDE.md is the canonical global — ~/CLAUDE.md is not.
10
+ export const CLAUDE_DIR = process.env.CLAUDE_CONFIG_DIR
11
+ ? resolve(process.env.CLAUDE_CONFIG_DIR)
12
+ : join(HOME, '.claude');
13
+ export const CLAUDE_MD = join(CLAUDE_DIR, 'CLAUDE.md');
14
+ export const SETTINGS = join(CLAUDE_DIR, 'settings.json');
15
+ export const ULTRACOST_DIR = join(CLAUDE_DIR, 'ultracost');
16
+ export const POLICY_PATH = join(ULTRACOST_DIR, 'policy.json');
17
+ export const PROJECTS_DIR = join(CLAUDE_DIR, 'projects');
18
+
19
+ export const HOOK_PATH = join(ULTRACOST_DIR, 'reinject.mjs');
20
+
21
+ export const DEFAULT_POLICY = join(ROOT, 'templates', 'policy.default.json');
22
+ export const HOOK_SRC = join(ROOT, 'templates', 'hooks', 'reinject.mjs');
23
+
24
+ export const MARKER_START = '<!-- ultracost:start -->';
25
+ export const MARKER_END = '<!-- ultracost:end -->';
26
+
27
+ export const tilde = (p) => (p.startsWith(HOME) ? p.replace(HOME, '~') : p);
package/src/policy.js ADDED
@@ -0,0 +1,80 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { DEFAULT_POLICY, POLICY_PATH } from './paths.js';
3
+
4
+ // Resolution order: explicit path, installed policy, bundled default.
5
+ export function loadPolicy(explicitPath) {
6
+ const candidates = [explicitPath, POLICY_PATH, DEFAULT_POLICY].filter(Boolean);
7
+ for (const p of candidates) {
8
+ if (!existsSync(p)) continue;
9
+ const raw = readFileSync(p, 'utf8');
10
+ let parsed;
11
+ try {
12
+ parsed = JSON.parse(raw);
13
+ } catch (e) {
14
+ throw new Error(`Policy file is not valid JSON: ${p}\n ${e.message}`);
15
+ }
16
+ return { policy: normalize(parsed), source: p };
17
+ }
18
+ throw new Error('No policy found and bundled default is missing.');
19
+ }
20
+
21
+ export function normalize(input) {
22
+ const p = { ...input };
23
+ p.version ??= 1;
24
+ p.neverUse = (p.neverUse ?? ['haiku']).map((m) => String(m).toLowerCase());
25
+ p.allowInherit ??= false;
26
+ p.tiers ??= { opus: { model: 'opus', effort: 'xhigh' }, sonnet: { model: 'sonnet', effort: 'high' } };
27
+ p.default ??= 'opus';
28
+ p.tieBreaker ??= p.default;
29
+ p.alwaysOpus ??= [];
30
+ p.rules ??= [];
31
+
32
+ p.effort ??= {};
33
+ p.effort.range ??= ['low', 'medium', 'high', 'xhigh'];
34
+ p.effort.default ??= 'high';
35
+ p.effort.maxByModel ??= { sonnet: 'high', opus: 'xhigh' };
36
+ p.effort.byComplexity ??= {
37
+ low: 'trivial deterministic work: listing/globbing files, simple extraction, formatting, mechanical renames',
38
+ medium: 'light judgment on a small surface: a single straightforward edit, summarizing one source',
39
+ high: 'standard coding/analysis: most refactors, per-file review, non-trivial tests',
40
+ xhigh: 'hard reasoning: cross-file architecture, adversarial review, planning, final synthesis'
41
+ };
42
+
43
+ p.pricing ??= {};
44
+ p.pricing.opus ??= { input: 5, output: 25 };
45
+ p.pricing.sonnet ??= { input: 3, output: 15 };
46
+ p.pricing.haiku ??= { input: 1, output: 5 };
47
+
48
+ p.estimation ??= {};
49
+ p.estimation.tokensPerStage ??= { input: 2000, output: 1200 };
50
+ p.estimation.effortOutputMultiplier ??= { low: 0.4, medium: 1, high: 1.8, xhigh: 3, max: 4 };
51
+ p.estimation.assumedFanout ??= 5;
52
+
53
+ const errors = [];
54
+ if (!p.tiers[p.default]) errors.push(`default tier "${p.default}" is not defined in tiers`);
55
+ for (const r of p.rules) {
56
+ if (r.tier && !p.tiers[r.tier]) errors.push(`rule references unknown tier "${r.tier}"`);
57
+ }
58
+ for (const [name, t] of Object.entries(p.tiers)) {
59
+ if (!t || typeof t.model !== 'string') errors.push(`tier "${name}" is missing a string "model"`);
60
+ if (p.neverUse.includes(String(t?.model).toLowerCase())) {
61
+ errors.push(`tier "${name}" uses model "${t.model}" which is listed in neverUse`);
62
+ }
63
+ }
64
+ if (errors.length) throw new Error('Invalid policy:\n - ' + errors.join('\n - '));
65
+ return p;
66
+ }
67
+
68
+ // neverUse matches by alias or substring, so "haiku" also bans "claude-haiku-4-5".
69
+ export function classifyModel(value, policy) {
70
+ const v = String(value).toLowerCase();
71
+ if (v === 'inherit') return policy.allowInherit ? 'ok' : 'inherit';
72
+ for (const banned of policy.neverUse) {
73
+ if (v === banned || v.includes(banned)) return 'banned';
74
+ }
75
+ return 'ok';
76
+ }
77
+
78
+ export function tierModel(tierName, policy) {
79
+ return policy.tiers[tierName]?.model ?? tierName;
80
+ }
package/src/pricing.js ADDED
@@ -0,0 +1,82 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { POLICY_PATH } from './paths.js';
3
+
4
+ // The .md variant returns the clean markdown rate table; the bare URL is a JS-rendered
5
+ // SPA whose raw HTML does not parse reliably.
6
+ export const DEFAULT_PRICING_URL = 'https://platform.claude.com/docs/en/about-claude/pricing.md';
7
+ const DEFAULT_MODELS = { opus: 'Claude Opus 4.8', sonnet: 'Claude Sonnet 4.6', haiku: 'Claude Haiku 4.5' };
8
+
9
+ // Defensive bounds on the one outbound request ultracost ever makes.
10
+ const FETCH_TIMEOUT_MS = 10_000;
11
+ const MAX_BODY_LENGTH = 2 * 1024 * 1024; // ~2MB of text; the page is a few KB
12
+
13
+ // Parse per-model {input, output} from the official pricing page text. The standard
14
+ // rate row carries several dollar figures (base input + cache columns + output); the
15
+ // long-context row has only two. Pick, per model, the row with the most figures, then
16
+ // take the first as input and the last as output.
17
+ export function parsePrices(pageText, models = DEFAULT_MODELS) {
18
+ const out = {};
19
+ const lines = String(pageText).split('\n');
20
+ for (const [alias, name] of Object.entries(models)) {
21
+ let best = null;
22
+ for (const l of lines) {
23
+ if (!l.includes(name)) continue;
24
+ const amounts = [...l.matchAll(/\$\s*([0-9]+(?:\.[0-9]+)?)/g)].map((m) => parseFloat(m[1]));
25
+ if (!best || amounts.length > best.length) best = amounts;
26
+ }
27
+ if (best && best.length >= 2) out[alias] = { input: best[0], output: best[best.length - 1] };
28
+ }
29
+ return out;
30
+ }
31
+
32
+ // Fetch the official pricing page and return an updated pricing block (provenance
33
+ // refreshed). fetchImpl is injectable so tests run offline. Throws on HTTP error or
34
+ // if any model can't be parsed (page format drift) — the caller keeps old prices.
35
+ export async function refreshPricing(policy, { url, fetchImpl = globalThis.fetch } = {}) {
36
+ const models = policy.pricing?._models || DEFAULT_MODELS;
37
+ const src = url || policy.pricing?._source || DEFAULT_PRICING_URL;
38
+ if (typeof fetchImpl !== 'function') throw new Error('no fetch available (need Node >= 24 or an injected fetchImpl)');
39
+ const controller = new AbortController();
40
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
41
+ let body;
42
+ try {
43
+ const res = await fetchImpl(src, { headers: { 'user-agent': 'ultracost-pricing-refresh' }, signal: controller.signal });
44
+ if (!res.ok) throw new Error(`pricing fetch failed: HTTP ${res.status} from ${src}`);
45
+ body = await res.text();
46
+ } finally {
47
+ clearTimeout(timer);
48
+ }
49
+ if (typeof body === 'string' && body.length > MAX_BODY_LENGTH) {
50
+ throw new Error(`pricing page too large (${body.length} chars > ${MAX_BODY_LENGTH}) from ${src} — refusing to parse`);
51
+ }
52
+ const parsed = parsePrices(body, models);
53
+ const missing = Object.keys(models).filter((a) => !parsed[a]);
54
+ if (missing.length) throw new Error(`could not parse pricing for: ${missing.join(', ')} from ${src} (page format may have changed)`);
55
+ assertPlausible(parsed, src);
56
+ return { ...policy.pricing, _source: src, _asOf: new Date().toISOString().slice(0, 10), _models: models, ...parsed };
57
+ }
58
+
59
+ // Guard against a bad parse silently overwriting good prices: output must exceed input,
60
+ // input must be positive, and the models must not all be identical (a tell that the
61
+ // parser latched onto unrelated numbers, as a JS-rendered HTML page produces).
62
+ export function assertPlausible(parsed, src = 'source') {
63
+ const entries = Object.entries(parsed);
64
+ for (const [alias, p] of entries) {
65
+ if (!(p.input > 0) || !(p.output > p.input)) {
66
+ throw new Error(`implausible pricing for ${alias} ($${p.input} in / $${p.output} out) from ${src} — keeping current prices`);
67
+ }
68
+ }
69
+ const sigs = new Set(entries.map(([, p]) => `${p.input}/${p.output}`));
70
+ if (entries.length > 1 && sigs.size === 1) {
71
+ throw new Error(`all models parsed to the same price from ${src} — likely a bad parse; keeping current prices`);
72
+ }
73
+ }
74
+
75
+ // Write a refreshed pricing block into the installed policy file. Returns the path.
76
+ export function writePricingToPolicy(pricing, policyPath = POLICY_PATH) {
77
+ if (!existsSync(policyPath)) throw new Error(`no installed policy at ${policyPath} — run "ultracost init" first`);
78
+ const policy = JSON.parse(readFileSync(policyPath, 'utf8'));
79
+ policy.pricing = pricing;
80
+ writeFileSync(policyPath, JSON.stringify(policy, null, 2) + '\n');
81
+ return policyPath;
82
+ }