@juspay/neurolink 11.20.0 → 11.21.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.
@@ -52,3 +52,17 @@ registerLocalUsageReader({
52
52
  return createCodexReader();
53
53
  },
54
54
  });
55
+ registerLocalUsageReader({
56
+ descriptor: {
57
+ id: "opencode",
58
+ displayName: "OpenCode",
59
+ verified: true,
60
+ dedupStrategy: "rowid-high-water-mark",
61
+ costConfidence: "unavailable",
62
+ requiresSqlite: true,
63
+ },
64
+ factory: async () => {
65
+ const { createOpenCodeReader } = await import("./openCodeReader.js");
66
+ return createOpenCodeReader();
67
+ },
68
+ });
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Reads token usage out of OpenCode's local SQLite store.
3
+ *
4
+ * Store: `~/.local/share/opencode/opencode.db` (NOT `~/.config/opencode`,
5
+ * which holds only configuration). Usage lives on the `message` table, whose
6
+ * `data` column is a JSON blob carrying `role`, `modelID`, `providerID`,
7
+ * `cost` and a `tokens` object.
8
+ *
9
+ * Two things the real data settled, both of which would corrupt totals
10
+ * silently if assumed instead of checked.
11
+ *
12
+ * **Cache is DISJOINT from input here.** Measured across all 4,674
13
+ * usage-bearing messages on a reference machine, `total` equals
14
+ * `input + output + cache.read + cache.write` — never `input + output` alone
15
+ * unless cache happened to be zero. This is the opposite of Codex, where
16
+ * `cached_input_tokens` is a SUBSET of `input_tokens` and has to be subtracted
17
+ * back out. Three readers now, three conventions; none of them is safe to
18
+ * infer from the others.
19
+ *
20
+ * **OpenCode's own `cost` field is not usable.** It was 0 in all 4,674
21
+ * messages, while the provider mix spanned `github-copilot` (a subscription),
22
+ * `openai` (metered) and `neurolink` (this proxy). A single cost figure for
23
+ * that mixture would be wrong whichever way it was computed, so tokens are
24
+ * reported and cost is declared `unavailable` rather than invented or
25
+ * quietly zeroed.
26
+ *
27
+ * Worth knowing for anyone summing sources: traffic OpenCode sent through the
28
+ * NeuroLink proxy appears under `providerID: "neurolink"` here AND in the
29
+ * proxy's own ledger. The two are independent measurements of the same
30
+ * requests, not additive. On the reference machine that was 41 messages and
31
+ * ~772k tokens against 458M total, so it is small — but it is not zero, and a
32
+ * dashboard adding local usage to proxy usage double-counts exactly that
33
+ * slice.
34
+ */
35
+ import type { LocalUsageReader } from "../types/index.js";
36
+ export declare function createOpenCodeReader(): Promise<LocalUsageReader>;
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Reads token usage out of OpenCode's local SQLite store.
3
+ *
4
+ * Store: `~/.local/share/opencode/opencode.db` (NOT `~/.config/opencode`,
5
+ * which holds only configuration). Usage lives on the `message` table, whose
6
+ * `data` column is a JSON blob carrying `role`, `modelID`, `providerID`,
7
+ * `cost` and a `tokens` object.
8
+ *
9
+ * Two things the real data settled, both of which would corrupt totals
10
+ * silently if assumed instead of checked.
11
+ *
12
+ * **Cache is DISJOINT from input here.** Measured across all 4,674
13
+ * usage-bearing messages on a reference machine, `total` equals
14
+ * `input + output + cache.read + cache.write` — never `input + output` alone
15
+ * unless cache happened to be zero. This is the opposite of Codex, where
16
+ * `cached_input_tokens` is a SUBSET of `input_tokens` and has to be subtracted
17
+ * back out. Three readers now, three conventions; none of them is safe to
18
+ * infer from the others.
19
+ *
20
+ * **OpenCode's own `cost` field is not usable.** It was 0 in all 4,674
21
+ * messages, while the provider mix spanned `github-copilot` (a subscription),
22
+ * `openai` (metered) and `neurolink` (this proxy). A single cost figure for
23
+ * that mixture would be wrong whichever way it was computed, so tokens are
24
+ * reported and cost is declared `unavailable` rather than invented or
25
+ * quietly zeroed.
26
+ *
27
+ * Worth knowing for anyone summing sources: traffic OpenCode sent through the
28
+ * NeuroLink proxy appears under `providerID: "neurolink"` here AND in the
29
+ * proxy's own ledger. The two are independent measurements of the same
30
+ * requests, not additive. On the reference machine that was 41 messages and
31
+ * ~772k tokens against 458M total, so it is small — but it is not zero, and a
32
+ * dashboard adding local usage to proxy usage double-counts exactly that
33
+ * slice.
34
+ */
35
+ import { stat } from "fs/promises";
36
+ import { homedir } from "os";
37
+ import { join } from "path";
38
+ const CLI_ID = "opencode";
39
+ const DEFAULT_SINCE_DAYS = 30;
40
+ function databasePath() {
41
+ return join(homedir(), ".local", "share", "opencode", "opencode.db");
42
+ }
43
+ function emptyTotals() {
44
+ return {
45
+ requests: 0,
46
+ inputTokens: 0,
47
+ outputTokens: 0,
48
+ cacheReadTokens: 0,
49
+ cacheCreationTokens: 0,
50
+ costUsd: 0,
51
+ costConfidence: "unavailable",
52
+ unpricedRequests: 0,
53
+ unpricedModels: [],
54
+ };
55
+ }
56
+ function num(value) {
57
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
58
+ }
59
+ export async function createOpenCodeReader() {
60
+ return {
61
+ descriptor: {
62
+ id: CLI_ID,
63
+ displayName: "OpenCode",
64
+ verified: true,
65
+ dedupStrategy: "rowid-high-water-mark",
66
+ costConfidence: "unavailable",
67
+ requiresSqlite: true,
68
+ },
69
+ detect: async () => {
70
+ try {
71
+ const info = await stat(databasePath());
72
+ return info.isFile();
73
+ }
74
+ catch {
75
+ return false;
76
+ }
77
+ },
78
+ scan: async (options) => {
79
+ const totals = emptyTotals();
80
+ const errors = [];
81
+ const models = new Set();
82
+ const dbPath = databasePath();
83
+ // `node:sqlite` is built in from Node 22 but still flagged experimental,
84
+ // so it can be absent or change shape. Imported lazily and behind a
85
+ // try/catch: a runtime without it must degrade to a reported failure for
86
+ // this one reader, not take down a scan of all the others.
87
+ let DatabaseSync;
88
+ try {
89
+ const sqlite = await import("node:sqlite");
90
+ // Validated, not asserted. The module is experimental and may change
91
+ // shape between Node releases; a cast would let a changed export sail
92
+ // through and fail later as an unrelated TypeError deep in the scan.
93
+ if (typeof sqlite === "object" &&
94
+ sqlite !== null &&
95
+ "DatabaseSync" in sqlite &&
96
+ typeof sqlite.DatabaseSync ===
97
+ "function") {
98
+ DatabaseSync = sqlite.DatabaseSync;
99
+ }
100
+ }
101
+ catch (error) {
102
+ errors.push({
103
+ cliId: CLI_ID,
104
+ filePath: dbPath,
105
+ message: `node:sqlite unavailable on this runtime: ${error instanceof Error ? error.message : String(error)}`,
106
+ });
107
+ return { cliId: CLI_ID, totals, filesScanned: 0, errors };
108
+ }
109
+ if (!DatabaseSync) {
110
+ errors.push({
111
+ cliId: CLI_ID,
112
+ filePath: dbPath,
113
+ message: "node:sqlite did not expose a callable DatabaseSync — the experimental API has likely changed shape",
114
+ });
115
+ return { cliId: CLI_ID, totals, filesScanned: 0, errors };
116
+ }
117
+ const sinceDays = options?.sinceDays ?? DEFAULT_SINCE_DAYS;
118
+ const cutoffMs = Number.isFinite(sinceDays) && sinceDays > 0
119
+ ? Date.now() - sinceDays * 86_400_000
120
+ : 0;
121
+ let db;
122
+ try {
123
+ // Read-only: this is the user's live store and OpenCode may be running.
124
+ db = new DatabaseSync(dbPath, { readOnly: true });
125
+ // Filtered in SQL rather than in JS. `time_created` is epoch ms, and
126
+ // the table holds thousands of rows whose `data` blobs are large — the
127
+ // point of the time window is not reading them at all.
128
+ const rows = db
129
+ .prepare(`SELECT data FROM message WHERE time_created >= ${cutoffMs}`)
130
+ .all();
131
+ for (const row of rows) {
132
+ if (typeof row.data !== "string") {
133
+ continue;
134
+ }
135
+ let parsed;
136
+ try {
137
+ parsed = JSON.parse(row.data);
138
+ }
139
+ catch {
140
+ continue;
141
+ }
142
+ const message = parsed;
143
+ const tokens = message.tokens;
144
+ if (!tokens || message.role !== "assistant") {
145
+ continue;
146
+ }
147
+ const input = num(tokens.input);
148
+ const output = num(tokens.output);
149
+ const cacheRead = num(tokens.cache?.read);
150
+ const cacheWrite = num(tokens.cache?.write);
151
+ if (input === 0 &&
152
+ output === 0 &&
153
+ cacheRead === 0 &&
154
+ cacheWrite === 0) {
155
+ continue;
156
+ }
157
+ totals.requests += 1;
158
+ // Added as-is: cache is disjoint from input in this store. See the
159
+ // note on this module — Codex is the other way round.
160
+ totals.inputTokens += input;
161
+ totals.outputTokens += output;
162
+ totals.cacheReadTokens += cacheRead;
163
+ totals.cacheCreationTokens += cacheWrite;
164
+ if (message.modelID) {
165
+ models.add(message.modelID);
166
+ }
167
+ }
168
+ }
169
+ catch (error) {
170
+ errors.push({
171
+ cliId: CLI_ID,
172
+ filePath: dbPath,
173
+ message: error instanceof Error ? error.message : String(error),
174
+ });
175
+ }
176
+ finally {
177
+ try {
178
+ db?.close();
179
+ }
180
+ catch {
181
+ // Closing a database that failed to open is not a second failure.
182
+ }
183
+ }
184
+ // Unpriced by construction rather than by a failed lookup — see the note
185
+ // on this module about the mixed subscription/metered provider set.
186
+ totals.unpricedRequests = totals.requests;
187
+ totals.unpricedModels = [...models].sort();
188
+ return {
189
+ cliId: CLI_ID,
190
+ totals,
191
+ filesScanned: totals.requests > 0 || errors.length === 0 ? 1 : 0,
192
+ errors,
193
+ };
194
+ },
195
+ };
196
+ }
@@ -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
  }
@@ -151,3 +151,21 @@ export type LocalUsageCodexSessionRollup = {
151
151
  /** token_count events where the cumulative total actually advanced. */
152
152
  billableEvents: number;
153
153
  };
154
+ /**
155
+ * The slice of `node:sqlite`'s `DatabaseSync` the OpenCode reader uses.
156
+ *
157
+ * Deliberately minimal. `node:sqlite` is still flagged experimental and may
158
+ * change shape between Node releases, so the reader validates this much at
159
+ * runtime rather than trusting a type assertion — naming only what is actually
160
+ * called keeps that check small and honest.
161
+ */
162
+ export type LocalUsageSqliteDatabase = {
163
+ prepare: (sql: string) => {
164
+ all: () => unknown[];
165
+ };
166
+ close: () => void;
167
+ };
168
+ /** Constructor shape for the same. */
169
+ export type LocalUsageSqliteDatabaseCtor = new (path: string, options?: {
170
+ readOnly?: boolean;
171
+ }) => LocalUsageSqliteDatabase;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juspay/neurolink",
3
- "version": "11.20.0",
3
+ "version": "11.21.0",
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": {