@nyte-ai/host 0.0.18 → 0.0.19
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/THIRD-PARTY-NOTICES.md +66 -0
- package/dist/chunks/{canvas-preview-5hxat7hh.js → canvas-preview-11nadadc.js} +8 -6
- package/dist/chunks/{canvas-preview-rp6fz0w5.js → canvas-preview-2jwbe14k.js} +5 -5
- package/dist/chunks/{canvas-preview-m02v4yy6.js → canvas-preview-55s0y68g.js} +3 -2
- package/dist/chunks/{canvas-preview-xn10d4f8.js → canvas-preview-5ekjx6sw.js} +9 -4
- package/dist/chunks/{canvas-preview-75db60xy.js → canvas-preview-8xc0vn7v.js} +5 -4
- package/dist/chunks/{canvas-preview-zn2d0k9d.js → canvas-preview-af86jz8t.js} +14 -6
- package/dist/chunks/{canvas-preview-d9wwhbtz.js → canvas-preview-aq5j4p25.js} +1 -1
- package/dist/chunks/{canvas-preview-j1tnf51w.js → canvas-preview-b2v8wfnz.js} +1 -1
- package/dist/chunks/canvas-preview-befkjea2.js +60 -0
- package/dist/chunks/canvas-preview-bxnqt8cd.js +103 -0
- package/dist/chunks/{canvas-preview-vax9ne4e.js → canvas-preview-cawy6vbz.js} +22 -11
- package/dist/chunks/{canvas-preview-senpjwep.js → canvas-preview-crmzxxt8.js} +3 -3
- package/dist/chunks/{canvas-preview-ymgz0937.js → canvas-preview-drctegz4.js} +8 -4
- package/dist/chunks/{canvas-preview-yrfz2ak6.js → canvas-preview-e3vnkkc5.js} +6 -6
- package/dist/chunks/canvas-preview-ec2mt7es.js +108 -0
- package/dist/chunks/{canvas-preview-1ta3avah.js → canvas-preview-fxa0rfb0.js} +10 -5
- package/dist/chunks/{canvas-preview-8xgt67ka.js → canvas-preview-g5fkjs34.js} +1 -1
- package/dist/chunks/{canvas-preview-g3qj3y3k.js → canvas-preview-jeddjex8.js} +13 -3
- package/dist/chunks/{canvas-preview-0nrnbt76.js → canvas-preview-k1yg4fgp.js} +199 -125
- package/dist/chunks/canvas-preview-kp75gb63.js +141 -0
- package/dist/chunks/{canvas-preview-ewgspmsd.js → canvas-preview-n2a35zv5.js} +1 -1
- package/dist/chunks/{canvas-preview-h8hshnff.js → canvas-preview-pkzqg96v.js} +1 -1
- package/dist/chunks/{canvas-preview-evh92ywd.js → canvas-preview-tnqe7k0b.js} +15 -10
- package/dist/chunks/{canvas-preview-ehf3vp31.js → canvas-preview-vm9zdp2t.js} +45 -58
- package/dist/chunks/{canvas-preview-76fwvd6e.js → canvas-preview-w54ymtb3.js} +1 -1
- package/dist/chunks/canvas-preview-y5y6ghaw.js +125 -0
- package/dist/chunks/{canvas-preview-98aj5p34.js → canvas-preview-ycepb05s.js} +1 -1
- package/dist/src/canvas-preview.js +7 -3
- package/dist/src/deploy/cloudflare/worker.js +1 -1
- package/dist/src/deploy/vercel/server/routes/[...].js +2 -2
- package/dist/src/deploy/vercel/server/utils/host.js +1 -1
- package/dist/src/deploy/vercel/server/workflows/drive.js +3 -3
- package/dist/src/images/exif.d.ts +1 -0
- package/dist/src/images/exif.js +6 -0
- package/dist/src/images/node.d.ts +37 -0
- package/dist/src/images/node.js +12 -0
- package/dist/src/images/photon.d.ts +3 -0
- package/dist/src/images/photon.js +7 -0
- package/dist/src/images/worker.d.ts +1 -0
- package/dist/src/images/worker.js +23 -0
- package/dist/src/index.d.ts +0 -1
- package/dist/src/index.js +23 -22
- package/dist/src/listen-fetch.js +3 -3
- package/dist/src/listen.js +4 -4
- package/dist/src/local/command.d.ts +5 -0
- package/dist/src/local/command.js +6 -2
- package/dist/src/local/files.js +2 -2
- package/dist/src/local/git-watch.d.ts +1 -1
- package/dist/src/local/git-watch.js +1 -1
- package/dist/src/local/git.js +3 -3
- package/dist/src/local/github.js +2 -2
- package/dist/src/local/jobs.d.ts +1 -1
- package/dist/src/local/jobs.js +2 -1
- package/dist/src/local/provider.js +9 -9
- package/dist/src/local/snapshots.js +2 -2
- package/dist/src/local/worktrees.js +2 -2
- package/dist/src/mcp/connect.d.ts +0 -13
- package/dist/src/mcp/connect.js +1 -1
- package/dist/src/mcp/hub.js +2 -2
- package/dist/src/open.d.ts +2 -30
- package/dist/src/open.js +20 -18
- package/dist/src/serve/binding.js +1 -1
- package/dist/src/serve/handler.js +2 -2
- package/dist/src/sqlite.d.ts +2 -1
- package/dist/src/sqlite.js +4 -3
- package/dist/src/store-usage.js +4 -1
- package/dist/src/usage-history.d.ts +54 -0
- package/dist/src/usage-history.js +14 -0
- package/dist/src/usage-service.d.ts +2 -1
- package/dist/src/usage-service.js +7 -3
- package/dist/src/usage-worker.js +8 -4
- package/dist/src/usage.d.ts +150 -27
- package/dist/src/usage.js +12 -2
- package/package.json +16 -8
- package/src/canvas-preview.ts +4 -3
- package/src/images/exif.ts +69 -0
- package/src/images/node.ts +163 -0
- package/src/images/photon.ts +141 -0
- package/src/images/worker.ts +29 -0
- package/src/index.ts +19 -1
- package/src/local/command.ts +64 -12
- package/src/local/files.ts +8 -3
- package/src/local/git-watch.ts +16 -6
- package/src/local/git.ts +7 -2
- package/src/local/github.ts +3 -4
- package/src/local/jobs.ts +55 -69
- package/src/local/snapshots.ts +4 -2
- package/src/local/worktrees.ts +4 -4
- package/src/mcp/connect.ts +1 -14
- package/src/open.ts +16 -36
- package/src/serve/binding.ts +4 -3
- package/src/sqlite.ts +33 -8
- package/src/store-usage.ts +5 -2
- package/src/usage-history.ts +219 -0
- package/src/usage-service.ts +12 -3
- package/src/usage.ts +251 -176
- package/dist/chunks/canvas-preview-a3yw0j70.js +0 -72
- package/dist/chunks/canvas-preview-azg56359.js +0 -68
- package/dist/src/store-owner.d.ts +0 -15
- package/dist/src/store-owner.js +0 -9
- package/src/store-owner.ts +0 -98
package/src/usage.ts
CHANGED
|
@@ -1,46 +1,82 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { lstat, open, opendir, stat } from "node:fs/promises";
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { lstat, opendir, stat } from "node:fs/promises";
|
|
4
3
|
import { homedir } from "node:os";
|
|
5
4
|
import { join, resolve } from "node:path";
|
|
6
5
|
import process from "node:process";
|
|
7
|
-
import { StringDecoder } from "node:string_decoder";
|
|
8
6
|
import { Type, type Static } from "typebox";
|
|
7
|
+
import { Compile } from "typebox/compile";
|
|
9
8
|
import { Value } from "typebox/value";
|
|
10
9
|
import { calculateCost, type Models } from "@nyte-ai/ai";
|
|
10
|
+
import { localDay } from "./store-usage.ts";
|
|
11
|
+
import {
|
|
12
|
+
fileScanSchema,
|
|
13
|
+
readCacheDocument,
|
|
14
|
+
readHistory,
|
|
15
|
+
UsageScanCache,
|
|
16
|
+
writeCacheDocument,
|
|
17
|
+
type HistoryLine,
|
|
18
|
+
type HistoryParser,
|
|
19
|
+
} from "./usage-history.ts";
|
|
11
20
|
import { summarizeModels, emptyModelUsage, recordedUsage } from "./usage-summary.ts";
|
|
12
|
-
import type { LocalHistoryUsage, ModelUsage } from "./usage-types.ts";
|
|
21
|
+
import type { LocalHistoryUsage, ModelUsage, UsageWindow } from "./usage-types.ts";
|
|
13
22
|
import type { Usage } from "@nyte-ai/schema/model";
|
|
14
23
|
|
|
15
24
|
export interface ClaudeCodeUsageOptions {
|
|
16
25
|
readonly models: Pick<Models, "getModels">;
|
|
26
|
+
readonly window: UsageWindow;
|
|
17
27
|
readonly configDir?: string;
|
|
18
28
|
readonly signal?: AbortSignal;
|
|
19
29
|
readonly cache?: UsageScanCache<ClaudeCodeFileScan>;
|
|
20
30
|
}
|
|
21
31
|
|
|
22
|
-
export type
|
|
32
|
+
export type ClaudeCodeUsage = LocalHistoryUsage;
|
|
23
33
|
|
|
24
|
-
|
|
25
|
-
readonly size: number;
|
|
26
|
-
readonly mtimeMs: number;
|
|
27
|
-
readonly scan: T;
|
|
28
|
-
}
|
|
34
|
+
const tokens = Type.Integer({ minimum: 0, maximum: Number.MAX_SAFE_INTEGER });
|
|
29
35
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
36
|
+
const count = Type.Number({ minimum: 0 });
|
|
37
|
+
|
|
38
|
+
const modelUsage = Type.Object({
|
|
39
|
+
input: count,
|
|
40
|
+
output: count,
|
|
41
|
+
cacheRead: count,
|
|
42
|
+
cacheWrite: count,
|
|
43
|
+
cacheWrite1h: Type.Optional(count),
|
|
44
|
+
reasoning: Type.Optional(count),
|
|
45
|
+
totalTokens: count,
|
|
46
|
+
cost: Type.Object({
|
|
47
|
+
input: Type.Number(),
|
|
48
|
+
output: Type.Number(),
|
|
49
|
+
cacheRead: Type.Number(),
|
|
50
|
+
cacheWrite: Type.Number(),
|
|
51
|
+
total: Type.Number(),
|
|
52
|
+
}),
|
|
53
|
+
});
|
|
35
54
|
|
|
36
|
-
|
|
55
|
+
/** When a record was written, in epoch ms; `null` when its line carries no readable timestamp. */
|
|
56
|
+
const recordedAt = Type.Union([Type.Number(), Type.Null()]);
|
|
37
57
|
|
|
38
|
-
const
|
|
58
|
+
const snapshot = Type.Object({
|
|
59
|
+
model: Type.String(),
|
|
60
|
+
usage: modelUsage,
|
|
61
|
+
costUSD: Type.Optional(Type.Number()),
|
|
62
|
+
at: recordedAt,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
export type Snapshot = Static<typeof snapshot>;
|
|
66
|
+
|
|
67
|
+
const claudeCodeFileScan = Type.Object({
|
|
68
|
+
keyed: Type.Array(Type.Tuple([Type.String(), snapshot])),
|
|
69
|
+
anonymous: Type.Array(snapshot),
|
|
70
|
+
malformedRecords: Type.Integer({ minimum: 0 }),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
export type ClaudeCodeFileScan = Static<typeof claudeCodeFileScan>;
|
|
39
74
|
|
|
40
75
|
const envelope = Type.Object({ type: Type.String() });
|
|
41
76
|
|
|
42
77
|
const assistantRecord = Type.Object({
|
|
43
78
|
type: Type.Literal("assistant"),
|
|
79
|
+
timestamp: Type.Optional(Type.String()),
|
|
44
80
|
requestId: Type.Optional(Type.String()),
|
|
45
81
|
costUSD: Type.Optional(Type.Unknown()),
|
|
46
82
|
message: Type.Object({
|
|
@@ -63,12 +99,6 @@ const assistantRecord = Type.Object({
|
|
|
63
99
|
|
|
64
100
|
const reportedCost = Type.Number({ minimum: 0, maximum: Number.MAX_VALUE });
|
|
65
101
|
|
|
66
|
-
export interface Snapshot {
|
|
67
|
-
readonly model: string;
|
|
68
|
-
readonly usage: Usage;
|
|
69
|
-
readonly costUSD: number | undefined;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
102
|
function betterSnapshot(previous: Snapshot | undefined, next: Snapshot): boolean {
|
|
73
103
|
if (previous === undefined) return true;
|
|
74
104
|
const usage = next.usage;
|
|
@@ -83,38 +113,51 @@ function betterSnapshot(previous: Snapshot | undefined, next: Snapshot): boolean
|
|
|
83
113
|
);
|
|
84
114
|
}
|
|
85
115
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
signal: AbortSignal | undefined,
|
|
90
|
-
): Promise<ClaudeCodeFileScan> {
|
|
91
|
-
const keyed = new Map<string, Snapshot>();
|
|
92
|
-
const anonymous: Snapshot[] = [];
|
|
93
|
-
let malformedRecords = 0;
|
|
116
|
+
function parseRecordedAt(value: string | undefined): number | null {
|
|
117
|
+
if (value === undefined) return null;
|
|
118
|
+
const parsed = Date.parse(value);
|
|
94
119
|
|
|
95
|
-
|
|
96
|
-
|
|
120
|
+
return Number.isNaN(parsed) ? null : parsed;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Whether a record falls in `window`, by the host's local day. An undated record counts only toward all time. */
|
|
124
|
+
function recordedIn(window: UsageWindow, at: number | null): boolean {
|
|
125
|
+
if (at === null) return window.sinceDay === null;
|
|
126
|
+
const day = localDay(at);
|
|
97
127
|
|
|
98
|
-
|
|
128
|
+
return day <= window.untilDay && (window.sinceDay === null || day >= window.sinceDay);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Folds Claude Code transcript lines into usage snapshots. An unparsable last line without its newline is not counted malformed: a writer may still be appending it. */
|
|
132
|
+
const claudeCodeParser: HistoryParser<ClaudeCodeFileScan> = (from) => {
|
|
133
|
+
const keyed = new Map(from?.keyed);
|
|
134
|
+
const anonymous = [...(from?.anonymous ?? [])];
|
|
135
|
+
let malformedRecords = from?.malformedRecords ?? 0;
|
|
136
|
+
|
|
137
|
+
const line = ({ text, terminated }: HistoryLine): void => {
|
|
138
|
+
if (!text.trim()) return;
|
|
99
139
|
let value: unknown;
|
|
100
140
|
|
|
101
141
|
try {
|
|
102
|
-
value = JSON.parse(
|
|
142
|
+
value = JSON.parse(text);
|
|
103
143
|
} catch {
|
|
104
|
-
if (
|
|
105
|
-
|
|
144
|
+
if (terminated) malformedRecords++;
|
|
145
|
+
|
|
146
|
+
return;
|
|
106
147
|
}
|
|
107
148
|
|
|
108
149
|
if (!Value.Check(envelope, value)) {
|
|
109
150
|
malformedRecords++;
|
|
110
|
-
|
|
151
|
+
|
|
152
|
+
return;
|
|
111
153
|
}
|
|
112
154
|
|
|
113
|
-
if (value.type !== "assistant")
|
|
155
|
+
if (value.type !== "assistant") return;
|
|
114
156
|
|
|
115
157
|
if (!Value.Check(assistantRecord, value)) {
|
|
116
158
|
malformedRecords++;
|
|
117
|
-
|
|
159
|
+
|
|
160
|
+
return;
|
|
118
161
|
}
|
|
119
162
|
|
|
120
163
|
const raw = value.message.usage;
|
|
@@ -123,7 +166,8 @@ async function scanClaudeCodeFile(
|
|
|
123
166
|
|
|
124
167
|
if ((cacheWrite1h ?? 0) + (raw.cache_creation?.ephemeral_5m_input_tokens ?? 0) > cacheWrite) {
|
|
125
168
|
malformedRecords++;
|
|
126
|
-
|
|
169
|
+
|
|
170
|
+
return;
|
|
127
171
|
}
|
|
128
172
|
|
|
129
173
|
const usage: Usage = {
|
|
@@ -141,50 +185,53 @@ async function scanClaudeCodeFile(
|
|
|
141
185
|
const snapshot: Snapshot = {
|
|
142
186
|
model: value.message.model,
|
|
143
187
|
usage,
|
|
144
|
-
|
|
188
|
+
at: parseRecordedAt(value.timestamp),
|
|
145
189
|
};
|
|
146
190
|
|
|
191
|
+
if (Value.Check(reportedCost, value.costUSD)) snapshot.costUSD = value.costUSD;
|
|
192
|
+
|
|
147
193
|
const messageId = value.message.id?.trim() || null;
|
|
148
194
|
const requestId = value.requestId?.trim() || null;
|
|
149
195
|
|
|
150
196
|
if (messageId === null && requestId === null) {
|
|
151
197
|
anonymous.push(snapshot);
|
|
152
|
-
|
|
198
|
+
|
|
199
|
+
return;
|
|
153
200
|
}
|
|
154
201
|
|
|
155
202
|
const identity = JSON.stringify([messageId, requestId]);
|
|
156
203
|
|
|
157
204
|
if (betterSnapshot(keyed.get(identity), snapshot)) keyed.set(identity, snapshot);
|
|
158
|
-
}
|
|
205
|
+
};
|
|
159
206
|
|
|
160
|
-
return { keyed: [...keyed], anonymous, malformedRecords };
|
|
161
|
-
}
|
|
207
|
+
return { line, scan: () => ({ keyed: [...keyed], anonymous: [...anonymous], malformedRecords }) };
|
|
208
|
+
};
|
|
162
209
|
|
|
163
|
-
|
|
210
|
+
/**
|
|
211
|
+
* Reads one history file unless it was last written before `window` opens: no record is dated
|
|
212
|
+
* after its file's last write. The path counts as seen either way, so the cache keeps a file the
|
|
213
|
+
* window passes over.
|
|
214
|
+
*/
|
|
215
|
+
async function readHistoryInWindow<Scan>(
|
|
164
216
|
path: string,
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
217
|
+
window: UsageWindow,
|
|
218
|
+
read: {
|
|
219
|
+
readonly parser: HistoryParser<Scan>;
|
|
220
|
+
readonly cache: UsageScanCache<Scan> | undefined;
|
|
221
|
+
readonly seen: Set<string>;
|
|
222
|
+
readonly signal: AbortSignal | undefined;
|
|
223
|
+
},
|
|
224
|
+
): Promise<Scan | undefined> {
|
|
225
|
+
read.seen.add(path);
|
|
172
226
|
const info = await lstat(path);
|
|
173
227
|
|
|
174
228
|
if (!info.isFile()) throw new Error("History is not a regular file.");
|
|
175
|
-
const hit = cache.get(path);
|
|
176
|
-
|
|
177
|
-
if (hit !== undefined && hit.size === info.size && hit.mtimeMs === info.mtimeMs) return hit.scan;
|
|
178
|
-
const scan = await parse();
|
|
179
|
-
cache.set(path, { size: info.size, mtimeMs: info.mtimeMs, scan });
|
|
180
229
|
|
|
181
|
-
|
|
182
|
-
}
|
|
230
|
+
const opens = window.sinceDay === null ? 0 : new Date(`${window.sinceDay}T00:00:00`).getTime();
|
|
183
231
|
|
|
184
|
-
|
|
185
|
-
if (cache === undefined) return;
|
|
232
|
+
if (info.mtimeMs < opens) return undefined;
|
|
186
233
|
|
|
187
|
-
|
|
234
|
+
return readHistory(path, info, read.parser, read.cache, read.signal);
|
|
188
235
|
}
|
|
189
236
|
|
|
190
237
|
/**
|
|
@@ -248,10 +295,14 @@ export async function readClaudeCodeUsage(
|
|
|
248
295
|
if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue;
|
|
249
296
|
|
|
250
297
|
try {
|
|
251
|
-
const scan = await
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
298
|
+
const scan = await readHistoryInWindow(path, options.window, {
|
|
299
|
+
parser: claudeCodeParser,
|
|
300
|
+
cache: options.cache,
|
|
301
|
+
seen,
|
|
302
|
+
signal: options.signal,
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
if (scan === undefined) continue;
|
|
255
306
|
malformedRecords += scan.malformedRecords;
|
|
256
307
|
anonymous.push(...scan.anonymous);
|
|
257
308
|
|
|
@@ -272,7 +323,7 @@ export async function readClaudeCodeUsage(
|
|
|
272
323
|
}
|
|
273
324
|
|
|
274
325
|
options.signal?.throwIfAborted();
|
|
275
|
-
|
|
326
|
+
options.cache?.keepOnly(seen);
|
|
276
327
|
|
|
277
328
|
const catalog = options.models
|
|
278
329
|
.getModels("anthropic")
|
|
@@ -283,6 +334,8 @@ export async function readClaudeCodeUsage(
|
|
|
283
334
|
|
|
284
335
|
for (const snapshot of [...keyed.values(), ...anonymous]) {
|
|
285
336
|
options.signal?.throwIfAborted();
|
|
337
|
+
|
|
338
|
+
if (!recordedIn(options.window, snapshot.at)) continue;
|
|
286
339
|
const usage: Usage = { ...snapshot.usage, cost: { ...snapshot.usage.cost } };
|
|
287
340
|
|
|
288
341
|
if (snapshot.costUSD !== undefined) {
|
|
@@ -323,21 +376,32 @@ export type CodexUsage = LocalHistoryUsage;
|
|
|
323
376
|
|
|
324
377
|
export interface CodexUsageOptions {
|
|
325
378
|
readonly models: Pick<Models, "getModels">;
|
|
379
|
+
readonly window: UsageWindow;
|
|
326
380
|
readonly homeDir?: string;
|
|
327
381
|
readonly signal?: AbortSignal;
|
|
328
382
|
readonly cache?: UsageScanCache<CodexFileScan>;
|
|
329
383
|
}
|
|
330
384
|
|
|
331
|
-
|
|
332
|
-
readonly modern: readonly (readonly [string, CodexRow])[];
|
|
333
|
-
readonly legacy: readonly CodexRow[];
|
|
334
|
-
readonly malformedRecords: number;
|
|
335
|
-
}
|
|
385
|
+
const codexRow = Type.Object({ model: Type.String(), usage: modelUsage, at: recordedAt });
|
|
336
386
|
|
|
337
|
-
export
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
387
|
+
export type CodexRow = Static<typeof codexRow>;
|
|
388
|
+
|
|
389
|
+
/** What a rollout's next line is read against: see {@link codexParser}. */
|
|
390
|
+
const codexCursor = Type.Object({
|
|
391
|
+
model: Type.String(),
|
|
392
|
+
sawMeta: Type.Boolean(),
|
|
393
|
+
signature: Type.Optional(Type.String()),
|
|
394
|
+
forkAnchorMs: Type.Optional(Type.Number()),
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
const codexFileScan = Type.Object({
|
|
398
|
+
modern: Type.Array(Type.Tuple([Type.String(), codexRow])),
|
|
399
|
+
legacy: Type.Array(codexRow),
|
|
400
|
+
malformedRecords: Type.Integer({ minimum: 0 }),
|
|
401
|
+
cursor: codexCursor,
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
export type CodexFileScan = Static<typeof codexFileScan>;
|
|
341
405
|
|
|
342
406
|
const codexTokens = Type.Object({
|
|
343
407
|
input_tokens: Type.Optional(tokens),
|
|
@@ -349,6 +413,7 @@ const codexTokens = Type.Object({
|
|
|
349
413
|
|
|
350
414
|
const usageRecord = Type.Object({
|
|
351
415
|
type: Type.Literal("token_usage_record"),
|
|
416
|
+
timestamp: Type.Optional(Type.String()),
|
|
352
417
|
payload: Type.Object({
|
|
353
418
|
response_id: Type.Optional(Type.String()),
|
|
354
419
|
usage: codexTokens,
|
|
@@ -400,13 +465,6 @@ function codexUsage(raw: CodexTokens): Usage {
|
|
|
400
465
|
|
|
401
466
|
const FORK_COPY_MAX_GAP_MS = 1000;
|
|
402
467
|
|
|
403
|
-
function parseMs(value: string | undefined): number | undefined {
|
|
404
|
-
if (value === undefined) return undefined;
|
|
405
|
-
const parsed = Date.parse(value);
|
|
406
|
-
|
|
407
|
-
return Number.isNaN(parsed) ? undefined : parsed;
|
|
408
|
-
}
|
|
409
|
-
|
|
410
468
|
/**
|
|
411
469
|
* Reads Codex's local usage from `sessions` and `archived_sessions` (where the legacy format
|
|
412
470
|
* lives). A `response_id` is unique across files, so one map dedups a forked rollout's repeated
|
|
@@ -448,10 +506,14 @@ export async function readCodexUsage(options: CodexUsageOptions): Promise<CodexU
|
|
|
448
506
|
for (const root of present) {
|
|
449
507
|
for await (const path of jsonlFiles(root, options.signal)) {
|
|
450
508
|
try {
|
|
451
|
-
const scan = await
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
509
|
+
const scan = await readHistoryInWindow(path, options.window, {
|
|
510
|
+
parser: codexParser,
|
|
511
|
+
cache: options.cache,
|
|
512
|
+
seen,
|
|
513
|
+
signal: options.signal,
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
if (scan === undefined) continue;
|
|
455
517
|
malformedRecords += scan.malformedRecords;
|
|
456
518
|
|
|
457
519
|
if (scan.modern.length > 0) {
|
|
@@ -467,7 +529,7 @@ export async function readCodexUsage(options: CodexUsageOptions): Promise<CodexU
|
|
|
467
529
|
}
|
|
468
530
|
|
|
469
531
|
options.signal?.throwIfAborted();
|
|
470
|
-
|
|
532
|
+
options.cache?.keepOnly(seen);
|
|
471
533
|
|
|
472
534
|
const catalog = options.models
|
|
473
535
|
.getModels("openai-codex")
|
|
@@ -478,6 +540,8 @@ export async function readCodexUsage(options: CodexUsageOptions): Promise<CodexU
|
|
|
478
540
|
|
|
479
541
|
for (const row of [...responses.values(), ...legacy]) {
|
|
480
542
|
options.signal?.throwIfAborted();
|
|
543
|
+
|
|
544
|
+
if (!recordedIn(options.window, row.at)) continue;
|
|
481
545
|
const usage: Usage = { ...row.usage, cost: { ...row.usage.cost } };
|
|
482
546
|
const matches = catalog.filter((model) => model.id === row.model);
|
|
483
547
|
const model = matches.length === 1 ? matches[0] : undefined;
|
|
@@ -507,94 +571,103 @@ export async function readCodexUsage(options: CodexUsageOptions): Promise<CodexU
|
|
|
507
571
|
}
|
|
508
572
|
|
|
509
573
|
/**
|
|
510
|
-
*
|
|
574
|
+
* Folds one Codex rollout. A `token_count` names no model, so the last `turn_context` does; only
|
|
511
575
|
* the file's first session meta counts, since a fork repeats its ancestors' metas straight after
|
|
512
576
|
* and would move the fork anchor; and a `token_count` equal to the one before is a re-emission
|
|
513
|
-
* at a stream boundary, not new usage.
|
|
577
|
+
* at a stream boundary, not new usage. That reading state is the scan's cursor, so a grown
|
|
578
|
+
* rollout resumes against it.
|
|
514
579
|
*/
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
const
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
let sawMeta = false;
|
|
524
|
-
let signature: string | undefined;
|
|
525
|
-
let forkAnchorMs: number | undefined;
|
|
526
|
-
|
|
527
|
-
for await (const line of historyLines(path, signal)) {
|
|
528
|
-
signal?.throwIfAborted();
|
|
529
|
-
|
|
530
|
-
if (!line.text.trim()) continue;
|
|
580
|
+
const codexParser: HistoryParser<CodexFileScan> = (from) => {
|
|
581
|
+
const modern = new Map(from?.modern);
|
|
582
|
+
const legacy = [...(from?.legacy ?? [])];
|
|
583
|
+
let malformedRecords = from?.malformedRecords ?? 0;
|
|
584
|
+
const cursor: CodexFileScan["cursor"] = { ...(from?.cursor ?? { model: "", sawMeta: false }) };
|
|
585
|
+
|
|
586
|
+
const line = ({ text, terminated }: HistoryLine): void => {
|
|
587
|
+
if (!text.trim()) return;
|
|
531
588
|
let value: unknown;
|
|
532
589
|
|
|
533
590
|
try {
|
|
534
|
-
value = JSON.parse(
|
|
591
|
+
value = JSON.parse(text);
|
|
535
592
|
} catch {
|
|
536
|
-
if (
|
|
537
|
-
|
|
593
|
+
if (terminated) malformedRecords++;
|
|
594
|
+
|
|
595
|
+
return;
|
|
538
596
|
}
|
|
539
597
|
|
|
540
598
|
if (!Value.Check(envelope, value)) {
|
|
541
599
|
malformedRecords++;
|
|
542
|
-
|
|
600
|
+
|
|
601
|
+
return;
|
|
543
602
|
}
|
|
544
603
|
|
|
545
604
|
if (Value.Check(turnContext, value)) {
|
|
546
|
-
if (value.payload.model !== undefined) model = value.payload.model;
|
|
547
|
-
|
|
605
|
+
if (value.payload.model !== undefined) cursor.model = value.payload.model;
|
|
606
|
+
|
|
607
|
+
return;
|
|
548
608
|
}
|
|
549
609
|
|
|
550
610
|
if (Value.Check(sessionMeta, value)) {
|
|
551
|
-
if (sawMeta)
|
|
552
|
-
sawMeta = true;
|
|
611
|
+
if (cursor.sawMeta) return;
|
|
612
|
+
cursor.sawMeta = true;
|
|
553
613
|
|
|
554
614
|
const forked =
|
|
555
615
|
value.payload.forked_from_id !== undefined || value.payload.parent_thread_id !== undefined;
|
|
556
616
|
|
|
557
|
-
|
|
558
|
-
|
|
617
|
+
const anchor = parseRecordedAt(value.timestamp);
|
|
618
|
+
|
|
619
|
+
if (forked && anchor !== null) cursor.forkAnchorMs = anchor;
|
|
620
|
+
|
|
621
|
+
return;
|
|
559
622
|
}
|
|
560
623
|
|
|
561
624
|
if (Value.Check(usageRecord, value)) {
|
|
562
625
|
const id = value.payload.response_id;
|
|
563
626
|
|
|
564
|
-
if (id === undefined || model === "")
|
|
627
|
+
if (id === undefined || cursor.model === "") return;
|
|
565
628
|
const usage = codexUsage(value.payload.usage);
|
|
566
629
|
|
|
567
|
-
if (usage.totalTokens > 0 && !modern.has(id))
|
|
568
|
-
|
|
630
|
+
if (usage.totalTokens > 0 && !modern.has(id))
|
|
631
|
+
modern.set(id, { model: cursor.model, usage, at: parseRecordedAt(value.timestamp) });
|
|
632
|
+
|
|
633
|
+
return;
|
|
569
634
|
}
|
|
570
635
|
|
|
571
|
-
if (!Value.Check(tokenCount, value))
|
|
636
|
+
if (!Value.Check(tokenCount, value)) return;
|
|
572
637
|
const last = value.payload.info.last_token_usage;
|
|
573
638
|
|
|
574
|
-
if (last === undefined || model === "")
|
|
639
|
+
if (last === undefined || cursor.model === "") return;
|
|
575
640
|
const next = JSON.stringify(last);
|
|
576
641
|
|
|
577
|
-
if (next === signature)
|
|
578
|
-
signature = next;
|
|
642
|
+
if (next === cursor.signature) return;
|
|
643
|
+
cursor.signature = next;
|
|
644
|
+
const at = parseRecordedAt(value.timestamp);
|
|
579
645
|
|
|
580
|
-
if (forkAnchorMs !== undefined) {
|
|
581
|
-
|
|
646
|
+
if (cursor.forkAnchorMs !== undefined) {
|
|
647
|
+
if (at !== null && at - cursor.forkAnchorMs < FORK_COPY_MAX_GAP_MS) {
|
|
648
|
+
cursor.forkAnchorMs = at;
|
|
582
649
|
|
|
583
|
-
|
|
584
|
-
forkAnchorMs = at;
|
|
585
|
-
continue;
|
|
650
|
+
return;
|
|
586
651
|
}
|
|
587
652
|
|
|
588
|
-
forkAnchorMs
|
|
653
|
+
delete cursor.forkAnchorMs;
|
|
589
654
|
}
|
|
590
655
|
|
|
591
656
|
const usage = codexUsage(last);
|
|
592
657
|
|
|
593
|
-
if (usage.totalTokens > 0) legacy.push({ model, usage });
|
|
594
|
-
}
|
|
658
|
+
if (usage.totalTokens > 0) legacy.push({ model: cursor.model, usage, at });
|
|
659
|
+
};
|
|
595
660
|
|
|
596
|
-
return {
|
|
597
|
-
|
|
661
|
+
return {
|
|
662
|
+
line,
|
|
663
|
+
scan: () => ({
|
|
664
|
+
modern: [...modern],
|
|
665
|
+
legacy: [...legacy],
|
|
666
|
+
malformedRecords,
|
|
667
|
+
cursor: { ...cursor },
|
|
668
|
+
}),
|
|
669
|
+
};
|
|
670
|
+
};
|
|
598
671
|
|
|
599
672
|
async function* jsonlFiles(root: string, signal: AbortSignal | undefined): AsyncGenerator<string> {
|
|
600
673
|
const directories = [root];
|
|
@@ -618,52 +691,51 @@ async function* jsonlFiles(root: string, signal: AbortSignal | undefined): Async
|
|
|
618
691
|
}
|
|
619
692
|
}
|
|
620
693
|
|
|
621
|
-
/** Yields a history file's lines up to the size it had when opened, though its writer may keep appending; the last line is `terminated: false` without its newline. */
|
|
622
|
-
async function* historyLines(path: string, signal: AbortSignal | undefined) {
|
|
623
|
-
const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
624
|
-
|
|
625
|
-
try {
|
|
626
|
-
const stat = await file.stat();
|
|
627
|
-
|
|
628
|
-
if (!stat.isFile()) throw new Error("History is not a regular file.");
|
|
629
|
-
const buffer = Buffer.alloc(64 * 1024);
|
|
630
|
-
const decoder = new StringDecoder("utf8");
|
|
631
|
-
let pending = "";
|
|
632
|
-
let position = 0;
|
|
633
|
-
|
|
634
|
-
while (position < stat.size) {
|
|
635
|
-
signal?.throwIfAborted();
|
|
636
|
-
|
|
637
|
-
const { bytesRead } = await file.read(
|
|
638
|
-
buffer,
|
|
639
|
-
0,
|
|
640
|
-
Math.min(buffer.length, stat.size - position),
|
|
641
|
-
position,
|
|
642
|
-
);
|
|
643
|
-
|
|
644
|
-
if (bytesRead === 0) break;
|
|
645
|
-
position += bytesRead;
|
|
646
|
-
const lines = `${pending}${decoder.write(buffer.subarray(0, bytesRead))}`.split("\n");
|
|
647
|
-
pending = lines.pop() ?? "";
|
|
648
|
-
|
|
649
|
-
for (const text of lines) yield { text, terminated: true };
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
pending += decoder.end();
|
|
653
|
-
|
|
654
|
-
if (pending) yield { text: pending, terminated: false };
|
|
655
|
-
} finally {
|
|
656
|
-
await file.close();
|
|
657
|
-
}
|
|
658
|
-
}
|
|
659
|
-
|
|
660
694
|
export interface UsageScanCaches {
|
|
661
695
|
readonly claudeCode: UsageScanCache<ClaudeCodeFileScan>;
|
|
662
696
|
readonly codex: UsageScanCache<CodexFileScan>;
|
|
663
697
|
}
|
|
664
698
|
|
|
665
699
|
export function createUsageScanCaches(): UsageScanCaches {
|
|
666
|
-
return { claudeCode: new
|
|
700
|
+
return { claudeCode: new UsageScanCache(), codex: new UsageScanCache() };
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/** Bump when a parser changes what it keeps, so an older document is dropped instead of misread. */
|
|
704
|
+
const USAGE_SCAN_CACHE_VERSION = 1;
|
|
705
|
+
|
|
706
|
+
const usageScanCacheDocument = Type.Object({
|
|
707
|
+
version: Type.Literal(USAGE_SCAN_CACHE_VERSION),
|
|
708
|
+
claudeCode: Type.Array(Type.Tuple([Type.String(), fileScanSchema(claudeCodeFileScan)])),
|
|
709
|
+
codex: Type.Array(Type.Tuple([Type.String(), fileScanSchema(codexFileScan)])),
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
const usageScanCacheValidator = Compile(usageScanCacheDocument);
|
|
713
|
+
|
|
714
|
+
/** Restores the scans {@link saveUsageScanCaches} wrote to `path`; each still answers only while its file is unchanged. */
|
|
715
|
+
export async function loadUsageScanCaches(path: string): Promise<UsageScanCaches> {
|
|
716
|
+
const document = await readCacheDocument(path, usageScanCacheValidator);
|
|
717
|
+
|
|
718
|
+
if (document === undefined) return createUsageScanCaches();
|
|
719
|
+
|
|
720
|
+
return {
|
|
721
|
+
claudeCode: new UsageScanCache(document.claudeCode),
|
|
722
|
+
codex: new UsageScanCache(document.codex),
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/** Writes `caches` to `path` when a read changed them since they were last saved; a failed write is tried again on the next save. */
|
|
727
|
+
export async function saveUsageScanCaches(path: string, caches: UsageScanCaches): Promise<void> {
|
|
728
|
+
if (!caches.claudeCode.changed && !caches.codex.changed) return;
|
|
729
|
+
|
|
730
|
+
const saved = await writeCacheDocument(path, {
|
|
731
|
+
version: USAGE_SCAN_CACHE_VERSION,
|
|
732
|
+
claudeCode: [...caches.claudeCode.entries()],
|
|
733
|
+
codex: [...caches.codex.entries()],
|
|
734
|
+
} satisfies Static<typeof usageScanCacheDocument>);
|
|
735
|
+
|
|
736
|
+
if (!saved) return;
|
|
737
|
+
caches.claudeCode.markSaved();
|
|
738
|
+
caches.codex.markSaved();
|
|
667
739
|
}
|
|
668
740
|
|
|
669
741
|
export interface LocalUsage {
|
|
@@ -673,6 +745,7 @@ export interface LocalUsage {
|
|
|
673
745
|
|
|
674
746
|
export async function readLocalUsage(options: {
|
|
675
747
|
readonly models: Pick<Models, "getModels">;
|
|
748
|
+
readonly window: UsageWindow;
|
|
676
749
|
readonly signal?: AbortSignal;
|
|
677
750
|
readonly caches?: UsageScanCaches;
|
|
678
751
|
}): Promise<LocalUsage> {
|
|
@@ -685,11 +758,13 @@ export async function readLocalUsage(options: {
|
|
|
685
758
|
const [claudeCode, codex] = await Promise.all([
|
|
686
759
|
readClaudeCodeUsage({
|
|
687
760
|
models: options.models,
|
|
761
|
+
window: options.window,
|
|
688
762
|
signal: options.signal,
|
|
689
763
|
cache: options.caches?.claudeCode,
|
|
690
764
|
}).catch(() => failed("Could not read Claude Code history.")),
|
|
691
765
|
readCodexUsage({
|
|
692
766
|
models: options.models,
|
|
767
|
+
window: options.window,
|
|
693
768
|
signal: options.signal,
|
|
694
769
|
cache: options.caches?.codex,
|
|
695
770
|
}).catch(() => failed("Could not read Codex history.")),
|