@juspay/neurolink 11.19.0 → 11.20.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.
@@ -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
+ }
@@ -38,3 +38,17 @@ registerLocalUsageReader({
38
38
  return createClaudeCodeReader();
39
39
  },
40
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
+ });
@@ -187,6 +187,20 @@ declare function executeClaudeFallbackTranslation(args: {
187
187
  }) => void;
188
188
  options: Parameters<ServerContext["neurolink"]["stream"]>[0];
189
189
  providerLabel: string;
190
+ /**
191
+ * Idle timeout for the fallback stream, in ms. Defaults to
192
+ * FALLBACK_STREAM_IDLE_TIMEOUT_MS.
193
+ *
194
+ * Injectable purely so a test can drive the timeout path in milliseconds
195
+ * instead of two minutes. The alternative the coverage used before was
196
+ * patching `globalThis.setTimeout` to fire every timer at 0ms for the
197
+ * duration of an await — which rewrites the delay of ANY timer created in
198
+ * that window, not just this one's, inside a 280-case suite sharing a single
199
+ * process. That is not a hypothetical: an attempt to measure the patched
200
+ * case with its own `setTimeout`-based watchdog had the watchdog rewritten
201
+ * out from under it and reported an instant false hang.
202
+ */
203
+ idleTimeoutMs?: number;
190
204
  }): Promise<unknown>;
191
205
  declare function executeClaudeFallbackWithRetry(args: Parameters<typeof executeClaudeFallbackTranslation>[0]): Promise<unknown>;
192
206
  declare function buildClaudeAnthropicFailureResponse(args: {
@@ -2690,14 +2690,14 @@ async function loadClaudeProxyAccounts(args) {
2690
2690
  };
2691
2691
  }
2692
2692
  async function executeClaudeFallbackTranslation(args) {
2693
- const { ctx, body, tracer, requestStartTime, logProxyBody, logFinalRequest, options, providerLabel, } = args;
2693
+ const { ctx, body, tracer, requestStartTime, logProxyBody, logFinalRequest, options, providerLabel, idleTimeoutMs = FALLBACK_STREAM_IDLE_TIMEOUT_MS, } = args;
2694
2694
  const fallbackAbortController = new AbortController();
2695
2695
  let streamResult;
2696
2696
  try {
2697
2697
  streamResult = await withTimeout(ctx.neurolink.stream({
2698
2698
  ...options,
2699
2699
  abortSignal: fallbackAbortController.signal,
2700
- }), FALLBACK_STREAM_IDLE_TIMEOUT_MS, `Fallback ${providerLabel} initialization timed out after ${FALLBACK_STREAM_IDLE_TIMEOUT_MS}ms`);
2700
+ }), idleTimeoutMs, `Fallback ${providerLabel} initialization timed out after ${idleTimeoutMs}ms`);
2701
2701
  }
2702
2702
  catch (error) {
2703
2703
  fallbackAbortController.abort(error);
@@ -2708,7 +2708,7 @@ async function executeClaudeFallbackTranslation(args) {
2708
2708
  let collectedText = "";
2709
2709
  try {
2710
2710
  while (true) {
2711
- const { value: chunk, done } = await withTimeout(iterator.next(), FALLBACK_STREAM_IDLE_TIMEOUT_MS, `Fallback ${providerLabel} stream timed out after ${FALLBACK_STREAM_IDLE_TIMEOUT_MS}ms of inactivity`);
2711
+ const { value: chunk, done } = await withTimeout(iterator.next(), idleTimeoutMs, `Fallback ${providerLabel} stream timed out after ${idleTimeoutMs}ms of inactivity`);
2712
2712
  if (done) {
2713
2713
  return collectedText;
2714
2714
  }
@@ -137,3 +137,17 @@ export type LocalUsageClaudeRawUsage = {
137
137
  cache_read_input_tokens?: number;
138
138
  cache_creation_input_tokens?: number;
139
139
  };
140
+ /**
141
+ * One Codex rollout reduced to its session-level totals.
142
+ *
143
+ * The token figures here are the session's CUMULATIVE counter, not a sum of
144
+ * per-turn values — see `codexReader.ts` for why summing overstates by ~63%.
145
+ */
146
+ export type LocalUsageCodexSessionRollup = {
147
+ model?: string;
148
+ input: number;
149
+ output: number;
150
+ cached: number;
151
+ /** token_count events where the cumulative total actually advanced. */
152
+ billableEvents: number;
153
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juspay/neurolink",
3
- "version": "11.19.0",
3
+ "version": "11.20.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": {