@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
|
@@ -0,0 +1,350 @@
|
|
|
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 { readdir, stat } from "fs/promises";
|
|
51
|
+
import { homedir } from "os";
|
|
52
|
+
import { join } from "path";
|
|
53
|
+
import { resolveScanCutoffMs } from "./scanWindow.js";
|
|
54
|
+
const CLI_ID = "hermes";
|
|
55
|
+
function hermesHome() {
|
|
56
|
+
const env = process.env.HERMES_HOME;
|
|
57
|
+
return env !== undefined && env.trim().length > 0
|
|
58
|
+
? env
|
|
59
|
+
: join(homedir(), ".hermes");
|
|
60
|
+
}
|
|
61
|
+
function emptyTotals() {
|
|
62
|
+
return {
|
|
63
|
+
requests: 0,
|
|
64
|
+
inputTokens: 0,
|
|
65
|
+
outputTokens: 0,
|
|
66
|
+
cacheReadTokens: 0,
|
|
67
|
+
cacheCreationTokens: 0,
|
|
68
|
+
costUsd: 0,
|
|
69
|
+
costConfidence: "modeled",
|
|
70
|
+
unpricedRequests: 0,
|
|
71
|
+
unpricedModels: [],
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
/** A finite, non-negative number, or 0. NULL and garbage both read as 0. */
|
|
75
|
+
function count(value) {
|
|
76
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0
|
|
77
|
+
? value
|
|
78
|
+
: 0;
|
|
79
|
+
}
|
|
80
|
+
/** A finite, non-negative number, or null — for costs, where 0 is a value. */
|
|
81
|
+
function amount(value) {
|
|
82
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0
|
|
83
|
+
? value
|
|
84
|
+
: null;
|
|
85
|
+
}
|
|
86
|
+
/** The root store plus one per profile. Missing pieces are simply absent. */
|
|
87
|
+
async function findStateDatabases() {
|
|
88
|
+
const root = hermesHome();
|
|
89
|
+
const out = [];
|
|
90
|
+
const rootDb = join(root, "state.db");
|
|
91
|
+
try {
|
|
92
|
+
if ((await stat(rootDb)).isFile()) {
|
|
93
|
+
out.push(rootDb);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// No root store.
|
|
98
|
+
}
|
|
99
|
+
let profiles;
|
|
100
|
+
try {
|
|
101
|
+
profiles = (await readdir(join(root, "profiles"), { withFileTypes: true }))
|
|
102
|
+
.filter((entry) => entry.isDirectory())
|
|
103
|
+
.map((entry) => entry.name);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
for (const name of profiles) {
|
|
109
|
+
const db = join(root, "profiles", name, "state.db");
|
|
110
|
+
try {
|
|
111
|
+
if ((await stat(db)).isFile()) {
|
|
112
|
+
out.push(db);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
// Profile without a store yet.
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
function columnsOf(db, table) {
|
|
122
|
+
// PRAGMA table_info cannot take a bound parameter; the table names here are
|
|
123
|
+
// fixed identifiers, never user input.
|
|
124
|
+
const rows = db.prepare(`PRAGMA table_info(${table})`).all();
|
|
125
|
+
return new Set(rows
|
|
126
|
+
.map((row) => row.name)
|
|
127
|
+
.filter((name) => typeof name === "string"));
|
|
128
|
+
}
|
|
129
|
+
const USAGE_REQUIRED = [
|
|
130
|
+
"session_id",
|
|
131
|
+
"model",
|
|
132
|
+
"api_call_count",
|
|
133
|
+
"input_tokens",
|
|
134
|
+
"output_tokens",
|
|
135
|
+
];
|
|
136
|
+
const SESSIONS_REQUIRED = [
|
|
137
|
+
"id",
|
|
138
|
+
"started_at",
|
|
139
|
+
"input_tokens",
|
|
140
|
+
"output_tokens",
|
|
141
|
+
];
|
|
142
|
+
const OPTIONAL_COUNTS = [
|
|
143
|
+
"cache_read_tokens",
|
|
144
|
+
"cache_write_tokens",
|
|
145
|
+
"reasoning_tokens",
|
|
146
|
+
];
|
|
147
|
+
const OPTIONAL_COST = [
|
|
148
|
+
"estimated_cost_usd",
|
|
149
|
+
"actual_cost_usd",
|
|
150
|
+
"cost_status",
|
|
151
|
+
];
|
|
152
|
+
/** `col` if the table has it, else a typed default under the same alias. */
|
|
153
|
+
function select(have, col, fallback, alias = col) {
|
|
154
|
+
return have.has(col)
|
|
155
|
+
? `${alias === col ? col : `${col} AS ${alias}`}`
|
|
156
|
+
: `${fallback} AS ${alias}`;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Which cost a row is allowed to claim.
|
|
160
|
+
*
|
|
161
|
+
* `cost_status` is provenance, and it governs which column is trusted: an
|
|
162
|
+
* explicit actual/billed status admits `actual_cost_usd`; an explicit
|
|
163
|
+
* estimated status admits `estimated_cost_usd`. Without a status, a POSITIVE
|
|
164
|
+
* estimate is still a computed value rather than a schema default and is
|
|
165
|
+
* accepted; a bare zero is not, because `actual_cost_usd` defaults to 0 on
|
|
166
|
+
* every usage row and would otherwise price every call at nothing.
|
|
167
|
+
*/
|
|
168
|
+
function rowCost(row) {
|
|
169
|
+
const status = typeof row.cost_status === "string" ? row.cost_status.toLowerCase() : "";
|
|
170
|
+
const actual = amount(row.actual_cost_usd);
|
|
171
|
+
const estimated = amount(row.estimated_cost_usd);
|
|
172
|
+
if (status === "actual" || status === "billed") {
|
|
173
|
+
return actual;
|
|
174
|
+
}
|
|
175
|
+
if (status === "estimated") {
|
|
176
|
+
return estimated;
|
|
177
|
+
}
|
|
178
|
+
return estimated !== null && estimated > 0 ? estimated : null;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* `COALESCE` over the timestamp columns a schema actually has. SQLite rejects
|
|
182
|
+
* a one-argument COALESCE outright, and a minimal schema (or a fixture
|
|
183
|
+
* modelled on one) can have exactly one — `started_at` is the only required
|
|
184
|
+
* timestamp — so a single column is emitted bare.
|
|
185
|
+
*/
|
|
186
|
+
function newestOf(cols) {
|
|
187
|
+
const [only] = cols;
|
|
188
|
+
return cols.length === 1 && only !== undefined
|
|
189
|
+
? only
|
|
190
|
+
: `COALESCE(${cols.join(", ")})`;
|
|
191
|
+
}
|
|
192
|
+
function foldRow(row, totals, unpriced) {
|
|
193
|
+
const calls = count(row.api_call_count);
|
|
194
|
+
const input = count(row.input_tokens);
|
|
195
|
+
const output = count(row.output_tokens);
|
|
196
|
+
if (calls === 0 && input + output === 0) {
|
|
197
|
+
// A failed one-shot leaves a session with no calls and no tokens. It is
|
|
198
|
+
// not usage, and counting it as a zero-token request would inflate the
|
|
199
|
+
// request count with attempts that never reached a model.
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
totals.requests += calls > 0 ? calls : 1;
|
|
203
|
+
totals.inputTokens += input;
|
|
204
|
+
totals.outputTokens += output;
|
|
205
|
+
totals.cacheReadTokens += count(row.cache_read_tokens);
|
|
206
|
+
totals.cacheCreationTokens += count(row.cache_write_tokens);
|
|
207
|
+
const cost = rowCost(row);
|
|
208
|
+
if (cost === null) {
|
|
209
|
+
totals.unpricedRequests += calls > 0 ? calls : 1;
|
|
210
|
+
unpriced.add(typeof row.model === "string" && row.model ? row.model : "unknown");
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
totals.costUsd += cost;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
function readStore(db, dbPath, cutoffSeconds, totals, unpriced, errors) {
|
|
217
|
+
const sessionCols = columnsOf(db, "sessions");
|
|
218
|
+
if (sessionCols.size === 0) {
|
|
219
|
+
errors.push({
|
|
220
|
+
cliId: CLI_ID,
|
|
221
|
+
filePath: dbPath,
|
|
222
|
+
message: "no sessions table — not a Hermes state store, or one this reader does not understand",
|
|
223
|
+
});
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
const missingSessions = SESSIONS_REQUIRED.filter((c) => !sessionCols.has(c));
|
|
227
|
+
if (missingSessions.length > 0) {
|
|
228
|
+
// Fail closed. Reading a partial schema and reporting a clean zero would
|
|
229
|
+
// look like "no usage" when the truth is "unreadable".
|
|
230
|
+
errors.push({
|
|
231
|
+
cliId: CLI_ID,
|
|
232
|
+
filePath: dbPath,
|
|
233
|
+
message: `sessions table lacks required column(s) ${missingSessions.join(", ")} — Hermes' schema has changed`,
|
|
234
|
+
});
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
const usageCols = columnsOf(db, "session_model_usage");
|
|
238
|
+
const hasUsage = usageCols.size > 0 && USAGE_REQUIRED.every((c) => usageCols.has(c));
|
|
239
|
+
// Last activity, in seconds. Sessions know theirs; usage rows fall back to
|
|
240
|
+
// their session's when they carry no `last_seen` of their own.
|
|
241
|
+
const sessionAt = [
|
|
242
|
+
sessionCols.has("last_activity_at") ? "s.last_activity_at" : null,
|
|
243
|
+
sessionCols.has("ended_at") ? "s.ended_at" : null,
|
|
244
|
+
"s.started_at",
|
|
245
|
+
].filter((c) => c !== null);
|
|
246
|
+
if (hasUsage) {
|
|
247
|
+
const at = [
|
|
248
|
+
usageCols.has("last_seen") ? "u.last_seen" : null,
|
|
249
|
+
usageCols.has("first_seen") ? "u.first_seen" : null,
|
|
250
|
+
...sessionAt,
|
|
251
|
+
].filter((c) => c !== null);
|
|
252
|
+
const sql = `SELECT u.session_id, u.model, u.api_call_count, u.input_tokens, u.output_tokens, ${OPTIONAL_COUNTS.map((c) => (usageCols.has(c) ? `u.${c}` : `0 AS ${c}`)).join(", ")}, ${OPTIONAL_COST.map((c) => usageCols.has(c) ? `u.${c}` : `NULL AS ${c}`).join(", ")}, ${newestOf(at)} AS at FROM session_model_usage u LEFT JOIN sessions s ON s.id = u.session_id`;
|
|
253
|
+
const rows = db.prepare(sql).all();
|
|
254
|
+
for (const row of rows) {
|
|
255
|
+
if (cutoffSeconds !== undefined && count(row.at) < cutoffSeconds) {
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
foldRow(row, totals, unpriced);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
// Sessions with no usage rows: only the aggregate exists for them.
|
|
262
|
+
const orphanFilter = hasUsage
|
|
263
|
+
? " WHERE NOT EXISTS (SELECT 1 FROM session_model_usage u WHERE u.session_id = s.id)"
|
|
264
|
+
: "";
|
|
265
|
+
const sessionSql = `SELECT s.id AS session_id, ${select(sessionCols, "model", "NULL")}, ${select(sessionCols, "api_call_count", "0")}, s.input_tokens, s.output_tokens, ${OPTIONAL_COUNTS.map((c) => sessionCols.has(c) ? `s.${c}` : `0 AS ${c}`).join(", ")}, ${OPTIONAL_COST.map((c) => sessionCols.has(c) ? `s.${c}` : `NULL AS ${c}`).join(", ")}, ${newestOf(sessionAt)} AS at FROM sessions s${orphanFilter}`;
|
|
266
|
+
const sessions = db.prepare(sessionSql).all();
|
|
267
|
+
for (const row of sessions) {
|
|
268
|
+
if (cutoffSeconds !== undefined && count(row.at) < cutoffSeconds) {
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
foldRow(row, totals, unpriced);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
export async function createHermesReader() {
|
|
275
|
+
return {
|
|
276
|
+
descriptor: {
|
|
277
|
+
id: CLI_ID,
|
|
278
|
+
displayName: "Hermes Agent",
|
|
279
|
+
verified: true,
|
|
280
|
+
// One row per (session, model, task): the store already holds each call
|
|
281
|
+
// exactly once, so the only thing to avoid is reading a session's
|
|
282
|
+
// aggregate on top of its rows.
|
|
283
|
+
dedupStrategy: "last-write-wins",
|
|
284
|
+
costConfidence: "modeled",
|
|
285
|
+
requiresSqlite: true,
|
|
286
|
+
},
|
|
287
|
+
detect: async () => (await findStateDatabases()).length > 0,
|
|
288
|
+
scan: async (options) => {
|
|
289
|
+
const totals = emptyTotals();
|
|
290
|
+
const errors = [];
|
|
291
|
+
const unpriced = new Set();
|
|
292
|
+
let DatabaseSync;
|
|
293
|
+
try {
|
|
294
|
+
const sqlite = await import("node:sqlite");
|
|
295
|
+
if (typeof sqlite === "object" &&
|
|
296
|
+
sqlite !== null &&
|
|
297
|
+
"DatabaseSync" in sqlite &&
|
|
298
|
+
typeof sqlite.DatabaseSync ===
|
|
299
|
+
"function") {
|
|
300
|
+
DatabaseSync = sqlite.DatabaseSync;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
catch (error) {
|
|
304
|
+
errors.push({
|
|
305
|
+
cliId: CLI_ID,
|
|
306
|
+
filePath: hermesHome(),
|
|
307
|
+
message: `node:sqlite unavailable on this runtime: ${error instanceof Error ? error.message : String(error)}`,
|
|
308
|
+
});
|
|
309
|
+
return { cliId: CLI_ID, totals, filesScanned: 0, errors };
|
|
310
|
+
}
|
|
311
|
+
if (!DatabaseSync) {
|
|
312
|
+
errors.push({
|
|
313
|
+
cliId: CLI_ID,
|
|
314
|
+
filePath: hermesHome(),
|
|
315
|
+
message: "node:sqlite did not expose a callable DatabaseSync — the experimental API has likely changed shape",
|
|
316
|
+
});
|
|
317
|
+
return { cliId: CLI_ID, totals, filesScanned: 0, errors };
|
|
318
|
+
}
|
|
319
|
+
const cutoffMs = resolveScanCutoffMs(options?.sinceDays);
|
|
320
|
+
const cutoffSeconds = cutoffMs === undefined ? undefined : cutoffMs / 1000;
|
|
321
|
+
let filesScanned = 0;
|
|
322
|
+
for (const dbPath of await findStateDatabases()) {
|
|
323
|
+
let db;
|
|
324
|
+
try {
|
|
325
|
+
// Read-only: Hermes may be writing to this file right now.
|
|
326
|
+
db = new DatabaseSync(dbPath, { readOnly: true });
|
|
327
|
+
filesScanned += 1;
|
|
328
|
+
readStore(db, dbPath, cutoffSeconds, totals, unpriced, errors);
|
|
329
|
+
}
|
|
330
|
+
catch (error) {
|
|
331
|
+
errors.push({
|
|
332
|
+
cliId: CLI_ID,
|
|
333
|
+
filePath: dbPath,
|
|
334
|
+
message: error instanceof Error ? error.message : String(error),
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
finally {
|
|
338
|
+
try {
|
|
339
|
+
db?.close();
|
|
340
|
+
}
|
|
341
|
+
catch {
|
|
342
|
+
// Already closed, or never opened.
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
totals.unpricedModels = [...unpriced].sort();
|
|
347
|
+
return { cliId: CLI_ID, totals, filesScanned, errors };
|
|
348
|
+
},
|
|
349
|
+
};
|
|
350
|
+
}
|
|
@@ -123,3 +123,31 @@ registerLocalUsageReader({
|
|
|
123
123
|
return createCursorReader();
|
|
124
124
|
},
|
|
125
125
|
});
|
|
126
|
+
registerLocalUsageReader({
|
|
127
|
+
descriptor: {
|
|
128
|
+
id: "grok",
|
|
129
|
+
displayName: "Grok Build",
|
|
130
|
+
verified: true,
|
|
131
|
+
dedupStrategy: "last-write-wins",
|
|
132
|
+
costConfidence: "unavailable",
|
|
133
|
+
requiresSqlite: false,
|
|
134
|
+
},
|
|
135
|
+
factory: async () => {
|
|
136
|
+
const { createGrokReader } = await import("./grokReader.js");
|
|
137
|
+
return createGrokReader();
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
registerLocalUsageReader({
|
|
141
|
+
descriptor: {
|
|
142
|
+
id: "hermes",
|
|
143
|
+
displayName: "Hermes Agent",
|
|
144
|
+
verified: true,
|
|
145
|
+
dedupStrategy: "last-write-wins",
|
|
146
|
+
costConfidence: "modeled",
|
|
147
|
+
requiresSqlite: true,
|
|
148
|
+
},
|
|
149
|
+
factory: async () => {
|
|
150
|
+
const { createHermesReader } = await import("./hermesReader.js");
|
|
151
|
+
return createHermesReader();
|
|
152
|
+
},
|
|
153
|
+
});
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { ProxyActivitySnapshot, ProxyResponseTrackingObserver } from "../types/index.js";
|
|
2
|
+
export declare function registerProxyResponseObserver(metadata: object, observer: ProxyResponseTrackingObserver): void;
|
|
3
|
+
export declare function takeProxyResponseObservers(metadata: object): ProxyResponseTrackingObserver[];
|
|
2
4
|
/** Track one client-facing proxy request until its response body settles. */
|
|
3
5
|
export declare function beginProxyRequest(): () => void;
|
|
4
6
|
export declare function getProxyActivitySnapshot(): ProxyActivitySnapshot;
|
|
@@ -3,6 +3,24 @@ import { logger } from "../utils/logger.js";
|
|
|
3
3
|
const PROXY_RESPONSE_CANCEL_TIMEOUT_MS = 1_000;
|
|
4
4
|
let activeRequests = 0;
|
|
5
5
|
let lastActivityAtMs = null;
|
|
6
|
+
// Route handlers can attach terminal observers to their request context without
|
|
7
|
+
// wrapping the response body a second time. The HTTP runtime drains every
|
|
8
|
+
// response through one tracker, which fans these observers out at the point
|
|
9
|
+
// where bytes actually leave the proxy.
|
|
10
|
+
const responseObserversByMetadata = new WeakMap();
|
|
11
|
+
export function registerProxyResponseObserver(metadata, observer) {
|
|
12
|
+
const existing = responseObserversByMetadata.get(metadata);
|
|
13
|
+
if (existing) {
|
|
14
|
+
existing.push(observer);
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
responseObserversByMetadata.set(metadata, [observer]);
|
|
18
|
+
}
|
|
19
|
+
export function takeProxyResponseObservers(metadata) {
|
|
20
|
+
const observers = responseObserversByMetadata.get(metadata) ?? [];
|
|
21
|
+
responseObserversByMetadata.delete(metadata);
|
|
22
|
+
return observers;
|
|
23
|
+
}
|
|
6
24
|
function touchActivity() {
|
|
7
25
|
lastActivityAtMs = Date.now();
|
|
8
26
|
}
|
|
@@ -24,7 +24,7 @@ import { loadAccountQuotas, saveAccountQuota, } from "../../proxy/accountQuota.j
|
|
|
24
24
|
import { createCodexUsageTap } from "../../proxy/codexUsage.js";
|
|
25
25
|
import { CODEX_ACCOUNT_PREFIX, parseCodexRateLimitHeaders, } from "../../proxy/codexAccountUsage.js";
|
|
26
26
|
import { buildClientAttribution } from "../../proxy/clientAttribution.js";
|
|
27
|
-
import {
|
|
27
|
+
import { registerProxyResponseObserver } from "../../proxy/proxyActivity.js";
|
|
28
28
|
import { logRequest, logRequestAttempt } from "../../proxy/requestLogger.js";
|
|
29
29
|
import { parseRetryAfterMs } from "../../proxy/routingPolicy.js";
|
|
30
30
|
import { recordAttempt, recordAttemptError, recordFinalError, recordFinalSuccess, } from "../../proxy/usageStats.js";
|
|
@@ -459,7 +459,7 @@ export async function handleCodexResponsesRequest(ctx) {
|
|
|
459
459
|
status: upstream.status,
|
|
460
460
|
headers,
|
|
461
461
|
});
|
|
462
|
-
|
|
462
|
+
registerProxyResponseObserver(ctx.metadata, {
|
|
463
463
|
onTerminal: ({ outcome }) => {
|
|
464
464
|
void usageSeen
|
|
465
465
|
.then((usage) => {
|
|
@@ -491,6 +491,7 @@ export async function handleCodexResponsesRequest(ctx) {
|
|
|
491
491
|
.catch(() => undefined);
|
|
492
492
|
},
|
|
493
493
|
});
|
|
494
|
+
return relay;
|
|
494
495
|
}
|
|
495
496
|
const errText = await upstream.text().catch(() => "");
|
|
496
497
|
// 401/403 → try a forced token refresh once, then rotate.
|
|
@@ -22,7 +22,7 @@ export type LocalUsageCliId = "claude-code" | "codex" | "gemini-cli" | "opencode
|
|
|
22
22
|
* that names it. `usage local --cli copilot-cli` still resolves, normalised
|
|
23
23
|
* to "copilot" at the input boundary.
|
|
24
24
|
*/
|
|
25
|
-
| "copilot-cli" | "cursor" | "
|
|
25
|
+
| "copilot-cli" | "cursor" | "grok" | "hermes" | "amp" | "kiro" | "antigravity";
|
|
26
26
|
/**
|
|
27
27
|
* How much to trust a computed cost figure.
|
|
28
28
|
*
|
|
@@ -242,6 +242,61 @@ export type LocalUsageCopilotUsageRow = {
|
|
|
242
242
|
reasoning_tokens: number | null;
|
|
243
243
|
created_at: string | null;
|
|
244
244
|
};
|
|
245
|
+
/**
|
|
246
|
+
* One usage row as this subsystem reads it out of Hermes Agent's `state.db` —
|
|
247
|
+
* either a `session_model_usage` row, or a `sessions` row projected onto the
|
|
248
|
+
* same columns for a session that predates that table. Every column but the
|
|
249
|
+
* first five may be absent from an older schema and is selected with a
|
|
250
|
+
* default, so they are optional here. `at` is the row's last activity in
|
|
251
|
+
* epoch SECONDS, coalesced from whichever timestamp the schema has.
|
|
252
|
+
*/
|
|
253
|
+
export type LocalUsageHermesUsageRow = {
|
|
254
|
+
session_id: string;
|
|
255
|
+
model: string | null;
|
|
256
|
+
api_call_count: number | null;
|
|
257
|
+
input_tokens: number | null;
|
|
258
|
+
output_tokens: number | null;
|
|
259
|
+
cache_read_tokens?: number | null;
|
|
260
|
+
cache_write_tokens?: number | null;
|
|
261
|
+
reasoning_tokens?: number | null;
|
|
262
|
+
estimated_cost_usd?: number | null;
|
|
263
|
+
actual_cost_usd?: number | null;
|
|
264
|
+
cost_status?: string | null;
|
|
265
|
+
at?: number | null;
|
|
266
|
+
};
|
|
267
|
+
/**
|
|
268
|
+
* The `usage` object on a Grok Build `turn_completed` session update, as
|
|
269
|
+
* appended to a session's `updates.jsonl`. camelCase, from the CLI's own
|
|
270
|
+
* serde definitions and confirmed on a real run. `modelUsage` holds the same
|
|
271
|
+
* shape per model id; `numTurns` is the process ledger's turn counter, which
|
|
272
|
+
* is how a reader tells a cumulative run from a fresh one — see
|
|
273
|
+
* `grokReader.ts`.
|
|
274
|
+
*/
|
|
275
|
+
export type LocalUsageGrokTurnUsage = {
|
|
276
|
+
inputTokens?: number;
|
|
277
|
+
outputTokens?: number;
|
|
278
|
+
cachedReadTokens?: number;
|
|
279
|
+
cacheCreationTokens?: number;
|
|
280
|
+
reasoningTokens?: number;
|
|
281
|
+
modelCalls?: number;
|
|
282
|
+
numTurns?: number;
|
|
283
|
+
modelUsage?: Record<string, unknown>;
|
|
284
|
+
};
|
|
285
|
+
/**
|
|
286
|
+
* One Grok Build completed turn after validation: every count a finite,
|
|
287
|
+
* non-negative safe integer, and the `modelUsage` keys collected. `turns` is
|
|
288
|
+
* the ledger's `numTurns`, which decides whether the next record continues
|
|
289
|
+
* this process run or starts a fresh one — see `grokReader.ts`.
|
|
290
|
+
*/
|
|
291
|
+
export type LocalUsageGrokTurn = {
|
|
292
|
+
input: number;
|
|
293
|
+
output: number;
|
|
294
|
+
cacheRead: number;
|
|
295
|
+
cacheCreation: number;
|
|
296
|
+
calls: number;
|
|
297
|
+
turns: number;
|
|
298
|
+
models: string[];
|
|
299
|
+
};
|
|
245
300
|
/**
|
|
246
301
|
* The slice of `node:sqlite`'s `DatabaseSync` the OpenCode reader uses.
|
|
247
302
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@juspay/neurolink",
|
|
3
|
-
"version": "12.
|
|
3
|
+
"version": "12.9.1",
|
|
4
4
|
"packageManager": "pnpm@10.15.1",
|
|
5
5
|
"description": "TypeScript AI SDK with 24+ LLM providers behind one consistent API. MCP-native (connect any MCP server), voice TTS/STT/realtime, RAG, agents, memory, context compaction. OpenAI · Anthropic · Gemini · Bedrock · Azure · Ollama · DeepSeek · NVIDIA NIM and more.",
|
|
6
6
|
"author": {
|