@juspay/neurolink 11.18.5 → 11.20.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/CHANGELOG.md +3 -4
- package/dist/browser/neurolink.min.js +362 -375
- package/dist/index.d.ts +1 -0
- package/dist/index.js +7 -0
- package/dist/localUsage/claudeCodeReader.d.ts +25 -0
- package/dist/localUsage/claudeCodeReader.js +222 -0
- package/dist/localUsage/codexReader.d.ts +30 -0
- package/dist/localUsage/codexReader.js +218 -0
- package/dist/localUsage/index.d.ts +18 -0
- package/dist/localUsage/index.js +46 -0
- package/dist/localUsage/localUsageReaderRegistry.d.ts +14 -0
- package/dist/localUsage/localUsageReaderRegistry.js +54 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.js +1 -0
- package/dist/types/localUsage.d.ts +153 -0
- package/dist/types/localUsage.js +13 -0
- package/dist/utils/messageBuilder.js +0 -76
- package/package.json +2 -1
package/dist/index.d.ts
CHANGED
|
@@ -445,3 +445,4 @@ export { TripwireEvaluator, createDefaultTripwireEvaluator, commonTripwires, } f
|
|
|
445
445
|
export { Agent } from "./agent/agent.js";
|
|
446
446
|
export { AgentNetwork } from "./agent/agentNetwork.js";
|
|
447
447
|
export { AgentCoordinator, TaskDistributor, MessageBus, NetworkOrchestrator, NetworkTopology, TopologyBuilder, } from "./agent/index.js";
|
|
448
|
+
export { createLocalUsageReader, getLocalUsageDescriptors, getRegisteredLocalUsageCliIds, readAllLocalUsage, registerLocalUsageReader, } from "./localUsage/index.js";
|
package/dist/index.js
CHANGED
|
@@ -745,3 +745,10 @@ AgentCoordinator, TaskDistributor,
|
|
|
745
745
|
MessageBus,
|
|
746
746
|
// Orchestration
|
|
747
747
|
NetworkOrchestrator, NetworkTopology, TopologyBuilder, } from "./agent/index.js";
|
|
748
|
+
// ============================================================================
|
|
749
|
+
// Local usage exports
|
|
750
|
+
// ============================================================================
|
|
751
|
+
// Token spend read from each CLI's own session logs, rather than from proxy
|
|
752
|
+
// traffic. Exported because the proxy's ledger only sees CLIs that route
|
|
753
|
+
// through it; this covers the rest, and covers history predating the proxy.
|
|
754
|
+
export { createLocalUsageReader, getLocalUsageDescriptors, getRegisteredLocalUsageCliIds, readAllLocalUsage, registerLocalUsageReader, } from "./localUsage/index.js";
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads token usage out of Claude Code's own session transcripts.
|
|
3
|
+
*
|
|
4
|
+
* Layout, confirmed on a real machine rather than from documentation:
|
|
5
|
+
* `~/.claude/projects/<sanitized-cwd>/<sessionId>.jsonl`, where the sanitized
|
|
6
|
+
* cwd is the absolute working-directory path with every `/` replaced by `-`.
|
|
7
|
+
*
|
|
8
|
+
* The load-bearing detail is that top-level session transcripts are a tiny
|
|
9
|
+
* minority of the files. On the machine this was written against, 17,439
|
|
10
|
+
* transcripts totalling 9.7 GB broke down as roughly 103 top-level sessions
|
|
11
|
+
* against 17,116 subagent-task transcripts nested under
|
|
12
|
+
* `<sessionId>/subagents/`. A reader that globbed only `<project>/*.jsonl`
|
|
13
|
+
* would miss essentially all of a subagent-heavy user's real spend, so this
|
|
14
|
+
* one recurses.
|
|
15
|
+
*
|
|
16
|
+
* The files are not homogeneous message streams. A single 14,747-line
|
|
17
|
+
* transcript carried eleven distinct `type` values — `assistant`, `user`,
|
|
18
|
+
* `attachment`, `pr-link`, `last-prompt`, `mode`, `permission-mode`,
|
|
19
|
+
* `queue-operation`, `system`, `file-history-delta`, `file-history-snapshot` —
|
|
20
|
+
* several of which have no `message` key at all. Every one of its 6,028
|
|
21
|
+
* `type: "assistant"` lines carried a `message.usage` object, so that filter is
|
|
22
|
+
* safe, but everything else must be skipped rather than treated as malformed.
|
|
23
|
+
*/
|
|
24
|
+
import type { LocalUsageReader } from "../types/index.js";
|
|
25
|
+
export declare function createClaudeCodeReader(): Promise<LocalUsageReader>;
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads token usage out of Claude Code's own session transcripts.
|
|
3
|
+
*
|
|
4
|
+
* Layout, confirmed on a real machine rather than from documentation:
|
|
5
|
+
* `~/.claude/projects/<sanitized-cwd>/<sessionId>.jsonl`, where the sanitized
|
|
6
|
+
* cwd is the absolute working-directory path with every `/` replaced by `-`.
|
|
7
|
+
*
|
|
8
|
+
* The load-bearing detail is that top-level session transcripts are a tiny
|
|
9
|
+
* minority of the files. On the machine this was written against, 17,439
|
|
10
|
+
* transcripts totalling 9.7 GB broke down as roughly 103 top-level sessions
|
|
11
|
+
* against 17,116 subagent-task transcripts nested under
|
|
12
|
+
* `<sessionId>/subagents/`. A reader that globbed only `<project>/*.jsonl`
|
|
13
|
+
* would miss essentially all of a subagent-heavy user's real spend, so this
|
|
14
|
+
* one recurses.
|
|
15
|
+
*
|
|
16
|
+
* The files are not homogeneous message streams. A single 14,747-line
|
|
17
|
+
* transcript carried eleven distinct `type` values — `assistant`, `user`,
|
|
18
|
+
* `attachment`, `pr-link`, `last-prompt`, `mode`, `permission-mode`,
|
|
19
|
+
* `queue-operation`, `system`, `file-history-delta`, `file-history-snapshot` —
|
|
20
|
+
* several of which have no `message` key at all. Every one of its 6,028
|
|
21
|
+
* `type: "assistant"` lines carried a `message.usage` object, so that filter is
|
|
22
|
+
* safe, but everything else must be skipped rather than treated as malformed.
|
|
23
|
+
*/
|
|
24
|
+
import { createReadStream } from "fs";
|
|
25
|
+
import { createInterface } from "readline";
|
|
26
|
+
import { readdir, stat } from "fs/promises";
|
|
27
|
+
import { homedir } from "os";
|
|
28
|
+
import { join } from "path";
|
|
29
|
+
import { calculateCost, hasPricing } from "../utils/pricing.js";
|
|
30
|
+
const CLI_ID = "claude-code";
|
|
31
|
+
const DEFAULT_SINCE_DAYS = 30;
|
|
32
|
+
const PROVIDER = "anthropic";
|
|
33
|
+
function projectsRoot() {
|
|
34
|
+
return join(homedir(), ".claude", "projects");
|
|
35
|
+
}
|
|
36
|
+
function emptyTotals() {
|
|
37
|
+
return {
|
|
38
|
+
requests: 0,
|
|
39
|
+
inputTokens: 0,
|
|
40
|
+
outputTokens: 0,
|
|
41
|
+
cacheReadTokens: 0,
|
|
42
|
+
cacheCreationTokens: 0,
|
|
43
|
+
costUsd: 0,
|
|
44
|
+
costConfidence: "modeled",
|
|
45
|
+
unpricedRequests: 0,
|
|
46
|
+
unpricedModels: [],
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Every `.jsonl` under the projects root, at any depth.
|
|
51
|
+
*
|
|
52
|
+
* Depth matters here — see the module header. `withFileTypes` avoids a stat
|
|
53
|
+
* per entry, and a directory that cannot be read is skipped rather than
|
|
54
|
+
* aborting the walk: one unreadable project must not cost the other eleven
|
|
55
|
+
* thousand files.
|
|
56
|
+
*/
|
|
57
|
+
async function collectTranscripts(dir, out) {
|
|
58
|
+
let entries;
|
|
59
|
+
try {
|
|
60
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
for (const entry of entries) {
|
|
66
|
+
const full = join(dir, entry.name);
|
|
67
|
+
if (entry.isDirectory()) {
|
|
68
|
+
await collectTranscripts(full, out);
|
|
69
|
+
}
|
|
70
|
+
else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
|
|
71
|
+
out.push(full);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function num(value) {
|
|
76
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Fold one transcript into `totals`.
|
|
80
|
+
*
|
|
81
|
+
* Read line by line rather than into a string: these files reach tens of
|
|
82
|
+
* megabytes, and a full-history scan opens thousands of them.
|
|
83
|
+
*
|
|
84
|
+
* Dedup is by `message.id`, keeping the LARGEST output count seen for that id.
|
|
85
|
+
* A resumed session re-logs turns it has already written, and the re-logged
|
|
86
|
+
* copy can carry a higher output count than the first — taking the max rather
|
|
87
|
+
* than the first or last is what makes a resumed session total correct instead
|
|
88
|
+
* of either under- or double-counted. The map is per-file and discarded after,
|
|
89
|
+
* so it stays bounded no matter how many files a scan covers.
|
|
90
|
+
*/
|
|
91
|
+
async function foldTranscript(filePath, totals, unpriced) {
|
|
92
|
+
const seen = new Map();
|
|
93
|
+
const rl = createInterface({
|
|
94
|
+
input: createReadStream(filePath, { encoding: "utf8" }),
|
|
95
|
+
crlfDelay: Infinity,
|
|
96
|
+
});
|
|
97
|
+
try {
|
|
98
|
+
for await (const line of rl) {
|
|
99
|
+
if (!line || line.charCodeAt(0) !== 123 /* '{' */) {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
let parsed;
|
|
103
|
+
try {
|
|
104
|
+
parsed = JSON.parse(line);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// A transcript being appended to while we read it ends in a partial
|
|
108
|
+
// line. That is normal, not corruption — skip it.
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const record = parsed;
|
|
112
|
+
if (record.type !== "assistant" || !record.message?.usage) {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const usage = record.message.usage;
|
|
116
|
+
const id = record.message.id;
|
|
117
|
+
if (typeof id !== "string" || id.length === 0) {
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const candidate = {
|
|
121
|
+
model: record.message.model ?? "unknown",
|
|
122
|
+
input: num(usage.input_tokens),
|
|
123
|
+
output: num(usage.output_tokens),
|
|
124
|
+
read: num(usage.cache_read_input_tokens),
|
|
125
|
+
create: num(usage.cache_creation_input_tokens),
|
|
126
|
+
};
|
|
127
|
+
const existing = seen.get(id);
|
|
128
|
+
if (!existing || candidate.output > existing.output) {
|
|
129
|
+
seen.set(id, candidate);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
finally {
|
|
134
|
+
rl.close();
|
|
135
|
+
}
|
|
136
|
+
for (const turn of seen.values()) {
|
|
137
|
+
totals.requests += 1;
|
|
138
|
+
totals.inputTokens += turn.input;
|
|
139
|
+
totals.outputTokens += turn.output;
|
|
140
|
+
totals.cacheReadTokens += turn.read;
|
|
141
|
+
totals.cacheCreationTokens += turn.create;
|
|
142
|
+
if (hasPricing(PROVIDER, turn.model)) {
|
|
143
|
+
totals.costUsd += calculateCost(PROVIDER, turn.model, {
|
|
144
|
+
input: turn.input,
|
|
145
|
+
output: turn.output,
|
|
146
|
+
total: turn.input + turn.output,
|
|
147
|
+
cacheReadTokens: turn.read,
|
|
148
|
+
cacheCreationTokens: turn.create,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
totals.unpricedRequests += 1;
|
|
153
|
+
unpriced.add(turn.model);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
export async function createClaudeCodeReader() {
|
|
158
|
+
return {
|
|
159
|
+
descriptor: {
|
|
160
|
+
id: CLI_ID,
|
|
161
|
+
displayName: "Claude Code",
|
|
162
|
+
verified: true,
|
|
163
|
+
dedupStrategy: "message-id-keep-max",
|
|
164
|
+
costConfidence: "modeled",
|
|
165
|
+
requiresSqlite: false,
|
|
166
|
+
},
|
|
167
|
+
detect: async () => {
|
|
168
|
+
try {
|
|
169
|
+
const info = await stat(projectsRoot());
|
|
170
|
+
return info.isDirectory();
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
scan: async (options) => {
|
|
177
|
+
const totals = emptyTotals();
|
|
178
|
+
const errors = [];
|
|
179
|
+
const unpriced = new Set();
|
|
180
|
+
const files = [];
|
|
181
|
+
await collectTranscripts(projectsRoot(), files);
|
|
182
|
+
const sinceDays = options?.sinceDays ?? DEFAULT_SINCE_DAYS;
|
|
183
|
+
const cutoff = Number.isFinite(sinceDays) && sinceDays > 0
|
|
184
|
+
? Date.now() - sinceDays * 86_400_000
|
|
185
|
+
: undefined;
|
|
186
|
+
let filesScanned = 0;
|
|
187
|
+
for (const file of files) {
|
|
188
|
+
try {
|
|
189
|
+
if (cutoff !== undefined) {
|
|
190
|
+
const info = await stat(file);
|
|
191
|
+
if (info.mtimeMs < cutoff) {
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
await foldTranscript(file, totals, unpriced);
|
|
196
|
+
filesScanned += 1;
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
// One unreadable transcript is a reportable fact, not a reason to
|
|
200
|
+
// lose the totals from every other file in the scan.
|
|
201
|
+
errors.push({
|
|
202
|
+
cliId: CLI_ID,
|
|
203
|
+
filePath: file,
|
|
204
|
+
message: error instanceof Error ? error.message : String(error),
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
totals.unpricedModels = [...unpriced].sort();
|
|
209
|
+
// Confidence stays "modeled" even when some turns went unpriced, and the
|
|
210
|
+
// distinction matters. "heuristic" means the number itself was estimated
|
|
211
|
+
// — Kiro's byte-count figure, say. Here every dollar in `costUsd` came
|
|
212
|
+
// from a real rate table; what is missing is turns, not accuracy. That
|
|
213
|
+
// incompleteness is already reported precisely by `unpricedRequests` and
|
|
214
|
+
// `unpricedModels` (on this machine: Claude Code's internal
|
|
215
|
+
// "<synthetic>" model), so downgrading the whole row would overstate the
|
|
216
|
+
// doubt and make a genuinely estimated total indistinguishable from this
|
|
217
|
+
// one.
|
|
218
|
+
totals.costUsd = Math.round(totals.costUsd * 1_000_000) / 1_000_000;
|
|
219
|
+
return { cliId: CLI_ID, totals, filesScanned, errors };
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads token usage out of Codex's own rollout transcripts.
|
|
3
|
+
*
|
|
4
|
+
* Layout: `~/.codex/sessions/<yyyy>/<mm>/<dd>/rollout-<iso>-<sessionId>.jsonl`.
|
|
5
|
+
* Each line is one of `session_meta`, `turn_context`, `response_item` or
|
|
6
|
+
* `event_msg`; usage lives on `event_msg` records whose `payload.type` is
|
|
7
|
+
* `token_count`.
|
|
8
|
+
*
|
|
9
|
+
* The counter semantics are the whole story here, and they are the opposite of
|
|
10
|
+
* the Claude Code reader's. Every `token_count` event carries BOTH a
|
|
11
|
+
* cumulative `total_token_usage` for the session and a per-turn
|
|
12
|
+
* `last_token_usage`. Summing the per-turn values is the obvious move and it is
|
|
13
|
+
* wrong: measured across all 108 sessions on a real machine, summing yields
|
|
14
|
+
* 9,191,613,238 tokens against a true 5,653,217,442 — an overstatement of
|
|
15
|
+
* 62.6%, and 195% on the worst single session. The per-turn value repeats
|
|
16
|
+
* across events within a turn, so it double-counts.
|
|
17
|
+
*
|
|
18
|
+
* The cumulative counter is authoritative and was monotonic in all 108
|
|
19
|
+
* sessions — it never resets mid-file — so the last one in the file is the
|
|
20
|
+
* session's true total. `Math.max` is still used rather than "last seen",
|
|
21
|
+
* because a counter that is only monotonic in every case observed is not the
|
|
22
|
+
* same as one that is guaranteed to be, and the max costs nothing.
|
|
23
|
+
*
|
|
24
|
+
* Cost is deliberately not computed. Codex is a ChatGPT subscription — the
|
|
25
|
+
* rollouts carry `rate_limits.plan_type` — so a per-token dollar figure would
|
|
26
|
+
* be an invention, not a measurement. The tokens are real; the cost is
|
|
27
|
+
* `unavailable`.
|
|
28
|
+
*/
|
|
29
|
+
import type { LocalUsageReader } from "../types/index.js";
|
|
30
|
+
export declare function createCodexReader(): Promise<LocalUsageReader>;
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads token usage out of Codex's own rollout transcripts.
|
|
3
|
+
*
|
|
4
|
+
* Layout: `~/.codex/sessions/<yyyy>/<mm>/<dd>/rollout-<iso>-<sessionId>.jsonl`.
|
|
5
|
+
* Each line is one of `session_meta`, `turn_context`, `response_item` or
|
|
6
|
+
* `event_msg`; usage lives on `event_msg` records whose `payload.type` is
|
|
7
|
+
* `token_count`.
|
|
8
|
+
*
|
|
9
|
+
* The counter semantics are the whole story here, and they are the opposite of
|
|
10
|
+
* the Claude Code reader's. Every `token_count` event carries BOTH a
|
|
11
|
+
* cumulative `total_token_usage` for the session and a per-turn
|
|
12
|
+
* `last_token_usage`. Summing the per-turn values is the obvious move and it is
|
|
13
|
+
* wrong: measured across all 108 sessions on a real machine, summing yields
|
|
14
|
+
* 9,191,613,238 tokens against a true 5,653,217,442 — an overstatement of
|
|
15
|
+
* 62.6%, and 195% on the worst single session. The per-turn value repeats
|
|
16
|
+
* across events within a turn, so it double-counts.
|
|
17
|
+
*
|
|
18
|
+
* The cumulative counter is authoritative and was monotonic in all 108
|
|
19
|
+
* sessions — it never resets mid-file — so the last one in the file is the
|
|
20
|
+
* session's true total. `Math.max` is still used rather than "last seen",
|
|
21
|
+
* because a counter that is only monotonic in every case observed is not the
|
|
22
|
+
* same as one that is guaranteed to be, and the max costs nothing.
|
|
23
|
+
*
|
|
24
|
+
* Cost is deliberately not computed. Codex is a ChatGPT subscription — the
|
|
25
|
+
* rollouts carry `rate_limits.plan_type` — so a per-token dollar figure would
|
|
26
|
+
* be an invention, not a measurement. The tokens are real; the cost is
|
|
27
|
+
* `unavailable`.
|
|
28
|
+
*/
|
|
29
|
+
import { createReadStream } from "fs";
|
|
30
|
+
import { createInterface } from "readline";
|
|
31
|
+
import { readdir, stat } from "fs/promises";
|
|
32
|
+
import { homedir } from "os";
|
|
33
|
+
import { join } from "path";
|
|
34
|
+
const CLI_ID = "codex";
|
|
35
|
+
const DEFAULT_SINCE_DAYS = 30;
|
|
36
|
+
function sessionsRoot() {
|
|
37
|
+
return join(homedir(), ".codex", "sessions");
|
|
38
|
+
}
|
|
39
|
+
function emptyTotals() {
|
|
40
|
+
return {
|
|
41
|
+
requests: 0,
|
|
42
|
+
inputTokens: 0,
|
|
43
|
+
outputTokens: 0,
|
|
44
|
+
cacheReadTokens: 0,
|
|
45
|
+
cacheCreationTokens: 0,
|
|
46
|
+
costUsd: 0,
|
|
47
|
+
costConfidence: "unavailable",
|
|
48
|
+
unpricedRequests: 0,
|
|
49
|
+
unpricedModels: [],
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
async function collectRollouts(dir, out) {
|
|
53
|
+
let entries;
|
|
54
|
+
try {
|
|
55
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// One unreadable date directory must not cost the rest of the scan.
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
for (const entry of entries) {
|
|
62
|
+
const full = join(dir, entry.name);
|
|
63
|
+
if (entry.isDirectory()) {
|
|
64
|
+
await collectRollouts(full, out);
|
|
65
|
+
}
|
|
66
|
+
else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
|
|
67
|
+
out.push(full);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function num(value) {
|
|
72
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Fold one rollout into a single session-level rollup.
|
|
76
|
+
*
|
|
77
|
+
* `billableEvents` counts only the `token_count` events where the cumulative
|
|
78
|
+
* total actually advanced. Counting every event instead would inherit exactly
|
|
79
|
+
* the repetition that makes summing the per-turn values wrong.
|
|
80
|
+
*/
|
|
81
|
+
async function readRollout(filePath) {
|
|
82
|
+
const rl = createInterface({
|
|
83
|
+
input: createReadStream(filePath, { encoding: "utf8" }),
|
|
84
|
+
crlfDelay: Infinity,
|
|
85
|
+
});
|
|
86
|
+
let model;
|
|
87
|
+
let bestTotal = -1;
|
|
88
|
+
let best;
|
|
89
|
+
let previousTotal = -1;
|
|
90
|
+
let billableEvents = 0;
|
|
91
|
+
try {
|
|
92
|
+
for await (const line of rl) {
|
|
93
|
+
// Cheap reject before JSON.parse: these files reach hundreds of MB and
|
|
94
|
+
// most lines are conversation content with no usage on them at all.
|
|
95
|
+
const isTokenCount = line.includes('"token_count"');
|
|
96
|
+
const isTurnContext = line.includes('"turn_context"');
|
|
97
|
+
if (!isTokenCount && !isTurnContext) {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
let parsed;
|
|
101
|
+
try {
|
|
102
|
+
parsed = JSON.parse(line);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
// A rollout being appended to while we read it ends mid-line.
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const record = parsed;
|
|
109
|
+
if (record.type === "turn_context" && record.payload?.model) {
|
|
110
|
+
// Last one wins: a session can switch models partway through, and the
|
|
111
|
+
// most recent is the better single label for it.
|
|
112
|
+
model = record.payload.model;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (record.payload?.type !== "token_count") {
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const cumulative = record.payload.info?.total_token_usage;
|
|
119
|
+
if (!cumulative) {
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const total = num(cumulative.total_tokens);
|
|
123
|
+
if (total > previousTotal) {
|
|
124
|
+
billableEvents += 1;
|
|
125
|
+
}
|
|
126
|
+
previousTotal = total;
|
|
127
|
+
if (total > bestTotal) {
|
|
128
|
+
bestTotal = total;
|
|
129
|
+
best = {
|
|
130
|
+
input: num(cumulative.input_tokens),
|
|
131
|
+
output: num(cumulative.output_tokens),
|
|
132
|
+
cached: num(cumulative.cached_input_tokens),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
finally {
|
|
138
|
+
rl.close();
|
|
139
|
+
}
|
|
140
|
+
if (!best) {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
return { model, billableEvents, ...best };
|
|
144
|
+
}
|
|
145
|
+
export async function createCodexReader() {
|
|
146
|
+
return {
|
|
147
|
+
descriptor: {
|
|
148
|
+
id: CLI_ID,
|
|
149
|
+
displayName: "Codex",
|
|
150
|
+
verified: true,
|
|
151
|
+
dedupStrategy: "session-dag",
|
|
152
|
+
costConfidence: "unavailable",
|
|
153
|
+
requiresSqlite: false,
|
|
154
|
+
},
|
|
155
|
+
detect: async () => {
|
|
156
|
+
try {
|
|
157
|
+
const info = await stat(sessionsRoot());
|
|
158
|
+
return info.isDirectory();
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
scan: async (options) => {
|
|
165
|
+
const totals = emptyTotals();
|
|
166
|
+
const errors = [];
|
|
167
|
+
const models = new Set();
|
|
168
|
+
const files = [];
|
|
169
|
+
await collectRollouts(sessionsRoot(), files);
|
|
170
|
+
const sinceDays = options?.sinceDays ?? DEFAULT_SINCE_DAYS;
|
|
171
|
+
const cutoff = Number.isFinite(sinceDays) && sinceDays > 0
|
|
172
|
+
? Date.now() - sinceDays * 86_400_000
|
|
173
|
+
: undefined;
|
|
174
|
+
let filesScanned = 0;
|
|
175
|
+
for (const file of files) {
|
|
176
|
+
try {
|
|
177
|
+
if (cutoff !== undefined) {
|
|
178
|
+
const info = await stat(file);
|
|
179
|
+
if (info.mtimeMs < cutoff) {
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
const rollup = await readRollout(file);
|
|
184
|
+
filesScanned += 1;
|
|
185
|
+
if (!rollup) {
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
totals.requests += rollup.billableEvents;
|
|
189
|
+
// `cached_input_tokens` is a SUBSET of `input_tokens` here — verified
|
|
190
|
+
// on all 108 sessions, where input + output == total exactly and
|
|
191
|
+
// cached <= input always. The other readers keep the two disjoint, so
|
|
192
|
+
// the cached portion is subtracted out rather than reported twice; a
|
|
193
|
+
// caller adding inputTokens + cacheReadTokens would otherwise
|
|
194
|
+
// over-count Codex and not the rest.
|
|
195
|
+
totals.inputTokens += Math.max(0, rollup.input - rollup.cached);
|
|
196
|
+
totals.cacheReadTokens += rollup.cached;
|
|
197
|
+
totals.outputTokens += rollup.output;
|
|
198
|
+
if (rollup.model) {
|
|
199
|
+
models.add(rollup.model);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
errors.push({
|
|
204
|
+
cliId: CLI_ID,
|
|
205
|
+
filePath: file,
|
|
206
|
+
message: error instanceof Error ? error.message : String(error),
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
// Every turn is unpriced by construction, not by accident: see the note
|
|
211
|
+
// on this module about Codex being a subscription. Naming the models
|
|
212
|
+
// keeps that legible instead of looking like a lookup that failed.
|
|
213
|
+
totals.unpricedRequests = totals.requests;
|
|
214
|
+
totals.unpricedModels = [...models].sort();
|
|
215
|
+
return { cliId: CLI_ID, totals, filesScanned, errors };
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local usage: token spend read from each CLI's own session logs.
|
|
3
|
+
*
|
|
4
|
+
* The proxy's ledger can only account for traffic that went through it, which
|
|
5
|
+
* caps coverage at the CLIs that expose a base-URL override. Every CLI writes
|
|
6
|
+
* a local transcript regardless, so reading those covers the rest — and covers
|
|
7
|
+
* history from before the proxy existed.
|
|
8
|
+
*/
|
|
9
|
+
import type { LocalUsageAggregateReport, LocalUsageScanOptions } from "../types/index.js";
|
|
10
|
+
export { createLocalUsageReader, getLocalUsageDescriptors, getRegisteredLocalUsageCliIds, registerLocalUsageReader, } from "./localUsageReaderRegistry.js";
|
|
11
|
+
/**
|
|
12
|
+
* Scan every registered reader whose CLI is actually present on this machine.
|
|
13
|
+
*
|
|
14
|
+
* "Not installed" and "failed" are reported separately and deliberately: a CLI
|
|
15
|
+
* the user never installed is not an error, and collapsing the two would make
|
|
16
|
+
* a broken reader indistinguishable from an absent one.
|
|
17
|
+
*/
|
|
18
|
+
export declare function readAllLocalUsage(options?: LocalUsageScanOptions): Promise<LocalUsageAggregateReport>;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local usage: token spend read from each CLI's own session logs.
|
|
3
|
+
*
|
|
4
|
+
* The proxy's ledger can only account for traffic that went through it, which
|
|
5
|
+
* caps coverage at the CLIs that expose a base-URL override. Every CLI writes
|
|
6
|
+
* a local transcript regardless, so reading those covers the rest — and covers
|
|
7
|
+
* history from before the proxy existed.
|
|
8
|
+
*/
|
|
9
|
+
import { createLocalUsageReader, getRegisteredLocalUsageCliIds, } from "./localUsageReaderRegistry.js";
|
|
10
|
+
export { createLocalUsageReader, getLocalUsageDescriptors, getRegisteredLocalUsageCliIds, registerLocalUsageReader, } from "./localUsageReaderRegistry.js";
|
|
11
|
+
/**
|
|
12
|
+
* Scan every registered reader whose CLI is actually present on this machine.
|
|
13
|
+
*
|
|
14
|
+
* "Not installed" and "failed" are reported separately and deliberately: a CLI
|
|
15
|
+
* the user never installed is not an error, and collapsing the two would make
|
|
16
|
+
* a broken reader indistinguishable from an absent one.
|
|
17
|
+
*/
|
|
18
|
+
export async function readAllLocalUsage(options) {
|
|
19
|
+
const totals = {};
|
|
20
|
+
const failures = [];
|
|
21
|
+
const notInstalled = [];
|
|
22
|
+
for (const cliId of getRegisteredLocalUsageCliIds()) {
|
|
23
|
+
try {
|
|
24
|
+
const reader = await createLocalUsageReader(cliId);
|
|
25
|
+
if (!(await reader.detect())) {
|
|
26
|
+
notInstalled.push(cliId);
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
const result = await reader.scan(options);
|
|
30
|
+
totals[cliId] = result.totals;
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
// One reader throwing must not lose the others' results.
|
|
34
|
+
failures.push({
|
|
35
|
+
cliId,
|
|
36
|
+
message: error instanceof Error ? error.message : String(error),
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
generatedAt: new Date().toISOString(),
|
|
42
|
+
totals,
|
|
43
|
+
failures,
|
|
44
|
+
notInstalled,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registry of local-usage readers, one per CLI.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors `providerRegistry.ts`: descriptors are static and always available,
|
|
5
|
+
* while the reader itself arrives through a dynamic import inside its factory.
|
|
6
|
+
* Same reason as the provider registry — a static import graph across a dozen
|
|
7
|
+
* readers is how circular dependencies start, and it would also force every
|
|
8
|
+
* reader's cost of loading onto a caller who only wanted one of them.
|
|
9
|
+
*/
|
|
10
|
+
import type { LocalUsageCliId, LocalUsageReader, LocalUsageReaderDescriptor, LocalUsageReaderRegistration } from "../types/index.js";
|
|
11
|
+
export declare function registerLocalUsageReader(registration: LocalUsageReaderRegistration): void;
|
|
12
|
+
export declare function getLocalUsageDescriptors(): LocalUsageReaderDescriptor[];
|
|
13
|
+
export declare function getRegisteredLocalUsageCliIds(): LocalUsageCliId[];
|
|
14
|
+
export declare function createLocalUsageReader(cliId: LocalUsageCliId): Promise<LocalUsageReader>;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registry of local-usage readers, one per CLI.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors `providerRegistry.ts`: descriptors are static and always available,
|
|
5
|
+
* while the reader itself arrives through a dynamic import inside its factory.
|
|
6
|
+
* Same reason as the provider registry — a static import graph across a dozen
|
|
7
|
+
* readers is how circular dependencies start, and it would also force every
|
|
8
|
+
* reader's cost of loading onto a caller who only wanted one of them.
|
|
9
|
+
*/
|
|
10
|
+
const registry = new Map();
|
|
11
|
+
export function registerLocalUsageReader(registration) {
|
|
12
|
+
registry.set(registration.descriptor.id, registration);
|
|
13
|
+
}
|
|
14
|
+
export function getLocalUsageDescriptors() {
|
|
15
|
+
return [...registry.values()].map((entry) => entry.descriptor);
|
|
16
|
+
}
|
|
17
|
+
export function getRegisteredLocalUsageCliIds() {
|
|
18
|
+
return [...registry.keys()];
|
|
19
|
+
}
|
|
20
|
+
export async function createLocalUsageReader(cliId) {
|
|
21
|
+
const registration = registry.get(cliId);
|
|
22
|
+
if (!registration) {
|
|
23
|
+
throw new Error(`No local-usage reader registered for "${cliId}". Registered: ${getRegisteredLocalUsageCliIds().join(", ") || "(none)"}`);
|
|
24
|
+
}
|
|
25
|
+
return registration.factory();
|
|
26
|
+
}
|
|
27
|
+
registerLocalUsageReader({
|
|
28
|
+
descriptor: {
|
|
29
|
+
id: "claude-code",
|
|
30
|
+
displayName: "Claude Code",
|
|
31
|
+
verified: true,
|
|
32
|
+
dedupStrategy: "message-id-keep-max",
|
|
33
|
+
costConfidence: "modeled",
|
|
34
|
+
requiresSqlite: false,
|
|
35
|
+
},
|
|
36
|
+
factory: async () => {
|
|
37
|
+
const { createClaudeCodeReader } = await import("./claudeCodeReader.js");
|
|
38
|
+
return createClaudeCodeReader();
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
registerLocalUsageReader({
|
|
42
|
+
descriptor: {
|
|
43
|
+
id: "codex",
|
|
44
|
+
displayName: "Codex",
|
|
45
|
+
verified: true,
|
|
46
|
+
dedupStrategy: "session-dag",
|
|
47
|
+
costConfidence: "unavailable",
|
|
48
|
+
requiresSqlite: false,
|
|
49
|
+
},
|
|
50
|
+
factory: async () => {
|
|
51
|
+
const { createCodexReader } = await import("./codexReader.js");
|
|
52
|
+
return createCodexReader();
|
|
53
|
+
},
|
|
54
|
+
});
|
package/dist/types/index.d.ts
CHANGED
package/dist/types/index.js
CHANGED