@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/.claude-plugin/marketplace.json +19 -0
- package/.claude-plugin/plugin.json +27 -0
- package/CHANGELOG.md +1421 -0
- package/LICENSE +202 -0
- package/NOTICE +82 -0
- package/README.md +620 -2
- package/README.zh-CN.md +555 -0
- package/bin/sakk.mjs +1460 -0
- package/integrations/litellm/LICENSE +202 -0
- package/integrations/litellm/NOTICE +82 -0
- package/integrations/litellm/README.md +366 -0
- package/integrations/litellm/config.example.yaml +52 -0
- package/integrations/litellm/sakk_gateway.py +823 -0
- package/package.json +88 -4
- package/policies/guard-default.json +161 -0
- package/policies/permissive-dev.json +22 -0
- package/prices/snapshot.json +1404 -0
- package/src/append.mjs +81 -0
- package/src/archive.mjs +489 -0
- package/src/classify.mjs +914 -0
- package/src/console-login.mjs +374 -0
- package/src/console.mjs +1628 -0
- package/src/gateway-status.mjs +203 -0
- package/src/guard-install.mjs +387 -0
- package/src/hook.mjs +730 -0
- package/src/ledger.mjs +1229 -0
- package/src/match.mjs +52 -0
- package/src/mcp.mjs +299 -0
- package/src/policy.mjs +423 -0
- package/src/prices.mjs +152 -0
- package/src/secret-rules.generated.mjs +225 -0
- package/src/secret-scan.mjs +489 -0
- package/src/spend.mjs +443 -0
package/src/spend.mjs
ADDED
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
//
|
|
3
|
+
// Spend from local logs (specs/spend/spec.md). Reads what Claude Code already
|
|
4
|
+
// writes — ~/.claude/projects/**/*.jsonl — incrementally, and prices it.
|
|
5
|
+
// Spend never goes down: a log that shrinks or is rewritten keeps what was
|
|
6
|
+
// counted (LOG_REWRITTEN). The guard's cap check reads only today's changes.
|
|
7
|
+
//
|
|
8
|
+
// Index under SAKK_HOME/spend/:
|
|
9
|
+
// files.json { "<log path>": { offset, size, mtimeMs, head } }
|
|
10
|
+
// days/<YYYY-MM-DD>.json { "<message.id>|<requestId>": { model, usage, ts } }
|
|
11
|
+
// Writes are atomic (temp + rename); entries merge by maximum per field, so two
|
|
12
|
+
// processes updating at once can re-read lines but never lose or double them.
|
|
13
|
+
|
|
14
|
+
import { createHash } from 'node:crypto';
|
|
15
|
+
import {
|
|
16
|
+
closeSync,
|
|
17
|
+
lstatSync,
|
|
18
|
+
mkdirSync,
|
|
19
|
+
openSync,
|
|
20
|
+
readFileSync,
|
|
21
|
+
readSync,
|
|
22
|
+
readdirSync,
|
|
23
|
+
statSync,
|
|
24
|
+
} from 'node:fs';
|
|
25
|
+
import { homedir } from 'node:os';
|
|
26
|
+
import { join } from 'node:path';
|
|
27
|
+
import { writeFileAtomic } from './append.mjs';
|
|
28
|
+
import { costOf, priceOf } from './prices.mjs';
|
|
29
|
+
|
|
30
|
+
export const MAX_LINE = 1024 * 1024;
|
|
31
|
+
export const MAX_FILES = 50_000;
|
|
32
|
+
const HEAD = 4096;
|
|
33
|
+
|
|
34
|
+
export class SpendError extends Error {
|
|
35
|
+
constructor(code, message) {
|
|
36
|
+
super(`${code}: ${message}`);
|
|
37
|
+
this.code = code;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Where Claude Code writes its transcripts: fixed when the cap was set, not read from the agent. */
|
|
42
|
+
export const claudeLogDir = (env = process.env) =>
|
|
43
|
+
join(env.CLAUDE_CONFIG_DIR || join(env.HOME || homedir(), '.claude'), 'projects');
|
|
44
|
+
|
|
45
|
+
/** The calendar day of a time in a zone, as YYYY-MM-DD. */
|
|
46
|
+
export function dayOf(ms, timeZone) {
|
|
47
|
+
const f = new Intl.DateTimeFormat('en-CA', {
|
|
48
|
+
timeZone,
|
|
49
|
+
year: 'numeric',
|
|
50
|
+
month: '2-digit',
|
|
51
|
+
day: '2-digit',
|
|
52
|
+
});
|
|
53
|
+
return f.format(new Date(ms));
|
|
54
|
+
}
|
|
55
|
+
export const systemZone = () => Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Every *.jsonl under `dir`, without following links out of it; bounded.
|
|
59
|
+
* A directory that exists but cannot be listed is NOT "no logs": it is pushed
|
|
60
|
+
* onto `unlistable` as { path, code } so the caller can fail closed — read as
|
|
61
|
+
* absent, it would read as $0 spend (F-204, UAT S-REL-002).
|
|
62
|
+
*/
|
|
63
|
+
export function listLogs(dir, unlistable = []) {
|
|
64
|
+
const out = [];
|
|
65
|
+
const walk = (d) => {
|
|
66
|
+
let entries;
|
|
67
|
+
try {
|
|
68
|
+
entries = readdirSync(d, { withFileTypes: true });
|
|
69
|
+
} catch (e) {
|
|
70
|
+
unlistable.push({ path: d, code: e.code ?? 'UNREADABLE' });
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
for (const e of entries) {
|
|
74
|
+
if (out.length >= MAX_FILES)
|
|
75
|
+
throw new SpendError(
|
|
76
|
+
'SPEND_TOO_MANY_LOGS',
|
|
77
|
+
`more than ${MAX_FILES} log files under ${dir}`,
|
|
78
|
+
);
|
|
79
|
+
const p = join(d, e.name);
|
|
80
|
+
if (e.isDirectory()) walk(p);
|
|
81
|
+
else if (e.isFile() && e.name.endsWith('.jsonl')) out.push(p);
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
walk(dir);
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const finiteNonNeg = (x) => typeof x === 'number' && Number.isFinite(x) && x >= 0;
|
|
89
|
+
// Token counts must be whole numbers: costOf rejects a fractional count deep in
|
|
90
|
+
// pricing, so screen it here at the parse boundary instead (F-170).
|
|
91
|
+
const tokenCount = (x) => finiteNonNeg(x) && Number.isInteger(x);
|
|
92
|
+
const USAGE_FIELDS = [
|
|
93
|
+
'input_tokens',
|
|
94
|
+
'output_tokens',
|
|
95
|
+
'cache_creation_input_tokens',
|
|
96
|
+
'cache_read_input_tokens',
|
|
97
|
+
];
|
|
98
|
+
const CACHE_SPLIT_FIELDS = ['ephemeral_5m_input_tokens', 'ephemeral_1h_input_tokens'];
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* One transcript line → null (not a priced call), { call } or { unreadable }.
|
|
102
|
+
* A call is keyed by (message.id, requestId); a line with neither is keyed by
|
|
103
|
+
* its own content hash and flagged UNKEYED.
|
|
104
|
+
*/
|
|
105
|
+
export function parseLine(line) {
|
|
106
|
+
if (line.length > MAX_LINE) return { unreadable: 'line over 1 MiB' };
|
|
107
|
+
let o;
|
|
108
|
+
try {
|
|
109
|
+
o = JSON.parse(line);
|
|
110
|
+
} catch {
|
|
111
|
+
return { unreadable: 'not JSON' };
|
|
112
|
+
}
|
|
113
|
+
if (o?.type !== 'assistant' || o.message?.usage === undefined) return null;
|
|
114
|
+
const u = o.message.usage;
|
|
115
|
+
if (u === null || typeof u !== 'object') return { unreadable: 'usage is not an object' };
|
|
116
|
+
for (const k of USAGE_FIELDS)
|
|
117
|
+
if (u[k] !== undefined && u[k] !== null && !tokenCount(u[k]))
|
|
118
|
+
return { unreadable: `${k} is not a whole-token count` };
|
|
119
|
+
const cc = u.cache_creation;
|
|
120
|
+
if (cc !== undefined && cc !== null) {
|
|
121
|
+
if (typeof cc !== 'object' || Array.isArray(cc))
|
|
122
|
+
return { unreadable: 'cache_creation is not an object' };
|
|
123
|
+
// Read as 0, a malformed split field would drop those writes from the bill
|
|
124
|
+
// whenever the aggregate is missing (F-179).
|
|
125
|
+
for (const k of CACHE_SPLIT_FIELDS)
|
|
126
|
+
if (cc[k] !== undefined && cc[k] !== null && !tokenCount(cc[k]))
|
|
127
|
+
return { unreadable: `cache_creation.${k} is not a whole-token count` };
|
|
128
|
+
}
|
|
129
|
+
const ts = Date.parse(o.timestamp);
|
|
130
|
+
if (!Number.isFinite(ts)) return { unreadable: 'no timestamp' };
|
|
131
|
+
const id = typeof o.message.id === 'string' ? o.message.id : null;
|
|
132
|
+
const req = typeof o.requestId === 'string' ? o.requestId : null;
|
|
133
|
+
const key =
|
|
134
|
+
id || req
|
|
135
|
+
? `${id ?? ''}|${req ?? ''}`
|
|
136
|
+
: `unkeyed:${createHash('sha256').update(line).digest('hex').slice(0, 16)}`;
|
|
137
|
+
const usage = {};
|
|
138
|
+
for (const k of USAGE_FIELDS) if (tokenCount(u[k])) usage[k] = u[k];
|
|
139
|
+
if (cc !== undefined && cc !== null)
|
|
140
|
+
usage.cache_creation = Object.fromEntries(CACHE_SPLIT_FIELDS.map((k) => [k, cc[k] ?? 0]));
|
|
141
|
+
for (const k of ['service_tier', 'speed', 'inference_geo'])
|
|
142
|
+
if (typeof u[k] === 'string') usage[k] = u[k];
|
|
143
|
+
return {
|
|
144
|
+
call: {
|
|
145
|
+
key,
|
|
146
|
+
model: typeof o.message.model === 'string' ? o.message.model : null,
|
|
147
|
+
usage,
|
|
148
|
+
ts,
|
|
149
|
+
unkeyed: !(id || req),
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Merge two observations of one call: every count at its maximum (streaming writes grow). */
|
|
155
|
+
export function mergeCall(a, b) {
|
|
156
|
+
if (!a) return b;
|
|
157
|
+
const usage = { ...a.usage };
|
|
158
|
+
for (const k of USAGE_FIELDS) usage[k] = Math.max(a.usage[k] ?? 0, b.usage[k] ?? 0);
|
|
159
|
+
if (a.usage.cache_creation || b.usage.cache_creation)
|
|
160
|
+
usage.cache_creation = {
|
|
161
|
+
ephemeral_5m_input_tokens: Math.max(
|
|
162
|
+
a.usage.cache_creation?.ephemeral_5m_input_tokens ?? 0,
|
|
163
|
+
b.usage.cache_creation?.ephemeral_5m_input_tokens ?? 0,
|
|
164
|
+
),
|
|
165
|
+
ephemeral_1h_input_tokens: Math.max(
|
|
166
|
+
a.usage.cache_creation?.ephemeral_1h_input_tokens ?? 0,
|
|
167
|
+
b.usage.cache_creation?.ephemeral_1h_input_tokens ?? 0,
|
|
168
|
+
),
|
|
169
|
+
};
|
|
170
|
+
for (const k of ['service_tier', 'speed', 'inference_geo']) usage[k] = b.usage[k] ?? a.usage[k];
|
|
171
|
+
const merged = { ...a, model: a.model ?? b.model, usage, ts: Math.min(a.ts, b.ts) };
|
|
172
|
+
if (a.future || b.future) merged.future = true; // once flagged future-dated, stays flagged (F-205)
|
|
173
|
+
if (a.backdated || b.backdated) merged.backdated = true; // once flagged backdated, stays flagged (F-210)
|
|
174
|
+
return merged;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// A re-read of an already-counted call: the stored row covers the line when
|
|
178
|
+
// every count is at least the line's, so merging could not grow it. A line
|
|
179
|
+
// that would grow the row — a new key included — is new money, and new money
|
|
180
|
+
// claiming an old day must not ride that day's already-checked bucket (F-210).
|
|
181
|
+
const covers = (prev, c) => {
|
|
182
|
+
if (!prev) return false;
|
|
183
|
+
for (const k of USAGE_FIELDS) if ((c.usage[k] ?? 0) > (prev.usage?.[k] ?? 0)) return false;
|
|
184
|
+
for (const k of CACHE_SPLIT_FIELDS)
|
|
185
|
+
if ((c.usage.cache_creation?.[k] ?? 0) > (prev.usage?.cache_creation?.[k] ?? 0)) return false;
|
|
186
|
+
return true;
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const readJson = (p, empty) => {
|
|
190
|
+
try {
|
|
191
|
+
return JSON.parse(readFileSync(p, 'utf8'));
|
|
192
|
+
} catch (e) {
|
|
193
|
+
if (e.code === 'ENOENT') return empty;
|
|
194
|
+
throw new SpendError('SPEND_INDEX_UNREADABLE', `${p}: ${e.message}`);
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
// the spend index is a derived cache, but use the one hardened writer (fsync +
|
|
198
|
+
// atomic rename, no write-through a planted symlink) the ledger uses
|
|
199
|
+
const writeAtomic = (p, value) => writeFileAtomic(p, JSON.stringify(value));
|
|
200
|
+
const headOf = (file) => {
|
|
201
|
+
const fd = openSync(file, 'r');
|
|
202
|
+
try {
|
|
203
|
+
const b = Buffer.alloc(HEAD);
|
|
204
|
+
const n = readSync(fd, b, 0, HEAD, 0);
|
|
205
|
+
return createHash('sha256').update(b.subarray(0, n)).digest('hex').slice(0, 16);
|
|
206
|
+
} finally {
|
|
207
|
+
closeSync(fd);
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Bring the index up to date. `since` (ms) skips files not modified since —
|
|
213
|
+
* the hook passes the start of today. `now` is the observing clock: a call
|
|
214
|
+
* whose own timestamp lies beyond it is booked to the day of observation and
|
|
215
|
+
* flagged `future` — an agent-authored timestamp must not move spend out of
|
|
216
|
+
* today's cap window (F-205, UAT S-REL-005; spec row "counted today, flagged").
|
|
217
|
+
* The same rule runs backward once a baseline is sealed: the first complete
|
|
218
|
+
* scan books everything already on disk to its claimed day (history that
|
|
219
|
+
* predates sakk's watch) and seals `baseline.json`; after that, a line first
|
|
220
|
+
* seen claiming an earlier day books the observation day, flagged `backdated`,
|
|
221
|
+
* unless the claimed day's bucket already covers it (a pure re-read) (F-210).
|
|
222
|
+
* Returns { rewritten: [paths], unreadable: n, unlistable: [{ path, code }] }.
|
|
223
|
+
*/
|
|
224
|
+
export function updateIndex({ home, logDir, zone, since = 0, now = Date.now() }) {
|
|
225
|
+
const dir = join(home, 'spend');
|
|
226
|
+
mkdirSync(join(dir, 'days'), { recursive: true });
|
|
227
|
+
const filesPath = join(dir, 'files.json');
|
|
228
|
+
const files = readJson(filesPath, {});
|
|
229
|
+
const baselinePath = join(dir, 'baseline.json');
|
|
230
|
+
const sealed = readJson(baselinePath, null) !== null;
|
|
231
|
+
if (!sealed) since = 0; // the sealing scan must be whole, never today-only
|
|
232
|
+
const today = dayOf(now, zone);
|
|
233
|
+
const days = new Map();
|
|
234
|
+
const day = (d) => {
|
|
235
|
+
if (!days.has(d)) days.set(d, readJson(join(dir, 'days', `${d}.json`), {}));
|
|
236
|
+
return days.get(d);
|
|
237
|
+
};
|
|
238
|
+
const rewritten = [];
|
|
239
|
+
let unreadable = 0;
|
|
240
|
+
const unlistable = [];
|
|
241
|
+
// The root itself is read like any subdirectory: only a root that is truly
|
|
242
|
+
// absent (ENOENT with no link entry in the way) is an honest "$0, no logs".
|
|
243
|
+
// An unreachable parent, a dangling link, a loop — anything else — is a
|
|
244
|
+
// failed reading, and read as absence it would read as $0 spend (F-208).
|
|
245
|
+
let rootPresent = false;
|
|
246
|
+
try {
|
|
247
|
+
statSync(logDir);
|
|
248
|
+
rootPresent = true;
|
|
249
|
+
} catch (e) {
|
|
250
|
+
if (e.code === 'ENOENT') {
|
|
251
|
+
let entryExists = false;
|
|
252
|
+
try {
|
|
253
|
+
lstatSync(logDir);
|
|
254
|
+
entryExists = true;
|
|
255
|
+
} catch {
|
|
256
|
+
/* nothing there at all: absence is not an error */
|
|
257
|
+
}
|
|
258
|
+
if (entryExists) unlistable.push({ path: logDir, code: 'ENOENT' });
|
|
259
|
+
} else {
|
|
260
|
+
unlistable.push({ path: logDir, code: e.code ?? 'UNREADABLE' });
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
for (const file of rootPresent ? listLogs(logDir, unlistable) : []) {
|
|
264
|
+
let st;
|
|
265
|
+
try {
|
|
266
|
+
st = statSync(file);
|
|
267
|
+
} catch (e) {
|
|
268
|
+
// listed but not statable: an absent reading, not zero spend (F-204)
|
|
269
|
+
unlistable.push({ path: file, code: e.code ?? 'UNREADABLE' });
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
const prev = files[file];
|
|
273
|
+
// mtime is agent-touchable (utimes), so `since` is an optimization, never a
|
|
274
|
+
// trust boundary: only a file the index already knows at an unchanged size
|
|
275
|
+
// may be skipped — a planted file faking an old mtime is still read (F-211)
|
|
276
|
+
if (st.mtimeMs < since && prev && prev.size === st.size) continue;
|
|
277
|
+
if (prev && prev.size === st.size && prev.mtimeMs === st.mtimeMs) continue;
|
|
278
|
+
let offset = prev?.offset ?? 0;
|
|
279
|
+
let head;
|
|
280
|
+
let text;
|
|
281
|
+
let wasRewritten = false;
|
|
282
|
+
// one try covers every read of this file: a file that lists and stats but
|
|
283
|
+
// cannot be opened (mode 000, a lock, a vanished link) is a named, absent
|
|
284
|
+
// reading like an unstatable one, never a crash of the whole walk (F-192)
|
|
285
|
+
try {
|
|
286
|
+
head = headOf(file);
|
|
287
|
+
if (prev && (st.size < prev.offset || head !== prev.head)) {
|
|
288
|
+
wasRewritten = true;
|
|
289
|
+
offset = 0;
|
|
290
|
+
}
|
|
291
|
+
const fd = openSync(file, 'r');
|
|
292
|
+
try {
|
|
293
|
+
const len = st.size - offset;
|
|
294
|
+
const b = Buffer.alloc(Math.max(0, len));
|
|
295
|
+
readSync(fd, b, 0, len, offset);
|
|
296
|
+
text = b.toString('utf8');
|
|
297
|
+
} finally {
|
|
298
|
+
closeSync(fd);
|
|
299
|
+
}
|
|
300
|
+
} catch (e) {
|
|
301
|
+
unlistable.push({ path: file, code: e.code ?? 'UNREADABLE' });
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
// counted spend stays; the file is read again from the start
|
|
305
|
+
if (wasRewritten) rewritten.push(file);
|
|
306
|
+
const lastNl = text.lastIndexOf('\n');
|
|
307
|
+
const complete = lastNl < 0 ? '' : text.slice(0, lastNl); // a partial last line waits for its end
|
|
308
|
+
for (const line of complete.split('\n')) {
|
|
309
|
+
if (line.trim() === '') continue;
|
|
310
|
+
const r = parseLine(line);
|
|
311
|
+
if (r === null) continue;
|
|
312
|
+
if (r.unreadable) {
|
|
313
|
+
unreadable += 1;
|
|
314
|
+
const d = day(dayOf(st.mtimeMs, zone));
|
|
315
|
+
d['~unreadable'] = (d['~unreadable'] ?? 0) + 1;
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
// a timestamp beyond the observing clock books to the observation day,
|
|
319
|
+
// flagged — never ahead (F-205) — and once the baseline is sealed,
|
|
320
|
+
// never behind either: new money claiming an earlier day books the
|
|
321
|
+
// observation day, flagged (F-210); YYYY-MM-DD compares as day order
|
|
322
|
+
if (r.call.ts > now) r.call.future = true;
|
|
323
|
+
let bookDay = dayOf(r.call.future ? now : r.call.ts, zone);
|
|
324
|
+
if (
|
|
325
|
+
sealed &&
|
|
326
|
+
!r.call.future &&
|
|
327
|
+
bookDay < today &&
|
|
328
|
+
!covers(day(bookDay)[r.call.key], r.call)
|
|
329
|
+
) {
|
|
330
|
+
r.call.backdated = true;
|
|
331
|
+
bookDay = today;
|
|
332
|
+
}
|
|
333
|
+
const d = day(bookDay);
|
|
334
|
+
d[r.call.key] = mergeCall(d[r.call.key], r.call);
|
|
335
|
+
}
|
|
336
|
+
files[file] = {
|
|
337
|
+
offset: offset + Buffer.byteLength(complete) + (lastNl < 0 ? 0 : 1),
|
|
338
|
+
size: st.size,
|
|
339
|
+
mtimeMs: st.mtimeMs,
|
|
340
|
+
head,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
for (const [d, calls] of days) writeAtomic(join(dir, 'days', `${d}.json`), calls);
|
|
344
|
+
writeAtomic(filesPath, files);
|
|
345
|
+
// seal only a whole scan: a directory that could not be listed holds history
|
|
346
|
+
// this scan never saw, and freezing now would strip that history's amnesty
|
|
347
|
+
if (!sealed && unlistable.length === 0) writeAtomic(baselinePath, { sealedAt: now, day: today });
|
|
348
|
+
return { rewritten, unreadable, unlistable };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** One day's spend: total of priced calls, and what could not be priced or read. */
|
|
352
|
+
export function daySpend({ home, day, table }) {
|
|
353
|
+
const calls = readJson(join(home, 'spend', 'days', `${day}.json`), {});
|
|
354
|
+
let usd = 0;
|
|
355
|
+
const unpriced = new Map();
|
|
356
|
+
const byModel = {};
|
|
357
|
+
let n = 0;
|
|
358
|
+
let future = 0;
|
|
359
|
+
let backdated = 0;
|
|
360
|
+
let unreadable = calls['~unreadable'] ?? 0;
|
|
361
|
+
for (const [key, c] of Object.entries(calls)) {
|
|
362
|
+
if (key === '~unreadable') continue;
|
|
363
|
+
n += 1;
|
|
364
|
+
if (c.future) future += 1;
|
|
365
|
+
if (c.backdated) backdated += 1;
|
|
366
|
+
const entry = priceOf(table, c.model);
|
|
367
|
+
if (!entry) {
|
|
368
|
+
unpriced.set(c.model ?? '(no model)', (unpriced.get(c.model ?? '(no model)') ?? 0) + 1);
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
let cost;
|
|
372
|
+
try {
|
|
373
|
+
cost = costOf(entry, c.usage);
|
|
374
|
+
} catch {
|
|
375
|
+
// a stored row that cannot be priced (e.g. a malformed count that reached
|
|
376
|
+
// the index through some other writer) is one unreadable line, not a
|
|
377
|
+
// crashed command — the cap then fails closed via SPEND_UNREADABLE (F-170)
|
|
378
|
+
n -= 1;
|
|
379
|
+
unreadable += 1;
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
usd += cost;
|
|
383
|
+
byModel[c.model] = (byModel[c.model] ?? 0) + cost;
|
|
384
|
+
}
|
|
385
|
+
return {
|
|
386
|
+
day,
|
|
387
|
+
usd,
|
|
388
|
+
calls: n,
|
|
389
|
+
future,
|
|
390
|
+
backdated,
|
|
391
|
+
unpriced: Object.fromEntries(unpriced),
|
|
392
|
+
unreadable,
|
|
393
|
+
byModel,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* The guard's step 2 (spec "Cap"): null when there is no cap or spend is under
|
|
399
|
+
* it; otherwise { deny: true, reason }. Fails closed: unpriced or unreadable
|
|
400
|
+
* spend today, a log folder that cannot be listed, or an index that cannot
|
|
401
|
+
* be read, is a deny.
|
|
402
|
+
*/
|
|
403
|
+
export function capCheck({ home, config, table, now = Date.now(), env = process.env }) {
|
|
404
|
+
if (config.capUsd === undefined || config.capUsd === null) return null;
|
|
405
|
+
const cap = config.capUsd;
|
|
406
|
+
if (!finiteNonNeg(cap))
|
|
407
|
+
return { deny: true, reason: 'SPEND_CAP_INVALID: the cap in config.json is not a number' };
|
|
408
|
+
const zone = config.timeZone || systemZone();
|
|
409
|
+
const today = dayOf(now, zone);
|
|
410
|
+
const logDir = config.claudeLogDir || claudeLogDir(env);
|
|
411
|
+
// files untouched since the start of today hold no new spend for today
|
|
412
|
+
const startOfToday = now - 36 * 3600 * 1000; // generous: any zone's midnight lies within 36 h
|
|
413
|
+
const idx = updateIndex({ home, logDir, zone, since: startOfToday, now });
|
|
414
|
+
if (idx.unlistable.length) {
|
|
415
|
+
const first = idx.unlistable[0];
|
|
416
|
+
return {
|
|
417
|
+
deny: true,
|
|
418
|
+
reason: `SPEND_LOGS_UNLISTABLE: ${idx.unlistable.length} path(s) under ${logDir} could not be read (${first.path}: ${first.code}), so today's spend cannot be counted — the cap fails closed until a person fixes the permissions`,
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
const s = daySpend({ home, day: today, table });
|
|
422
|
+
const models = Object.keys(s.unpriced);
|
|
423
|
+
if (models.length)
|
|
424
|
+
return {
|
|
425
|
+
deny: true,
|
|
426
|
+
reason: `PRICE_UNKNOWN: ${models.join(', ')} has no price, so today's spend cannot be shown to be under the cap — add the model to ${home}/prices.json (copy prices/snapshot.json there first)`,
|
|
427
|
+
};
|
|
428
|
+
if (s.unreadable)
|
|
429
|
+
return {
|
|
430
|
+
deny: true,
|
|
431
|
+
reason: `SPEND_UNREADABLE: ${s.unreadable} log line(s) today could not be read, so the cap cannot be checked`,
|
|
432
|
+
};
|
|
433
|
+
if (s.usd >= cap) {
|
|
434
|
+
const back = s.backdated
|
|
435
|
+
? ` — includes ${s.backdated} line(s) first seen today claiming an earlier day, counted today (F-210)`
|
|
436
|
+
: '';
|
|
437
|
+
return {
|
|
438
|
+
deny: true,
|
|
439
|
+
reason: `SPEND_CAP_REACHED: $${s.usd.toFixed(2)} ≥ $${cap.toFixed(2)} today (${today}, ${zone}, from local logs)${back} — a person raises it with sakk cap set <usd>; it resets at midnight`,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
return null;
|
|
443
|
+
}
|