@juspay/neurolink 12.8.0 → 12.9.1
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 -3
- package/dist/browser/neurolink.min.js +387 -387
- package/dist/cli/commands/proxy.js +35 -2
- package/dist/localUsage/grokReader.d.ts +73 -0
- package/dist/localUsage/grokReader.js +325 -0
- package/dist/localUsage/hermesReader.d.ts +51 -0
- package/dist/localUsage/hermesReader.js +350 -0
- package/dist/localUsage/localUsageReaderRegistry.js +28 -0
- package/dist/proxy/proxyActivity.d.ts +2 -0
- package/dist/proxy/proxyActivity.js +18 -0
- package/dist/server/routes/codexProxyRoutes.js +3 -2
- package/dist/types/localUsage.d.ts +56 -1
- package/package.json +1 -1
|
@@ -26,7 +26,7 @@ import { ProxyRuntimeConfigStore } from "../../proxy/runtimeConfig.js";
|
|
|
26
26
|
import { startProxyLogCleanupScheduler } from "../../proxy/logCleanupScheduler.js";
|
|
27
27
|
import { anthropicAccountKeysEqual, createAccountAllowlist, isAccountAllowed, LEGACY_ANTHROPIC_ACCOUNT_KEY, normalizeAnthropicAccountKey, shouldLoadFallbackCredential, } from "../../proxy/accountSelection.js";
|
|
28
28
|
import { resolveProxyStatusAccountIdentity } from "../../proxy/codexAccountUsage.js";
|
|
29
|
-
import { beginProxyRequest, getProxyActivitySnapshot, trackProxyResponse, } from "../../proxy/proxyActivity.js";
|
|
29
|
+
import { beginProxyRequest, getProxyActivitySnapshot, takeProxyResponseObservers, trackProxyResponse, } from "../../proxy/proxyActivity.js";
|
|
30
30
|
import { flushProxyLifecycleEvents, getProxyLifecycleLoggerSnapshot, hashProxyLifecycleSessionId, logProxyLifecycleEvent, } from "../../proxy/proxyLifecycle.js";
|
|
31
31
|
import { describeInstallFailure, getGlobalInstallArgs, isTransientInstallFailure, resolveGlobalInstaller, validateInstalledVersion, } from "../../proxy/globalInstaller.js";
|
|
32
32
|
import { startUpdaterWorkerSupervisor } from "../../proxy/updaterSupervisor.js";
|
|
@@ -1120,6 +1120,27 @@ function registerProxyRequestTracking(app, requestMetadata, readiness) {
|
|
|
1120
1120
|
responseStatus,
|
|
1121
1121
|
elapsedMs: performance.now() - startedMonotonicMs,
|
|
1122
1122
|
});
|
|
1123
|
+
const routeResponseObservers = takeProxyResponseObservers(metadata);
|
|
1124
|
+
const notifyRouteFirstChunk = (details) => {
|
|
1125
|
+
for (const observer of routeResponseObservers) {
|
|
1126
|
+
try {
|
|
1127
|
+
observer.onFirstChunk?.(details);
|
|
1128
|
+
}
|
|
1129
|
+
catch {
|
|
1130
|
+
// Route-level accounting must never interfere with the relay.
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
};
|
|
1134
|
+
const notifyRouteTerminal = (details) => {
|
|
1135
|
+
for (const observer of routeResponseObservers) {
|
|
1136
|
+
try {
|
|
1137
|
+
observer.onTerminal?.(details);
|
|
1138
|
+
}
|
|
1139
|
+
catch {
|
|
1140
|
+
// Route-level accounting must never interfere with the relay.
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
};
|
|
1123
1144
|
c.res = trackProxyResponse(c.res, finish, {
|
|
1124
1145
|
onFirstChunk: ({ observedBodyBytes, responseChunks }) => {
|
|
1125
1146
|
logProxyLifecycleEvent({
|
|
@@ -1137,6 +1158,10 @@ function registerProxyRequestTracking(app, requestMetadata, readiness) {
|
|
|
1137
1158
|
responseChunks,
|
|
1138
1159
|
elapsedMs: performance.now() - startedMonotonicMs,
|
|
1139
1160
|
});
|
|
1161
|
+
notifyRouteFirstChunk({
|
|
1162
|
+
observedBodyBytes,
|
|
1163
|
+
responseChunks,
|
|
1164
|
+
});
|
|
1140
1165
|
},
|
|
1141
1166
|
onTerminal: ({ outcome, observedBodyBytes, responseChunks }) => {
|
|
1142
1167
|
logProxyLifecycleEvent({
|
|
@@ -1157,6 +1182,11 @@ function registerProxyRequestTracking(app, requestMetadata, readiness) {
|
|
|
1157
1182
|
errorType: metadata.terminalErrorType,
|
|
1158
1183
|
errorCode: metadata.terminalErrorCode,
|
|
1159
1184
|
});
|
|
1185
|
+
notifyRouteTerminal({
|
|
1186
|
+
outcome,
|
|
1187
|
+
observedBodyBytes,
|
|
1188
|
+
responseChunks,
|
|
1189
|
+
});
|
|
1160
1190
|
},
|
|
1161
1191
|
});
|
|
1162
1192
|
}
|
|
@@ -1484,7 +1514,10 @@ export async function createProxyStartApp(params) {
|
|
|
1484
1514
|
neurolink: params.neurolink,
|
|
1485
1515
|
toolRegistry: params.neurolink.getToolRegistry(),
|
|
1486
1516
|
timestamp: Date.now(),
|
|
1487
|
-
metadata
|
|
1517
|
+
// Keep route terminal observers on the runtime request metadata so the
|
|
1518
|
+
// outer response tracker can notify them without adding a second body
|
|
1519
|
+
// wrapper to streaming routes.
|
|
1520
|
+
metadata: (metadata ?? {}),
|
|
1488
1521
|
// Route handlers publish limit/quota headers here. Only the streaming
|
|
1489
1522
|
// paths build their own Response (and set headers directly); every
|
|
1490
1523
|
// JSON and error path returns a plain object, so without this the
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads token usage out of Grok Build's own session streams.
|
|
3
|
+
*
|
|
4
|
+
* Grok Build is xAI's official terminal coding agent (github.com/xai-org/
|
|
5
|
+
* grok-build, Rust, installed from x.ai/cli/install.sh as `grok`). An earlier
|
|
6
|
+
* note in this folder said "grok" named no product, only eight competing npm
|
|
7
|
+
* packages; it had searched npm for a Rust binary that is not distributed
|
|
8
|
+
* there, and is retracted here.
|
|
9
|
+
*
|
|
10
|
+
* Layout, confirmed by running the real binary against a redirected home:
|
|
11
|
+
* `$GROK_HOME/sessions/<url-encoded cwd>/<session-id>/`, defaulting to
|
|
12
|
+
* `~/.grok`. Each session directory holds `updates.jsonl` — the CLI's own
|
|
13
|
+
* documentation calls it the authoritative conversation log — plus
|
|
14
|
+
* `summary.json`, `chat_history.jsonl`, `signals.json` and others. The walk
|
|
15
|
+
* here reads exactly `<group>/<session>/updates.jsonl` and nothing deeper:
|
|
16
|
+
* a session directory can also hold `compaction_checkpoints/`, and a
|
|
17
|
+
* checkpoint that snapshots the stream would otherwise be counted twice.
|
|
18
|
+
*
|
|
19
|
+
* Each line is a JSON-RPC style record. The ones that matter are
|
|
20
|
+
* `method: "_x.ai/session/update"` with `params.update.sessionUpdate ===
|
|
21
|
+
* "turn_completed"`; a completed turn that reached a model carries a `usage`
|
|
22
|
+
* object with `inputTokens`, `outputTokens`, `cachedReadTokens`,
|
|
23
|
+
* `cacheCreationTokens`, `reasoningTokens`, `modelCalls`, a per-model
|
|
24
|
+
* `modelUsage` map and `numTurns`. A turn that failed before any model call
|
|
25
|
+
* carries no `usage` at all, so it excludes itself. `stop_reason` is not an
|
|
26
|
+
* eligibility test: a truncated or interrupted turn with a usage object was
|
|
27
|
+
* still billed.
|
|
28
|
+
*
|
|
29
|
+
* What the numbers MEAN took a second real turn to settle, and the answer is
|
|
30
|
+
* neither "per turn" nor "cumulative" but both, by process:
|
|
31
|
+
*
|
|
32
|
+
* turn 1 (fresh process) inputTokens 10141 numTurns 1
|
|
33
|
+
* turn 2 (`grok -r`, new one) inputTokens 11034 numTurns 1
|
|
34
|
+
*
|
|
35
|
+
* The second record is not 21175, so the usage is not a session-lifetime
|
|
36
|
+
* counter. But the CLI's own persistence code says the figure is the
|
|
37
|
+
* process's live ledger, and computes a turn's cost as live minus the
|
|
38
|
+
* previous live value when the ledger has only grown — a running total within
|
|
39
|
+
* one process, reset when a new process resumes the session. A reader has no
|
|
40
|
+
* process boundary to look at; what it has is `numTurns`, the ledger's own
|
|
41
|
+
* turn counter. Strictly increasing, with every bucket at least as large,
|
|
42
|
+
* means the same ledger, and the turn's usage is the difference from the
|
|
43
|
+
* previous record. Anything else means a fresh ledger, and the whole record
|
|
44
|
+
* counts. Both real records above have numTurns 1, so both count whole, which
|
|
45
|
+
* is the measured truth. The in-process cumulative branch is taken from the
|
|
46
|
+
* CLI's source, not from a measurement — headless `-p` runs are one prompt
|
|
47
|
+
* per process, so no real stream on this machine exercises it.
|
|
48
|
+
*
|
|
49
|
+
* Duplicates: a prompt's terminal record can be re-emitted (the persistence
|
|
50
|
+
* layer folds a late re-emission into the same turn). Dedup is by
|
|
51
|
+
* `prompt_id`, scoped to the session directory — prompt ids are not
|
|
52
|
+
* documented as globally unique — keeping the last record seen. A record
|
|
53
|
+
* with no prompt id is keyed by its line.
|
|
54
|
+
*
|
|
55
|
+
* Cost is `unavailable`: the turn record carries no price, and Grok Build is
|
|
56
|
+
* a subscription product whose custom-model configurations can point at any
|
|
57
|
+
* OpenAI- or Anthropic-compatible endpoint — the real run here used DeepSeek.
|
|
58
|
+
* Cache and reasoning counts are reported as stored; every real sample so far
|
|
59
|
+
* has them at zero, so whether `cachedReadTokens` is a subset of
|
|
60
|
+
* `inputTokens` has not been measured and nothing is subtracted or folded
|
|
61
|
+
* until it has. Reasoning tokens in particular are NOT added to output: on the
|
|
62
|
+
* OpenAI wire they are a subset of completion tokens, and adding them would
|
|
63
|
+
* double count.
|
|
64
|
+
*
|
|
65
|
+
* Not read: the newer per-session `usage.json` the CLI writes from the same
|
|
66
|
+
* ledger. No real run on this machine produced one — the 1.0.13 binary's
|
|
67
|
+
* `grok usage` reports "No usage recorded" for these sessions — and a reader
|
|
68
|
+
* written against a struct definition alone is the guessed-format failure
|
|
69
|
+
* this folder keeps paying for. When a real file exists it belongs here as a
|
|
70
|
+
* fallback for a session with no readable stream, never as an addition.
|
|
71
|
+
*/
|
|
72
|
+
import type { LocalUsageReader } from "../types/index.js";
|
|
73
|
+
export declare function createGrokReader(): Promise<LocalUsageReader>;
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads token usage out of Grok Build's own session streams.
|
|
3
|
+
*
|
|
4
|
+
* Grok Build is xAI's official terminal coding agent (github.com/xai-org/
|
|
5
|
+
* grok-build, Rust, installed from x.ai/cli/install.sh as `grok`). An earlier
|
|
6
|
+
* note in this folder said "grok" named no product, only eight competing npm
|
|
7
|
+
* packages; it had searched npm for a Rust binary that is not distributed
|
|
8
|
+
* there, and is retracted here.
|
|
9
|
+
*
|
|
10
|
+
* Layout, confirmed by running the real binary against a redirected home:
|
|
11
|
+
* `$GROK_HOME/sessions/<url-encoded cwd>/<session-id>/`, defaulting to
|
|
12
|
+
* `~/.grok`. Each session directory holds `updates.jsonl` — the CLI's own
|
|
13
|
+
* documentation calls it the authoritative conversation log — plus
|
|
14
|
+
* `summary.json`, `chat_history.jsonl`, `signals.json` and others. The walk
|
|
15
|
+
* here reads exactly `<group>/<session>/updates.jsonl` and nothing deeper:
|
|
16
|
+
* a session directory can also hold `compaction_checkpoints/`, and a
|
|
17
|
+
* checkpoint that snapshots the stream would otherwise be counted twice.
|
|
18
|
+
*
|
|
19
|
+
* Each line is a JSON-RPC style record. The ones that matter are
|
|
20
|
+
* `method: "_x.ai/session/update"` with `params.update.sessionUpdate ===
|
|
21
|
+
* "turn_completed"`; a completed turn that reached a model carries a `usage`
|
|
22
|
+
* object with `inputTokens`, `outputTokens`, `cachedReadTokens`,
|
|
23
|
+
* `cacheCreationTokens`, `reasoningTokens`, `modelCalls`, a per-model
|
|
24
|
+
* `modelUsage` map and `numTurns`. A turn that failed before any model call
|
|
25
|
+
* carries no `usage` at all, so it excludes itself. `stop_reason` is not an
|
|
26
|
+
* eligibility test: a truncated or interrupted turn with a usage object was
|
|
27
|
+
* still billed.
|
|
28
|
+
*
|
|
29
|
+
* What the numbers MEAN took a second real turn to settle, and the answer is
|
|
30
|
+
* neither "per turn" nor "cumulative" but both, by process:
|
|
31
|
+
*
|
|
32
|
+
* turn 1 (fresh process) inputTokens 10141 numTurns 1
|
|
33
|
+
* turn 2 (`grok -r`, new one) inputTokens 11034 numTurns 1
|
|
34
|
+
*
|
|
35
|
+
* The second record is not 21175, so the usage is not a session-lifetime
|
|
36
|
+
* counter. But the CLI's own persistence code says the figure is the
|
|
37
|
+
* process's live ledger, and computes a turn's cost as live minus the
|
|
38
|
+
* previous live value when the ledger has only grown — a running total within
|
|
39
|
+
* one process, reset when a new process resumes the session. A reader has no
|
|
40
|
+
* process boundary to look at; what it has is `numTurns`, the ledger's own
|
|
41
|
+
* turn counter. Strictly increasing, with every bucket at least as large,
|
|
42
|
+
* means the same ledger, and the turn's usage is the difference from the
|
|
43
|
+
* previous record. Anything else means a fresh ledger, and the whole record
|
|
44
|
+
* counts. Both real records above have numTurns 1, so both count whole, which
|
|
45
|
+
* is the measured truth. The in-process cumulative branch is taken from the
|
|
46
|
+
* CLI's source, not from a measurement — headless `-p` runs are one prompt
|
|
47
|
+
* per process, so no real stream on this machine exercises it.
|
|
48
|
+
*
|
|
49
|
+
* Duplicates: a prompt's terminal record can be re-emitted (the persistence
|
|
50
|
+
* layer folds a late re-emission into the same turn). Dedup is by
|
|
51
|
+
* `prompt_id`, scoped to the session directory — prompt ids are not
|
|
52
|
+
* documented as globally unique — keeping the last record seen. A record
|
|
53
|
+
* with no prompt id is keyed by its line.
|
|
54
|
+
*
|
|
55
|
+
* Cost is `unavailable`: the turn record carries no price, and Grok Build is
|
|
56
|
+
* a subscription product whose custom-model configurations can point at any
|
|
57
|
+
* OpenAI- or Anthropic-compatible endpoint — the real run here used DeepSeek.
|
|
58
|
+
* Cache and reasoning counts are reported as stored; every real sample so far
|
|
59
|
+
* has them at zero, so whether `cachedReadTokens` is a subset of
|
|
60
|
+
* `inputTokens` has not been measured and nothing is subtracted or folded
|
|
61
|
+
* until it has. Reasoning tokens in particular are NOT added to output: on the
|
|
62
|
+
* OpenAI wire they are a subset of completion tokens, and adding them would
|
|
63
|
+
* double count.
|
|
64
|
+
*
|
|
65
|
+
* Not read: the newer per-session `usage.json` the CLI writes from the same
|
|
66
|
+
* ledger. No real run on this machine produced one — the 1.0.13 binary's
|
|
67
|
+
* `grok usage` reports "No usage recorded" for these sessions — and a reader
|
|
68
|
+
* written against a struct definition alone is the guessed-format failure
|
|
69
|
+
* this folder keeps paying for. When a real file exists it belongs here as a
|
|
70
|
+
* fallback for a session with no readable stream, never as an addition.
|
|
71
|
+
*/
|
|
72
|
+
import { createReadStream } from "fs";
|
|
73
|
+
import { createInterface } from "readline";
|
|
74
|
+
import { readdir, stat } from "fs/promises";
|
|
75
|
+
import { homedir } from "os";
|
|
76
|
+
import { join } from "path";
|
|
77
|
+
import { resolveScanCutoffMs } from "./scanWindow.js";
|
|
78
|
+
const CLI_ID = "grok";
|
|
79
|
+
function grokHome() {
|
|
80
|
+
const env = process.env.GROK_HOME;
|
|
81
|
+
return env !== undefined && env.trim().length > 0
|
|
82
|
+
? env
|
|
83
|
+
: join(homedir(), ".grok");
|
|
84
|
+
}
|
|
85
|
+
function sessionsRoot() {
|
|
86
|
+
return join(grokHome(), "sessions");
|
|
87
|
+
}
|
|
88
|
+
function emptyTotals() {
|
|
89
|
+
return {
|
|
90
|
+
requests: 0,
|
|
91
|
+
inputTokens: 0,
|
|
92
|
+
outputTokens: 0,
|
|
93
|
+
cacheReadTokens: 0,
|
|
94
|
+
cacheCreationTokens: 0,
|
|
95
|
+
costUsd: 0,
|
|
96
|
+
costConfidence: "unavailable",
|
|
97
|
+
unpricedRequests: 0,
|
|
98
|
+
unpricedModels: [],
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
/** A finite, non-negative safe integer, or 0. */
|
|
102
|
+
function count(value) {
|
|
103
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0
|
|
104
|
+
? value
|
|
105
|
+
: 0;
|
|
106
|
+
}
|
|
107
|
+
function isMissing(error) {
|
|
108
|
+
return (typeof error === "object" &&
|
|
109
|
+
error !== null &&
|
|
110
|
+
error.code === "ENOENT");
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Every `<group>/<session>/updates.jsonl`, exactly two levels down. Symlinks
|
|
114
|
+
* are not followed: a linked session directory is either a duplicate of one
|
|
115
|
+
* already in the tree or something outside it, and neither should be read.
|
|
116
|
+
*/
|
|
117
|
+
async function collectStreams(root, errors) {
|
|
118
|
+
let groups;
|
|
119
|
+
try {
|
|
120
|
+
groups = await readdir(root, { withFileTypes: true });
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
if (!isMissing(error)) {
|
|
124
|
+
errors.push({
|
|
125
|
+
cliId: CLI_ID,
|
|
126
|
+
filePath: root,
|
|
127
|
+
message: error instanceof Error ? error.message : String(error),
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
return [];
|
|
131
|
+
}
|
|
132
|
+
const out = [];
|
|
133
|
+
for (const group of groups) {
|
|
134
|
+
if (!group.isDirectory()) {
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const groupDir = join(root, group.name);
|
|
138
|
+
let sessions;
|
|
139
|
+
try {
|
|
140
|
+
sessions = await readdir(groupDir, { withFileTypes: true });
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
if (!isMissing(error)) {
|
|
144
|
+
errors.push({
|
|
145
|
+
cliId: CLI_ID,
|
|
146
|
+
filePath: groupDir,
|
|
147
|
+
message: error instanceof Error ? error.message : String(error),
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
for (const session of sessions) {
|
|
153
|
+
if (!session.isDirectory()) {
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
const stream = join(groupDir, session.name, "updates.jsonl");
|
|
157
|
+
try {
|
|
158
|
+
if ((await stat(stream)).isFile()) {
|
|
159
|
+
out.push(stream);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
// A session directory with no stream yet.
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return out;
|
|
168
|
+
}
|
|
169
|
+
function readTurn(usage) {
|
|
170
|
+
const models = typeof usage.modelUsage === "object" && usage.modelUsage !== null
|
|
171
|
+
? Object.keys(usage.modelUsage).filter((k) => k.length > 0)
|
|
172
|
+
: [];
|
|
173
|
+
return {
|
|
174
|
+
input: count(usage.inputTokens),
|
|
175
|
+
output: count(usage.outputTokens),
|
|
176
|
+
cacheRead: count(usage.cachedReadTokens),
|
|
177
|
+
cacheCreation: count(usage.cacheCreationTokens),
|
|
178
|
+
calls: count(usage.modelCalls),
|
|
179
|
+
turns: count(usage.numTurns),
|
|
180
|
+
models,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
/** Whether `next` is the same process ledger as `prev`, grown by a turn. */
|
|
184
|
+
function continuesRun(prev, next) {
|
|
185
|
+
return (next.turns > prev.turns &&
|
|
186
|
+
next.input >= prev.input &&
|
|
187
|
+
next.output >= prev.output &&
|
|
188
|
+
next.cacheRead >= prev.cacheRead &&
|
|
189
|
+
next.cacheCreation >= prev.cacheCreation &&
|
|
190
|
+
next.calls >= prev.calls);
|
|
191
|
+
}
|
|
192
|
+
function completedTurn(parsed) {
|
|
193
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
const record = parsed;
|
|
197
|
+
if (record.method !== "_x.ai/session/update") {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
if (typeof record.params !== "object" || record.params === null) {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
const update = record.params.update;
|
|
204
|
+
if (typeof update !== "object" || update === null) {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
const u = update;
|
|
208
|
+
if (u.sessionUpdate !== "turn_completed") {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
if (typeof u.usage !== "object" || u.usage === null) {
|
|
212
|
+
// Completed without reaching a model — an error before the first call.
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
return {
|
|
216
|
+
promptId: typeof u.prompt_id === "string" ? u.prompt_id : undefined,
|
|
217
|
+
usage: u.usage,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
async function foldStream(filePath, totals, unpriced) {
|
|
221
|
+
// Insertion-ordered, so the delta rule below sees turns in the order Grok
|
|
222
|
+
// wrote them, with a re-emitted prompt replacing its earlier record.
|
|
223
|
+
const turns = new Map();
|
|
224
|
+
const rl = createInterface({
|
|
225
|
+
input: createReadStream(filePath, { encoding: "utf8" }),
|
|
226
|
+
crlfDelay: Infinity,
|
|
227
|
+
});
|
|
228
|
+
try {
|
|
229
|
+
let lineNo = 0;
|
|
230
|
+
for await (const line of rl) {
|
|
231
|
+
lineNo += 1;
|
|
232
|
+
if (!line || line.charCodeAt(0) !== 123 /* '{' */) {
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
let parsed;
|
|
236
|
+
try {
|
|
237
|
+
parsed = JSON.parse(line);
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
// A stream being appended to ends in a partial line — normal.
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
const turn = completedTurn(parsed);
|
|
244
|
+
if (turn === null) {
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
turns.set(turn.promptId ?? `line:${lineNo}`, readTurn(turn.usage));
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
finally {
|
|
251
|
+
rl.close();
|
|
252
|
+
}
|
|
253
|
+
let prev;
|
|
254
|
+
for (const turn of turns.values()) {
|
|
255
|
+
const delta = prev !== undefined && continuesRun(prev, turn)
|
|
256
|
+
? {
|
|
257
|
+
input: turn.input - prev.input,
|
|
258
|
+
output: turn.output - prev.output,
|
|
259
|
+
cacheRead: turn.cacheRead - prev.cacheRead,
|
|
260
|
+
cacheCreation: turn.cacheCreation - prev.cacheCreation,
|
|
261
|
+
calls: turn.calls - prev.calls,
|
|
262
|
+
}
|
|
263
|
+
: turn;
|
|
264
|
+
prev = turn;
|
|
265
|
+
const calls = delta.calls > 0 ? delta.calls : 1;
|
|
266
|
+
totals.requests += calls;
|
|
267
|
+
totals.inputTokens += delta.input;
|
|
268
|
+
totals.outputTokens += delta.output;
|
|
269
|
+
totals.cacheReadTokens += delta.cacheRead;
|
|
270
|
+
totals.cacheCreationTokens += delta.cacheCreation;
|
|
271
|
+
totals.unpricedRequests += calls;
|
|
272
|
+
for (const model of turn.models.length > 0 ? turn.models : ["unknown"]) {
|
|
273
|
+
unpriced.add(model);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
export async function createGrokReader() {
|
|
278
|
+
return {
|
|
279
|
+
descriptor: {
|
|
280
|
+
id: CLI_ID,
|
|
281
|
+
displayName: "Grok Build",
|
|
282
|
+
verified: true,
|
|
283
|
+
// Per prompt id within a session, last record wins — a re-emitted
|
|
284
|
+
// terminal replaces the earlier one rather than adding to it.
|
|
285
|
+
dedupStrategy: "last-write-wins",
|
|
286
|
+
costConfidence: "unavailable",
|
|
287
|
+
requiresSqlite: false,
|
|
288
|
+
},
|
|
289
|
+
detect: async () => {
|
|
290
|
+
try {
|
|
291
|
+
return (await stat(sessionsRoot())).isDirectory();
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
},
|
|
297
|
+
scan: async (options) => {
|
|
298
|
+
const totals = emptyTotals();
|
|
299
|
+
const errors = [];
|
|
300
|
+
const unpriced = new Set();
|
|
301
|
+
const cutoff = resolveScanCutoffMs(options?.sinceDays);
|
|
302
|
+
let filesScanned = 0;
|
|
303
|
+
for (const file of await collectStreams(sessionsRoot(), errors)) {
|
|
304
|
+
try {
|
|
305
|
+
// One stream is one session, and its mtime is the session's last
|
|
306
|
+
// activity: a session is in or out of the window whole.
|
|
307
|
+
if (cutoff !== undefined && (await stat(file)).mtimeMs < cutoff) {
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
await foldStream(file, totals, unpriced);
|
|
311
|
+
filesScanned += 1;
|
|
312
|
+
}
|
|
313
|
+
catch (error) {
|
|
314
|
+
errors.push({
|
|
315
|
+
cliId: CLI_ID,
|
|
316
|
+
filePath: file,
|
|
317
|
+
message: error instanceof Error ? error.message : String(error),
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
totals.unpricedModels = [...unpriced].sort();
|
|
322
|
+
return { cliId: CLI_ID, totals, filesScanned, errors };
|
|
323
|
+
},
|
|
324
|
+
};
|
|
325
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads token usage out of Hermes Agent's SQLite state store.
|
|
3
|
+
*
|
|
4
|
+
* Hermes Agent is Nous Research's official CLI agent (github.com/NousResearch/
|
|
5
|
+
* hermes-agent), a Python program installed from its own install script — not
|
|
6
|
+
* an npm package. An earlier note in this folder said the opposite ("no
|
|
7
|
+
* official CLI, only an unofficial npm bridge"); it had searched npm for a
|
|
8
|
+
* product that is not distributed there, and is retracted here.
|
|
9
|
+
*
|
|
10
|
+
* Store: `$HERMES_HOME/state.db`, defaulting to `~/.hermes/state.db`, plus one
|
|
11
|
+
* `state.db` per profile under `profiles/<name>/`. SQLite, `schema_version`
|
|
12
|
+
* table (26 when this was written; `PRAGMA user_version` stays 0, so it is
|
|
13
|
+
* not the version to read). Confirmed on a real store produced by running the
|
|
14
|
+
* CLI itself, not inferred from documentation.
|
|
15
|
+
*
|
|
16
|
+
* Two tables carry usage, and they are NOT additive:
|
|
17
|
+
*
|
|
18
|
+
* sessions one row per session, with cumulative token columns
|
|
19
|
+
* for the PRIMARY task only.
|
|
20
|
+
* session_model_usage one row per (session, model, billing, task), each
|
|
21
|
+
* with its own `api_call_count`, token columns and cost.
|
|
22
|
+
*
|
|
23
|
+
* Measured on the real store: a one-prompt session held a `sessions` row of
|
|
24
|
+
* 10,568 input / 1 output / 1 call, and TWO usage rows — the primary task
|
|
25
|
+
* (`task = ''`, identical numbers) and a `title_generation` task of 248 / 8 /
|
|
26
|
+
* 1 call that the `sessions` aggregate does not include. The usage rows are
|
|
27
|
+
* therefore the complete record of what Hermes actually sent to a provider,
|
|
28
|
+
* and this reader sums them. The `sessions` aggregate is read only for a
|
|
29
|
+
* session that has no usage rows at all (a store older than the migration
|
|
30
|
+
* that introduced the table), and never in addition to them.
|
|
31
|
+
*
|
|
32
|
+
* Cost: Hermes records `estimated_cost_usd` with a `cost_status` of
|
|
33
|
+
* `estimated`, from its own pricing snapshot. That is a modeled figure and is
|
|
34
|
+
* reported as such. `actual_cost_usd` is `NOT NULL DEFAULT 0` on usage rows,
|
|
35
|
+
* so a zero there is a schema default, not evidence of a free call — it is
|
|
36
|
+
* used only when `cost_status` explicitly says the figure is actual. A row
|
|
37
|
+
* with no trustworthy cost is counted as unpriced and its model named.
|
|
38
|
+
*
|
|
39
|
+
* Cache and reasoning columns are reported as stored. Every real sample so far
|
|
40
|
+
* has them at zero, so whether `cache_read_tokens` is a subset of
|
|
41
|
+
* `input_tokens` (OpenAI convention) or disjoint from it (Anthropic
|
|
42
|
+
* convention) has not been measured, and no subtraction or folding is applied
|
|
43
|
+
* until it has. Reasoning tokens are not added to output for the same reason.
|
|
44
|
+
*
|
|
45
|
+
* The time window is a snapshot filter, not an attribution: every row is a
|
|
46
|
+
* cumulative counter, so a session that spans the cutoff is either included
|
|
47
|
+
* whole or excluded whole, keyed on its last activity. Timestamps are epoch
|
|
48
|
+
* SECONDS stored as REAL.
|
|
49
|
+
*/
|
|
50
|
+
import type { LocalUsageReader } from "../types/index.js";
|
|
51
|
+
export declare function createHermesReader(): Promise<LocalUsageReader>;
|