@sakk-dev/sakk 0.0.0-stage → 0.4.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/policy.mjs ADDED
@@ -0,0 +1,423 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ //
3
+ // Policy kernel (sakk-policy.v1) — the deterministic, auditable answer to
4
+ // "may this agent do this action at this cost?", decided BEFORE any
5
+ // reservation exists. Design signed 2026-10-15 (specs/policy-engine/):
6
+ // - decide() is PURE: same policy bytes + same context → same verdict.
7
+ // - This module never imports the ledger and never performs the action;
8
+ // enforcement belongs to the enforcer (MCP server / hook / CI gate),
9
+ // which calls reserve() only on an ALLOW.
10
+ // - Every refusal carries a stable machine code (public API).
11
+
12
+ import { createHash } from 'node:crypto';
13
+ import { readFileSync, statSync } from 'node:fs';
14
+ import { appendNoFollow } from './append.mjs';
15
+ import { matchPath, matchesAny, prefixCovers, isCanonicalRelPath } from './match.mjs';
16
+
17
+ // eslint-disable-next-line no-control-regex -- control characters are what it finds
18
+ const CONTROL = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/;
19
+
20
+ export class PolicyError extends Error {
21
+ constructor(code, message, details = {}) {
22
+ super(`${code}: ${message}`);
23
+ this.code = code;
24
+ this.details = details;
25
+ }
26
+ }
27
+
28
+ export const POLICY_SCHEMA = 'sakk-policy.v1';
29
+ export const MAX_POLICY_BYTES = 4 * 1024 * 1024; // above the work-budget attacks (25k rules ≈ 1.6 MB), which refuse by budget
30
+ const VERDICTS = ['allow', 'deny', 'require_approval'];
31
+ const RULE_KEYS = ['id', 'match', 'verdict', 'approvers_min'];
32
+ const MATCH_KEYS = ['agent', 'action', 'scope', 'max_estimated_usd'];
33
+
34
+ const isPlainObject = (x) => typeof x === 'object' && x !== null && !Array.isArray(x);
35
+ const isFiniteNonNeg = (x) => typeof x === 'number' && Number.isFinite(x) && x >= 0;
36
+
37
+ function bad(code, message, details) {
38
+ throw new PolicyError(code, message, details);
39
+ }
40
+
41
+ function validatePolicyDoc(doc, policyPath) {
42
+ const inv = (why, details = {}) =>
43
+ bad('POLICY_SCHEMA_INVALID', `${why} (${policyPath})`, { policyPath, ...details });
44
+ if (!isPlainObject(doc)) inv('policy root must be an object');
45
+ for (const k of Object.keys(doc))
46
+ if (!['schema', 'default_verdict', 'rules'].includes(k)) inv(`unknown top-level key "${k}"`);
47
+ if (doc.schema !== POLICY_SCHEMA) inv(`schema must be "${POLICY_SCHEMA}"`, { got: doc.schema });
48
+ if (!VERDICTS.includes(doc.default_verdict))
49
+ inv(
50
+ 'default_verdict is mandatory: allow | deny | require_approval — there is no implicit default',
51
+ );
52
+ if (!Array.isArray(doc.rules)) inv('rules must be an array');
53
+ const seen = new Set();
54
+ doc.rules.forEach((r, i) => {
55
+ if (!isPlainObject(r)) inv(`rules[${i}] must be an object`);
56
+ for (const k of Object.keys(r))
57
+ if (!RULE_KEYS.includes(k)) inv(`rules[${i}]: unknown key "${k}"`);
58
+ if (typeof r.id !== 'string' || r.id === '') inv(`rules[${i}]: id must be a non-empty string`);
59
+ // ids and names reach refusal messages and terminals (review N4)
60
+ if (CONTROL.test(r.id)) inv(`rules[${i}]: id contains control characters`);
61
+ if (seen.has(r.id)) inv(`duplicate rule id "${r.id}"`);
62
+ seen.add(r.id);
63
+ if (!VERDICTS.includes(r.verdict)) inv(`rule "${r.id}": unknown verdict`, { got: r.verdict });
64
+ if (!isPlainObject(r.match)) inv(`rule "${r.id}": match must be an object`);
65
+ for (const k of Object.keys(r.match))
66
+ if (!MATCH_KEYS.includes(k)) inv(`rule "${r.id}": unknown match key "${k}"`);
67
+ for (const f of ['agent', 'action']) {
68
+ if (f in r.match && (typeof r.match[f] !== 'string' || r.match[f] === ''))
69
+ inv(`rule "${r.id}": match.${f} must be a non-empty string`);
70
+ // review S3: F10's no-globs rule held for scope only — a 'ci-*' deny
71
+ // was a literal that never fired. Only the exact '*' is a wildcard.
72
+ if (f in r.match && r.match[f] !== '*' && /[*?[\]]/.test(r.match[f]))
73
+ inv(
74
+ `rule "${r.id}": match.${f} "${r.match[f]}" is not a glob — only the exact "*" is a wildcard, so this rule would never match`,
75
+ { got: r.match[f] },
76
+ );
77
+ if (f in r.match && CONTROL.test(r.match[f]))
78
+ inv(`rule "${r.id}": match.${f} contains control characters`);
79
+ }
80
+ if ('scope' in r.match) {
81
+ if (!Array.isArray(r.match.scope) || r.match.scope.length === 0)
82
+ inv(`rule "${r.id}": match.scope must be a non-empty array of path prefix rules`);
83
+ for (const s of r.match.scope) {
84
+ if (typeof s !== 'string' || s === '' || s.startsWith('/'))
85
+ inv(`rule "${r.id}": scope entries are relative path prefix rules`, { got: s });
86
+ // Review finding N2 (verify pass): ANY entry the matcher can never
87
+ // satisfy is a silent dead rule — 'docs//x', './docs', 'a/../b'
88
+ // fail-closed on the context side (F2), so as RULE entries they
89
+ // could only ever disable a deny. Refuse the whole family.
90
+ const dirBase = s.endsWith('/') ? s.slice(0, -1) : s;
91
+ if (!isCanonicalRelPath(dirBase))
92
+ inv(
93
+ `rule "${r.id}": scope entry "${s}" is non-canonical and can never match any valid context`,
94
+ { got: s },
95
+ );
96
+ // Review finding F10: prefix semantics have no wildcards — a 'prod/**'
97
+ // entry would be a dead rule that silently never fires.
98
+ if (s.includes('*'))
99
+ inv(
100
+ `rule "${r.id}": scope entries are prefix rules, not globs — "${s}" would never match anything`,
101
+ { got: s },
102
+ );
103
+ }
104
+ }
105
+ if (
106
+ 'max_estimated_usd' in r.match &&
107
+ !(isFiniteNonNeg(r.match.max_estimated_usd) && r.match.max_estimated_usd > 0)
108
+ )
109
+ inv(`rule "${r.id}": max_estimated_usd must be a finite number > 0`);
110
+ if ('approvers_min' in r) {
111
+ if (r.verdict !== 'require_approval')
112
+ inv(`rule "${r.id}": approvers_min only belongs on require_approval rules`);
113
+ if (!Number.isInteger(r.approvers_min) || r.approvers_min < 1)
114
+ inv(`rule "${r.id}": approvers_min must be an integer >= 1`);
115
+ }
116
+ });
117
+ // R-04: a gate that can only say yes is not a gate — empty or not
118
+ // (review S4: rules that are all allow, or a first rule that allows
119
+ // everything, loaded although the README promised this refusal).
120
+ const unconditional = (r) =>
121
+ Object.entries(r.match).every(([k, v]) => (k === 'agent' || k === 'action') && v === '*');
122
+ if (
123
+ (doc.default_verdict === 'allow' && doc.rules.every((r) => r.verdict === 'allow')) ||
124
+ (doc.rules.length > 0 && doc.rules[0].verdict === 'allow' && unconditional(doc.rules[0]))
125
+ )
126
+ bad(
127
+ 'POLICY_VACUOUS_ALLOW',
128
+ `this policy can only say yes — it can never refuse anything (${policyPath})`,
129
+ { policyPath },
130
+ );
131
+ }
132
+
133
+ /** Field-level coverage: does constraint of `a` accept every context that
134
+ * `b`'s accepts? Only the decidable cases return true. */
135
+ function fieldCovers(a, b, field) {
136
+ if (!(field in a.match)) return true; // absent = matches anything
137
+ if ((field === 'agent' || field === 'action') && a.match[field] === '*') return true; // '*' too
138
+ if (!(field in b.match)) return false; // a constrains, b does not
139
+ if (field === 'agent' || field === 'action') return a.match[field] === b.match[field];
140
+ if (field === 'scope')
141
+ return b.match.scope.every((sb) => a.match.scope.some((sa) => prefixCovers(sa, sb)));
142
+ if (field === 'max_estimated_usd') return a.match.max_estimated_usd >= b.match.max_estimated_usd;
143
+ return false;
144
+ }
145
+
146
+ // Review finding F4: the pairwise scan is quadratic and the per-pair scope
147
+ // subset check is quadratic again — a schema-legal file could park loadPolicy
148
+ // for minutes. Two defenses: a cheap compatibility pre-filter so benign
149
+ // many-rule policies stay near-linear, and a hard work budget — exceeding it
150
+ // REFUSES (POLICY_TOO_COMPLEX): bounded time by refusal, never by hanging.
151
+ // Weighted units: 1 per pair ITERATION (review N1: the pre-filtered fast
152
+ // path is charged too — a guard's own bypass is an attack surface), 1 per
153
+ // simple field check, 10 per scope-entry comparison.
154
+ const SHADOW_SCAN_BUDGET = 200_000_000;
155
+
156
+ function findShadowedRule(rules, policyPath) {
157
+ let ops = 0;
158
+ const overBudget = () =>
159
+ bad(
160
+ 'POLICY_TOO_COMPLEX',
161
+ `shadow analysis exceeded its work budget — split the policy or reduce overlapping rules (${policyPath})`,
162
+ { policyPath, budget: SHADOW_SCAN_BUDGET },
163
+ );
164
+ const coversWithBudget = (a, b, field) => {
165
+ if (field === 'scope' && field in a.match && field in b.match)
166
+ ops += 10 * a.match.scope.length * b.match.scope.length;
167
+ else ops += 1;
168
+ if (ops > SHADOW_SCAN_BUDGET) overBudget();
169
+ return fieldCovers(a, b, field);
170
+ };
171
+ for (let j = 1; j < rules.length; j++) {
172
+ const mj = rules[j].match;
173
+ for (let i = 0; i < j; i++) {
174
+ ops += 1; // N1: EVERY pair costs — including ones the pre-filter skips
175
+ if (ops > SHADOW_SCAN_BUDGET) overBudget();
176
+ const mi = rules[i].match;
177
+ // pre-filter: i can only cover j if its agent/action constraint is
178
+ // absent, '*', or literally equal — O(1), no allocation
179
+ if ('agent' in mi && mi.agent !== '*' && !('agent' in mj && mi.agent === mj.agent)) continue;
180
+ if ('action' in mi && mi.action !== '*' && !('action' in mj && mi.action === mj.action))
181
+ continue;
182
+ if (MATCH_KEYS.every((f) => coversWithBudget(rules[i], rules[j], f)))
183
+ return { by: rules[i].id, dead: rules[j].id };
184
+ }
185
+ }
186
+ return null;
187
+ }
188
+
189
+ // Review finding F7: JSON duplicate keys are parser-dependent ("deny" to a
190
+ // human reader, "allow" to last-wins JSON.parse) — a hashed file must have
191
+ // ONE meaning. This scanner walks the already-parse-validated text and
192
+ // refuses any object with a repeated key, at any depth.
193
+ function scanDuplicateKeys(text, policyPath) {
194
+ const frames = [];
195
+ let i = 0;
196
+ const n = text.length;
197
+ let pendingKey = null;
198
+ while (i < n) {
199
+ const c = text[i];
200
+ if (c === '"') {
201
+ const start = ++i;
202
+ while (i < n && text[i] !== '"') i += text[i] === '\\' ? 2 : 1;
203
+ const spanEnd = i;
204
+ i += 1; // closing quote
205
+ const top = frames[frames.length - 1];
206
+ if (top && top.type === 'obj' && top.expectKey) {
207
+ // normalize escapes the way JSON.parse does, so "a" === "a"
208
+ pendingKey = JSON.parse(`"${text.slice(start, spanEnd)}"`);
209
+ top.expectKey = false;
210
+ }
211
+ continue;
212
+ }
213
+ if (c === '{') {
214
+ frames.push({ type: 'obj', keys: new Set(), expectKey: true });
215
+ pendingKey = null;
216
+ } else if (c === '[') frames.push({ type: 'arr' });
217
+ else if (c === '}' || c === ']') frames.pop();
218
+ else if (c === ':') {
219
+ const top = frames[frames.length - 1];
220
+ if (top && top.type === 'obj' && pendingKey !== null) {
221
+ if (top.keys.has(pendingKey))
222
+ bad(
223
+ 'POLICY_SCHEMA_INVALID',
224
+ `duplicate JSON key "${pendingKey}" — a hashed policy must have exactly one meaning (${policyPath})`,
225
+ { policyPath, key: pendingKey },
226
+ );
227
+ top.keys.add(pendingKey);
228
+ pendingKey = null;
229
+ }
230
+ } else if (c === ',') {
231
+ const top = frames[frames.length - 1];
232
+ if (top && top.type === 'obj') top.expectKey = true;
233
+ }
234
+ i += 1;
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Load, validate and hash a policy file. The hash is SHA-256 over the RAW
240
+ * file bytes — what the auditor hashes is what decided; duplicate-key
241
+ * documents (whose meaning depends on the parser) refuse outright.
242
+ * Throws: POLICY_UNREADABLE | POLICY_TOO_LARGE | POLICY_SCHEMA_INVALID | POLICY_VACUOUS_ALLOW |
243
+ * POLICY_RULE_SHADOWED | POLICY_TOO_COMPLEX
244
+ */
245
+ export function loadPolicy({ policyPath }) {
246
+ if (typeof policyPath !== 'string' || policyPath === '')
247
+ bad('POLICY_UNREADABLE', 'policyPath is required — policies are never implicit', {});
248
+ let raw;
249
+ try {
250
+ // a fifo or device would block the read forever (review N1): files only
251
+ const st = statSync(policyPath);
252
+ if (!st.isFile())
253
+ bad('POLICY_UNREADABLE', `the policy path is not a regular file (${policyPath})`, {
254
+ policyPath,
255
+ });
256
+ // C-04: a policy is read whole on every hook call, so its size is bounded
257
+ if (st.size > MAX_POLICY_BYTES)
258
+ bad(
259
+ 'POLICY_TOO_LARGE',
260
+ `the policy file is larger than ${MAX_POLICY_BYTES} bytes (${policyPath})`,
261
+ {
262
+ policyPath,
263
+ bytes: st.size,
264
+ },
265
+ );
266
+ raw = readFileSync(policyPath);
267
+ } catch (e) {
268
+ if (e instanceof PolicyError) throw e;
269
+ bad('POLICY_UNREADABLE', `cannot read policy file: ${e.code ?? e.message}`, { policyPath });
270
+ }
271
+ let doc;
272
+ try {
273
+ doc = JSON.parse(raw.toString('utf8'));
274
+ } catch {
275
+ bad('POLICY_UNREADABLE', `policy file is not valid JSON (${policyPath})`, { policyPath });
276
+ }
277
+ scanDuplicateKeys(raw.toString('utf8'), policyPath);
278
+ validatePolicyDoc(doc, policyPath);
279
+ const shadowed = findShadowedRule(doc.rules, policyPath);
280
+ if (shadowed)
281
+ bad(
282
+ 'POLICY_RULE_SHADOWED',
283
+ `rule "${shadowed.dead}" can never fire — every context it matches is taken first by "${shadowed.by}"`,
284
+ { policyPath, ...shadowed },
285
+ );
286
+ const policyHash = createHash('sha256').update(raw).digest('hex');
287
+ return { policy: doc, policyHash };
288
+ }
289
+
290
+ const MAX_CONTEXT_STRING = 4096;
291
+
292
+ function validateContext(context) {
293
+ const inv = (why, details = {}) => bad('CONTEXT_INVALID', why, details);
294
+ if (!isPlainObject(context)) inv('context must be an object');
295
+ if (!isFiniteNonNeg(context.estimatedUsd))
296
+ inv('context.estimatedUsd must be a finite number >= 0 — no coercion, ever', {
297
+ got: context.estimatedUsd,
298
+ });
299
+ if (typeof context.callId !== 'string' || context.callId === '')
300
+ inv('context.callId is required — an unattributable decision is unauditable');
301
+ for (const f of ['agent', 'action', 'scope', 'callId'])
302
+ if (f in context && context[f] !== undefined) {
303
+ if (typeof context[f] !== 'string')
304
+ inv(`context.${f} must be a string when present`, { field: f });
305
+ // Review finding F11: unbounded strings flow into the audit log verbatim.
306
+ if (context[f].length > MAX_CONTEXT_STRING)
307
+ inv(`context.${f} exceeds ${MAX_CONTEXT_STRING} chars`, { field: f });
308
+ // review N3: '' is no agent and no action — the ledger refuses it later,
309
+ // after the decision was already logged as an allow
310
+ if (context[f] === '') inv(`context.${f} must not be empty when present`, { field: f });
311
+ }
312
+ // Review finding F2: a non-canonical scope ('prod//x', './prod/x',
313
+ // '/prod/x', 'a/../b') can match NO rule — in a deny-first policy that
314
+ // slides past an explicit deny into a later allow or a permissive
315
+ // default. Refuse the alias outright; canonicalizing (and unicode
316
+ // normalization) is the enforcer's documented duty.
317
+ if (typeof context.scope === 'string' && !isCanonicalRelPath(context.scope))
318
+ inv('context.scope must be a canonical relative path — aliases are refused, not ignored', {
319
+ got: context.scope.slice(0, 80),
320
+ });
321
+ }
322
+
323
+ function ruleMatches(rule, context) {
324
+ const m = rule.match;
325
+ if ('agent' in m && m.agent !== '*' && m.agent !== context.agent) return false;
326
+ if ('action' in m && m.action !== '*' && m.action !== context.action) return false;
327
+ if ('scope' in m) {
328
+ // a context without a scope cannot satisfy a scope-constrained rule
329
+ if (typeof context.scope !== 'string') return false;
330
+ if (!matchesAny(context.scope, m.scope)) return false;
331
+ }
332
+ // AT the limit matches (plain <=); the ledger's epsilon wall is the
333
+ // ledger's own, independent concern.
334
+ if ('max_estimated_usd' in m && !(context.estimatedUsd <= m.max_estimated_usd)) return false;
335
+ return true;
336
+ }
337
+
338
+ /**
339
+ * PURE decision: no I/O, no clock, no randomness. First match wins, top to
340
+ * bottom; no rule matched → the mandatory default verdict with ruleId null.
341
+ * Throws: CONTEXT_INVALID
342
+ */
343
+ export function decide({ policy, policyHash, context }) {
344
+ // COPY FIRST, validate the copy, judge the copy, log the copy. Each caller
345
+ // property is read exactly ONCE: a getter that answers the validator one
346
+ // value and the matcher another (review finding F5 — the lock-protocol
347
+ // TOCTOU, reborn in miniature) gets no second read, and mutating the
348
+ // caller's object after decide() cannot rewrite the audit log.
349
+ const judged = isPlainObject(context)
350
+ ? {
351
+ agent: context.agent,
352
+ action: context.action,
353
+ scope: context.scope,
354
+ estimatedUsd: context.estimatedUsd,
355
+ callId: context.callId,
356
+ }
357
+ : context;
358
+ validateContext(judged);
359
+ const finish = (verdict, ruleId, approversMinRaw) => {
360
+ const d = { verdict, ruleId, policyHash, context: judged };
361
+ // Review finding F1: the default path MUST carry approversMin too —
362
+ // "unmatched means a human signs" fell open as `0 < undefined === false`.
363
+ if (verdict === 'require_approval') d.approversMin = approversMinRaw ?? 1;
364
+ return d;
365
+ };
366
+ for (const rule of policy.rules)
367
+ if (ruleMatches(rule, judged)) return finish(rule.verdict, rule.id, rule.approvers_min);
368
+ return finish(policy.default_verdict, null, undefined);
369
+ }
370
+
371
+ /**
372
+ * Append one decision to the JSONL log. An unauditable allow is a deny:
373
+ * any failure here throws DECISION_UNLOGGABLE and the enforcer must refuse
374
+ * the action.
375
+ */
376
+ export function logDecision({ decisionLogPath, decision, by }) {
377
+ // Review finding F6: the log line format is public API, so the gate is
378
+ // strict — only something shaped exactly like a decide() result may enter
379
+ // the audit trail, and ANY failure (including serialization) is the one
380
+ // typed refusal. Accepts only loadPolicy/decide outputs by construction.
381
+ if (
382
+ typeof decisionLogPath !== 'string' ||
383
+ decisionLogPath === '' ||
384
+ !isPlainObject(decision) ||
385
+ !VERDICTS.includes(decision.verdict) ||
386
+ typeof decision.policyHash !== 'string' ||
387
+ !/^[0-9a-f]{64}$/.test(decision.policyHash) ||
388
+ !(decision.ruleId === null || typeof decision.ruleId === 'string') ||
389
+ !(
390
+ decision.context === undefined ||
391
+ decision.context === null ||
392
+ isPlainObject(decision.context)
393
+ ) ||
394
+ // N3: approversMin iff require_approval — exactly what decide() emits
395
+ !(decision.verdict === 'require_approval'
396
+ ? Number.isInteger(decision.approversMin) && decision.approversMin >= 1
397
+ : decision.approversMin === undefined) ||
398
+ // N4: the enforcer-supplied signer name obeys the same string cap
399
+ (typeof by === 'string' && by.length > MAX_CONTEXT_STRING)
400
+ )
401
+ bad('DECISION_UNLOGGABLE', 'only a decide()-shaped decision can enter the audit trail', {
402
+ decisionLogPath,
403
+ });
404
+ let line;
405
+ try {
406
+ line = `${JSON.stringify({
407
+ at: new Date().toISOString(),
408
+ by: typeof by === 'string' && by !== '' ? by : null,
409
+ verdict: decision.verdict,
410
+ ruleId: decision.ruleId ?? null,
411
+ ...(decision.approversMin !== undefined ? { approversMin: decision.approversMin } : {}),
412
+ policyHash: decision.policyHash,
413
+ context: decision.context ?? null,
414
+ })}\n`;
415
+ appendNoFollow(decisionLogPath, line); // never written through a planted symlink (review S4)
416
+ } catch (e) {
417
+ bad('DECISION_UNLOGGABLE', `decision log write failed: ${e.code ?? e.message}`, {
418
+ decisionLogPath,
419
+ });
420
+ }
421
+ }
422
+
423
+ export { matchPath };
package/src/prices.mjs ADDED
@@ -0,0 +1,152 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ //
3
+ // Prices (specs/spend/spec.md, "Price"). The table is data, never code: a
4
+ // trimmed snapshot of LiteLLM's model list ships with the package
5
+ // (maintainers regenerate it with scripts/prices-trim.mjs), and an operator
6
+ // can override or extend it with a sakk-prices.v1 table at SAKK_HOME/prices.json.
7
+ // A model missing from the table is unpriced — never guessed.
8
+ //
9
+ // Table shape (sakk-prices.v1), prices in USD per token:
10
+ // { schema, source: { url, commit, sha256, fetchedAt, license },
11
+ // models: { "<id>": { input, output, cacheWrite5m, cacheWrite1h, cacheRead,
12
+ // aliases?: [..],
13
+ // longContext?: { thresholdTokens, input, output, cacheWrite5m, cacheWrite1h, cacheRead },
14
+ // multipliers?: { "<usage field>": { "<value>": factor } } } } }
15
+
16
+ import { existsSync, readFileSync, statSync } from 'node:fs';
17
+ import { dirname, join } from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+
20
+ export const PRICES_SCHEMA = 'sakk-prices.v1';
21
+ export const MAX_PRICES_BYTES = 256 * 1024; // the plugin directory's per-file limit
22
+ export const SHIPPED_PRICES = join(
23
+ dirname(fileURLToPath(import.meta.url)),
24
+ '..',
25
+ 'prices',
26
+ 'snapshot.json',
27
+ );
28
+ const KINDS = ['input', 'output', 'cacheWrite5m', 'cacheWrite1h', 'cacheRead'];
29
+
30
+ export class PriceError extends Error {
31
+ constructor(code, message) {
32
+ super(`${code}: ${message}`);
33
+ this.code = code;
34
+ }
35
+ }
36
+
37
+ const finiteNonNeg = (x) => typeof x === 'number' && Number.isFinite(x) && x >= 0;
38
+
39
+ /** A table is refused whole if any entry is not a price: a half-read table would under-count. */
40
+ export function validateTable(t, where) {
41
+ const bad = (why) => {
42
+ throw new PriceError('PRICES_INVALID', `${where}: ${why}`);
43
+ };
44
+ if (t === null || typeof t !== 'object' || t.schema !== PRICES_SCHEMA)
45
+ bad(`schema must be ${PRICES_SCHEMA}`);
46
+ if (t.models === null || typeof t.models !== 'object' || Array.isArray(t.models))
47
+ bad('models must be an object');
48
+ const names = new Map();
49
+ for (const [id, m] of Object.entries(t.models)) {
50
+ for (const k of KINDS)
51
+ if (!finiteNonNeg(m?.[k])) bad(`${id}.${k} is not a non-negative number`);
52
+ if (m.longContext !== undefined) {
53
+ if (!(Number.isInteger(m.longContext.thresholdTokens) && m.longContext.thresholdTokens > 0))
54
+ bad(`${id}.longContext.thresholdTokens must be a positive integer`);
55
+ for (const k of KINDS)
56
+ if (!finiteNonNeg(m.longContext[k])) bad(`${id}.longContext.${k} is not a price`);
57
+ }
58
+ for (const [field, values] of Object.entries(m.multipliers ?? {}))
59
+ for (const [v, f] of Object.entries(values ?? {}))
60
+ if (!finiteNonNeg(f) || f === 0)
61
+ bad(`${id}.multipliers.${field}.${v} must be a positive number`);
62
+ for (const name of [id, ...(m.aliases ?? [])]) {
63
+ if (typeof name !== 'string' || name === '') bad(`${id}: an alias is not a name`);
64
+ if (names.has(name) && names.get(name) !== id)
65
+ bad(`${name} names two models (${names.get(name)}, ${id})`);
66
+ names.set(name, id);
67
+ }
68
+ }
69
+ return t;
70
+ }
71
+
72
+ /**
73
+ * The table in use: SAKK_HOME/prices.json when present (a malformed one is
74
+ * refused — never a silent fall back to the snapshot), else the shipped one.
75
+ */
76
+ export function loadPrices({ home, shipped = SHIPPED_PRICES } = {}) {
77
+ const user = home ? join(home, 'prices.json') : null;
78
+ const path = user && existsSync(user) ? user : shipped;
79
+ let raw;
80
+ try {
81
+ const st = statSync(path);
82
+ if (!st.isFile()) throw new PriceError('PRICES_UNREADABLE', `${path} is not a file`);
83
+ if (st.size > MAX_PRICES_BYTES)
84
+ throw new PriceError('PRICES_UNREADABLE', `${path} is larger than ${MAX_PRICES_BYTES} bytes`);
85
+ raw = readFileSync(path, 'utf8');
86
+ } catch (e) {
87
+ if (e instanceof PriceError) throw e;
88
+ throw new PriceError('PRICES_UNREADABLE', `${path}: ${e.code ?? e.message}`);
89
+ }
90
+ let t;
91
+ try {
92
+ t = JSON.parse(raw);
93
+ } catch {
94
+ throw new PriceError('PRICES_INVALID', `${path} is not JSON`);
95
+ }
96
+ return { table: validateTable(t, path), path };
97
+ }
98
+
99
+ /** The entry for a model id: the id itself or a listed alias. Nothing else is guessed. */
100
+ export function priceOf(table, model) {
101
+ if (typeof model !== 'string' || model === '') return null;
102
+ if (Object.hasOwn(table.models, model)) return table.models[model];
103
+ for (const m of Object.values(table.models)) if ((m.aliases ?? []).includes(model)) return m;
104
+ return null;
105
+ }
106
+
107
+ const count = (u, k) => {
108
+ const v = u?.[k];
109
+ if (v === undefined || v === null) return 0;
110
+ if (!finiteNonNeg(v) || !Number.isInteger(v))
111
+ throw new PriceError('USAGE_INVALID', `${k} is not a token count`);
112
+ return v;
113
+ };
114
+
115
+ /**
116
+ * The cost of one call from its usage (specs/spend/spec.md, "Price"):
117
+ * each token kind at its own price; a 1-hour cache write at its own price;
118
+ * past the long-context threshold the whole request at the higher rates;
119
+ * multipliers matched on usage fields (e.g. speed, inference_geo).
120
+ */
121
+ export function costOf(entry, usage) {
122
+ const input = count(usage, 'input_tokens');
123
+ const output = count(usage, 'output_tokens');
124
+ const cacheRead = count(usage, 'cache_read_input_tokens');
125
+ const cacheWrite = count(usage, 'cache_creation_input_tokens');
126
+ const split = usage?.cache_creation;
127
+ let w5 = cacheWrite;
128
+ let w1h = 0;
129
+ if (split && typeof split === 'object') {
130
+ const s5 = count(split, 'ephemeral_5m_input_tokens');
131
+ const s1h = count(split, 'ephemeral_1h_input_tokens');
132
+ // The split refines the price of the aggregate, never shrinks it: an
133
+ // empty cache_creation:{} (F-168) or one naming only part of the writes
134
+ // (F-179) leaves the rest at the 5-minute price, as a row with no split.
135
+ w1h = s1h;
136
+ w5 = Math.max(s5, cacheWrite - s1h);
137
+ }
138
+ const prompt = input + cacheRead + w5 + w1h;
139
+ const rate =
140
+ entry.longContext && prompt > entry.longContext.thresholdTokens ? entry.longContext : entry;
141
+ let usd =
142
+ input * rate.input +
143
+ output * rate.output +
144
+ w5 * rate.cacheWrite5m +
145
+ w1h * rate.cacheWrite1h +
146
+ cacheRead * rate.cacheRead;
147
+ for (const [field, values] of Object.entries(entry.multipliers ?? {})) {
148
+ const v = usage?.[field];
149
+ if (typeof v === 'string' && Object.hasOwn(values, v)) usd *= values[v];
150
+ }
151
+ return usd;
152
+ }