@juspay/neurolink 12.5.3 → 12.6.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 +7 -2
- package/dist/browser/neurolink.min.js +397 -397
- package/dist/cli/commands/proxy.js +9 -0
- package/dist/cli/commands/usage.js +15 -1
- package/dist/cli/proxy-clients/copilot.d.ts +3 -0
- package/dist/cli/proxy-clients/copilot.js +39 -0
- package/dist/cli/proxy-clients/registry.js +24 -1
- package/dist/constants/proxyModels.d.ts +14 -0
- package/dist/constants/proxyModels.js +14 -0
- package/dist/localUsage/copilotCliReader.d.ts +52 -0
- package/dist/localUsage/copilotCliReader.js +200 -0
- package/dist/localUsage/geminiCliReader.d.ts +73 -0
- package/dist/localUsage/geminiCliReader.js +319 -0
- package/dist/localUsage/index.js +3 -0
- package/dist/localUsage/localUsageReaderRegistry.js +42 -0
- package/dist/localUsage/qwenCodeReader.d.ts +52 -0
- package/dist/localUsage/qwenCodeReader.js +279 -0
- package/dist/proxy/clientAttribution.js +39 -3
- package/dist/proxy/proxyTranslationEngine.js +8 -0
- package/dist/types/localUsage.d.ts +72 -1
- package/dist/types/proxyClient.d.ts +17 -0
- package/dist/utils/providerRetry.js +44 -1
- package/package.json +1 -1
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads token usage out of Qwen Code's own session transcripts.
|
|
3
|
+
*
|
|
4
|
+
* Layout, confirmed on a real machine: `~/.qwen/projects/<sanitized-cwd>/chats/
|
|
5
|
+
* <sessionId>.jsonl`, where the sanitized cwd is the absolute working-directory
|
|
6
|
+
* path with every `/` replaced by `-` — the same convention Claude Code uses
|
|
7
|
+
* for its own `~/.claude/projects` layout, which is unsurprising since Qwen
|
|
8
|
+
* Code's chat-recording model traces back to the same lineage as Gemini CLI's.
|
|
9
|
+
* `~/.qwen/projects/<slug>/` also holds a sibling `memory/` directory with a
|
|
10
|
+
* `MEMORY.md` file and no JSONL, so the walk below is scoped to `chats/`
|
|
11
|
+
* specifically rather than the whole project directory.
|
|
12
|
+
*
|
|
13
|
+
* The bundled CLI (`@qwen-code/qwen-code`, installed copy inspected directly)
|
|
14
|
+
* references `parentSessionId` the same way Gemini CLI's engine does for
|
|
15
|
+
* subagent transcripts, so this reader recurses under each project's `chats/`
|
|
16
|
+
* directory at any depth rather than globbing one level — the same lesson
|
|
17
|
+
* `claudeCodeReader.ts` already paid for.
|
|
18
|
+
*
|
|
19
|
+
* Each line is a flat record with `type`, `uuid`, and (for `type: "assistant"`)
|
|
20
|
+
* a `model` and `usageMetadata` object shaped like Google's GenAI
|
|
21
|
+
* `usageMetadata`: `promptTokenCount`, `candidatesTokenCount`,
|
|
22
|
+
* `thoughtsTokenCount`, `totalTokenCount`, `cachedContentTokenCount`. On a real
|
|
23
|
+
* machine, `totalTokenCount` equalled `promptTokenCount + candidatesTokenCount
|
|
24
|
+
* + thoughtsTokenCount` exactly across all 21 real assistant records sampled —
|
|
25
|
+
* so `thoughtsTokenCount` is additive into output, not a separate bucket.
|
|
26
|
+
*
|
|
27
|
+
* `cachedContentTokenCount` is a SUBSET of `promptTokenCount`, not disjoint —
|
|
28
|
+
* confirmed by reading the installed CLI's own converters rather than
|
|
29
|
+
* inferring from samples, because every real sample on this machine had zero
|
|
30
|
+
* cache tokens. `buildAnthropicUsageMetadata` in
|
|
31
|
+
* `chunks/anthropicContentGenerator-*.js` and the OpenAI-compatible converter
|
|
32
|
+
* in `chunks/chunk-*.js` both derive `cachedContentTokenCount` from the
|
|
33
|
+
* upstream response's own cache-read field and leave `promptTokenCount` as the
|
|
34
|
+
* upstream's full prompt count — the cached portion is never added on top. So
|
|
35
|
+
* `inputTokens` here is `prompt - cached`, matching the convention this
|
|
36
|
+
* subsystem's `LocalUsageTotals` type expects (`inputTokens` +
|
|
37
|
+
* `cacheReadTokens` must sum to the true prompt size without double-counting),
|
|
38
|
+
* the same subtraction `codexReader.ts` does for the same reason.
|
|
39
|
+
*
|
|
40
|
+
* Cost is deliberately `unavailable`. Two independent reasons, either one
|
|
41
|
+
* sufficient on its own: (1) no provider identifier is logged alongside the
|
|
42
|
+
* model name, and Qwen Code is pluggable to arbitrary OpenAI- or
|
|
43
|
+
* Anthropic-compatible backends — on the machine this was written against, the
|
|
44
|
+
* model names logged were `claude-sonnet-4-5` and `claude-haiku-4-5`, i.e. this
|
|
45
|
+
* particular install was routed through a custom/litellm endpoint rather than
|
|
46
|
+
* Alibaba's own Qwen models, so even the model name does not reliably identify
|
|
47
|
+
* a vendor to price against; (2) Qwen Code's own hosted models are commonly
|
|
48
|
+
* used via a subscription/OAuth flow rather than metered API keys, the same
|
|
49
|
+
* situation that keeps Codex's confidence `unavailable`.
|
|
50
|
+
*/
|
|
51
|
+
import { createReadStream } from "fs";
|
|
52
|
+
import { createInterface } from "readline";
|
|
53
|
+
import { readdir, stat } from "fs/promises";
|
|
54
|
+
import { homedir } from "os";
|
|
55
|
+
import { join } from "path";
|
|
56
|
+
import { resolveScanCutoffMs } from "./scanWindow.js";
|
|
57
|
+
const CLI_ID = "qwen-code";
|
|
58
|
+
function projectsRoot() {
|
|
59
|
+
return join(homedir(), ".qwen", "projects");
|
|
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: "unavailable",
|
|
70
|
+
unpricedRequests: 0,
|
|
71
|
+
unpricedModels: [],
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
function num(value) {
|
|
75
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* A scan that cannot read a directory must say so.
|
|
79
|
+
*
|
|
80
|
+
* Swallowing every readdir failure made an unreadable tree indistinguishable
|
|
81
|
+
* from an empty one: the report showed zero usage, no failure entry, and an
|
|
82
|
+
* operator with a permissions problem had nothing to look at. ENOENT stays
|
|
83
|
+
* silent because a missing root legitimately means "nothing recorded yet";
|
|
84
|
+
* anything else is a real failure and is surfaced.
|
|
85
|
+
*/
|
|
86
|
+
function isMissing(error) {
|
|
87
|
+
return (typeof error === "object" &&
|
|
88
|
+
error !== null &&
|
|
89
|
+
error.code === "ENOENT");
|
|
90
|
+
}
|
|
91
|
+
/** Every `chats/` directory, one per project, under the Qwen projects root. */
|
|
92
|
+
async function collectChatsDirs(root, errors) {
|
|
93
|
+
let entries;
|
|
94
|
+
try {
|
|
95
|
+
entries = await readdir(root, { withFileTypes: true });
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
if (!isMissing(error)) {
|
|
99
|
+
errors.push({
|
|
100
|
+
cliId: CLI_ID,
|
|
101
|
+
filePath: root,
|
|
102
|
+
message: error instanceof Error ? error.message : String(error),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return [];
|
|
106
|
+
}
|
|
107
|
+
const dirs = [];
|
|
108
|
+
for (const entry of entries) {
|
|
109
|
+
if (!entry.isDirectory()) {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
const chatsDir = join(root, entry.name, "chats");
|
|
113
|
+
try {
|
|
114
|
+
const info = await stat(chatsDir);
|
|
115
|
+
if (info.isDirectory()) {
|
|
116
|
+
dirs.push(chatsDir);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
// No `chats/` for this project — nothing recorded yet.
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return dirs;
|
|
124
|
+
}
|
|
125
|
+
/** Every `.jsonl` transcript at any depth under a `chats/` directory. */
|
|
126
|
+
async function collectTranscripts(dir, out, errors) {
|
|
127
|
+
let entries;
|
|
128
|
+
try {
|
|
129
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
if (!isMissing(error)) {
|
|
133
|
+
errors.push({
|
|
134
|
+
cliId: CLI_ID,
|
|
135
|
+
filePath: dir,
|
|
136
|
+
message: error instanceof Error ? error.message : String(error),
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
for (const entry of entries) {
|
|
142
|
+
const full = join(dir, entry.name);
|
|
143
|
+
if (entry.isDirectory()) {
|
|
144
|
+
await collectTranscripts(full, out, errors);
|
|
145
|
+
}
|
|
146
|
+
else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
|
|
147
|
+
out.push(full);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Dedup is by `uuid`, keeping the LARGEST total-token count seen for that id
|
|
153
|
+
* — the same "resumed session re-logs a turn" protection `claudeCodeReader.ts`
|
|
154
|
+
* uses, applied here on the off chance a resumed Qwen session does the same.
|
|
155
|
+
* The map is per-file and discarded after, so it stays bounded regardless of
|
|
156
|
+
* how many files a scan covers.
|
|
157
|
+
*/
|
|
158
|
+
async function foldTranscript(filePath, totals, unpriced) {
|
|
159
|
+
const seen = new Map();
|
|
160
|
+
const rl = createInterface({
|
|
161
|
+
input: createReadStream(filePath, { encoding: "utf8" }),
|
|
162
|
+
crlfDelay: Infinity,
|
|
163
|
+
});
|
|
164
|
+
try {
|
|
165
|
+
for await (const line of rl) {
|
|
166
|
+
if (!line || line.charCodeAt(0) !== 123 /* '{' */) {
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
let parsed;
|
|
170
|
+
try {
|
|
171
|
+
parsed = JSON.parse(line);
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
// A transcript being appended to while we read it ends in a partial
|
|
175
|
+
// line. That is normal, not corruption — skip it.
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
const record = parsed;
|
|
179
|
+
if (record.type !== "assistant" || !record.usageMetadata) {
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
const id = record.uuid;
|
|
183
|
+
if (typeof id !== "string" || id.length === 0) {
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
const usage = record.usageMetadata;
|
|
187
|
+
const prompt = num(usage.promptTokenCount);
|
|
188
|
+
const cached = num(usage.cachedContentTokenCount);
|
|
189
|
+
const candidate = {
|
|
190
|
+
model: record.model ?? "unknown",
|
|
191
|
+
input: Math.max(0, prompt - cached),
|
|
192
|
+
output: num(usage.candidatesTokenCount) + num(usage.thoughtsTokenCount),
|
|
193
|
+
cached,
|
|
194
|
+
};
|
|
195
|
+
const existing = seen.get(id);
|
|
196
|
+
// Cached tokens are part of the raw prompt, so a record can carry more
|
|
197
|
+
// total usage and still lose on input+output alone — keep-max would then
|
|
198
|
+
// keep the smaller of two duplicates.
|
|
199
|
+
const candidateTotal = candidate.input + candidate.output + candidate.cached;
|
|
200
|
+
const existingTotal = existing
|
|
201
|
+
? existing.input + existing.output + existing.cached
|
|
202
|
+
: -1;
|
|
203
|
+
if (!existing || candidateTotal > existingTotal) {
|
|
204
|
+
seen.set(id, candidate);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
finally {
|
|
209
|
+
rl.close();
|
|
210
|
+
}
|
|
211
|
+
for (const turn of seen.values()) {
|
|
212
|
+
totals.requests += 1;
|
|
213
|
+
totals.inputTokens += turn.input;
|
|
214
|
+
totals.outputTokens += turn.output;
|
|
215
|
+
totals.cacheReadTokens += turn.cached;
|
|
216
|
+
// Cost is unavailable for this reader (see module header), so every turn
|
|
217
|
+
// is unpriced by construction rather than by a failed lookup.
|
|
218
|
+
totals.unpricedRequests += 1;
|
|
219
|
+
unpriced.add(turn.model);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
export async function createQwenCodeReader() {
|
|
223
|
+
return {
|
|
224
|
+
descriptor: {
|
|
225
|
+
id: CLI_ID,
|
|
226
|
+
displayName: "Qwen Code",
|
|
227
|
+
verified: true,
|
|
228
|
+
dedupStrategy: "message-id-keep-max",
|
|
229
|
+
costConfidence: "unavailable",
|
|
230
|
+
requiresSqlite: false,
|
|
231
|
+
},
|
|
232
|
+
detect: async () => {
|
|
233
|
+
try {
|
|
234
|
+
const info = await stat(projectsRoot());
|
|
235
|
+
return info.isDirectory();
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
scan: async (options) => {
|
|
242
|
+
const totals = emptyTotals();
|
|
243
|
+
const errors = [];
|
|
244
|
+
const unpriced = new Set();
|
|
245
|
+
const chatsDirs = await collectChatsDirs(projectsRoot(), errors);
|
|
246
|
+
const files = [];
|
|
247
|
+
for (const dir of chatsDirs) {
|
|
248
|
+
await collectTranscripts(dir, files, errors);
|
|
249
|
+
}
|
|
250
|
+
// Only Infinity means "no time filter" — see scanWindow.ts. A cutoff of
|
|
251
|
+
// `undefined` reads everything; any finite number is an mtime floor.
|
|
252
|
+
const cutoff = resolveScanCutoffMs(options?.sinceDays);
|
|
253
|
+
let filesScanned = 0;
|
|
254
|
+
for (const file of files) {
|
|
255
|
+
try {
|
|
256
|
+
if (cutoff !== undefined) {
|
|
257
|
+
const info = await stat(file);
|
|
258
|
+
if (info.mtimeMs < cutoff) {
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
await foldTranscript(file, totals, unpriced);
|
|
263
|
+
filesScanned += 1;
|
|
264
|
+
}
|
|
265
|
+
catch (error) {
|
|
266
|
+
// One unreadable transcript is a reportable fact, not a reason to
|
|
267
|
+
// lose the totals from every other file in the scan.
|
|
268
|
+
errors.push({
|
|
269
|
+
cliId: CLI_ID,
|
|
270
|
+
filePath: file,
|
|
271
|
+
message: error instanceof Error ? error.message : String(error),
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
totals.unpricedModels = [...unpriced].sort();
|
|
276
|
+
return { cliId: CLI_ID, totals, filesScanned, errors };
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
}
|
|
@@ -16,13 +16,49 @@
|
|
|
16
16
|
* client is then still attributable by its own User-Agent rather than
|
|
17
17
|
* collapsing into one bucket with every other unknown.
|
|
18
18
|
*/
|
|
19
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* Longest prefix wins, so a more specific match cannot be shadowed.
|
|
21
|
+
*
|
|
22
|
+
* Every entry below was read out of this machine's proxy request log or a
|
|
23
|
+
* header capture, never guessed. The measured string is quoted beside each so
|
|
24
|
+
* a future reader can tell an observation from an assumption.
|
|
25
|
+
*/
|
|
20
26
|
const CLIENT_PREFIXES = [
|
|
21
|
-
//
|
|
27
|
+
// "claude-cli/2.1.251 (external, cli)"
|
|
22
28
|
["claude-cli/", "claude-code"],
|
|
23
|
-
//
|
|
29
|
+
// "ai/4.x" — the AI SDK's own UA, used by NeuroLink's SDK callers.
|
|
24
30
|
["ai/", "sdk"],
|
|
31
|
+
// "opencode/1.3.13 ai-sdk/provider-utils/4.0.21 runtime/bun/1.3.13"
|
|
32
|
+
["opencode/", "opencode"],
|
|
33
|
+
// "QwenCode/0.17.0 (darwin; arm64)"
|
|
34
|
+
["QwenCode/", "qwen-code"],
|
|
35
|
+
// "GeminiCLI-tui/0.53.0/gemini-3.1-pro-preview (darwin; arm64; terminal)"
|
|
36
|
+
["GeminiCLI-", "gemini-cli"],
|
|
37
|
+
// "codex_exec/0.147.0 (Mac OS 26.6.0; arm64)". Only the non-interactive
|
|
38
|
+
// `codex exec` binary has been observed; the interactive TUI may well send a
|
|
39
|
+
// different token, and it is left unmapped until someone measures it rather
|
|
40
|
+
// than pattern-matched on a guess.
|
|
41
|
+
["codex_exec/", "codex"],
|
|
25
42
|
];
|
|
43
|
+
/**
|
|
44
|
+
* Deliberately NOT mapped, with the measurement that ruled each one out.
|
|
45
|
+
*
|
|
46
|
+
* Copilot CLI is the one client here that cannot be identified from its
|
|
47
|
+
* User-Agent, and both of the strings it sends are actively unsafe to key on:
|
|
48
|
+
*
|
|
49
|
+
* - `OpenAI/JS 5.20.1` — the stock OpenAI JS SDK UA, sent by every caller of
|
|
50
|
+
* that SDK. Mapping it to Copilot would file unrelated OpenAI-SDK traffic
|
|
51
|
+
* under Copilot's name, which is worse than leaving it unattributed.
|
|
52
|
+
* - `Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; Trident/5.0)` — a
|
|
53
|
+
* spoofed browser string. It looks like a fingerprint and is not one: it was
|
|
54
|
+
* captured from **both** Copilot CLI and Gemini CLI, so it identifies no
|
|
55
|
+
* client at all. (It also accounts for the largest single block of
|
|
56
|
+
* `unknown` rows in this machine's log, which is what made it tempting.)
|
|
57
|
+
*
|
|
58
|
+
* Copilot does send `x-initiator` and `x-interaction-type`, but neither is
|
|
59
|
+
* exclusive to it either. Attributing it needs a signal nobody has found yet,
|
|
60
|
+
* so it stays `unknown` and remains traceable through the stored raw header.
|
|
61
|
+
*/
|
|
26
62
|
/** Cap stored User-Agents. They are attacker-influenced and unbounded. */
|
|
27
63
|
const MAX_USER_AGENT_CHARS = 200;
|
|
28
64
|
/**
|
|
@@ -16,6 +16,7 @@ import { buildGeminiResponse, createGeminiSerializerAdapter, } from "./geminiFor
|
|
|
16
16
|
import { generateOpenAIToolCallId, OpenAIStreamSerializer, serializeOpenAIResponse, } from "./openaiFormat.js";
|
|
17
17
|
import { DEFAULT_PROXY_MODEL_IDS } from "../constants/proxyModels.js";
|
|
18
18
|
import { logRequest } from "./requestLogger.js";
|
|
19
|
+
import { buildClientAttribution } from "./clientAttribution.js";
|
|
19
20
|
import { recordAttempt, recordAttemptError, recordFinalError, recordFinalSuccess, } from "./usageStats.js";
|
|
20
21
|
import { ErrorCategory, ErrorSeverity } from "../constants/enums.js";
|
|
21
22
|
import { withTimeout } from "../utils/async/withTimeout.js";
|
|
@@ -484,6 +485,11 @@ export async function handleTranslatedStreamRequest(args) {
|
|
|
484
485
|
toolCount: Object.keys(parsed.tools).length,
|
|
485
486
|
account: "translation",
|
|
486
487
|
accountType: "translation",
|
|
488
|
+
// Without this the translated doors record no calling client at all.
|
|
489
|
+
// The Gemini door serves every request through this engine, so its
|
|
490
|
+
// rows carried a null clientApp and a null userAgent — not merely
|
|
491
|
+
// "unknown", but nothing to attribute after the fact either.
|
|
492
|
+
...buildClientAttribution(ctx.headers),
|
|
487
493
|
responseStatus: terminalStatus,
|
|
488
494
|
responseTimeMs: Date.now() - requestStartTime,
|
|
489
495
|
...(terminalErrorType ? { errorType: terminalErrorType } : {}),
|
|
@@ -588,6 +594,7 @@ export async function handleTranslatedJsonRequest(args) {
|
|
|
588
594
|
toolCount: Object.keys(parsed.tools).length,
|
|
589
595
|
account: "translation",
|
|
590
596
|
accountType: "translation",
|
|
597
|
+
...buildClientAttribution(ctx.headers),
|
|
591
598
|
responseStatus: 200,
|
|
592
599
|
responseTimeMs: Date.now() - requestStartTime,
|
|
593
600
|
inputTokens: resolvedUsage.input,
|
|
@@ -631,6 +638,7 @@ export async function handleTranslatedJsonRequest(args) {
|
|
|
631
638
|
toolCount: Object.keys(parsed.tools).length,
|
|
632
639
|
account: "translation",
|
|
633
640
|
accountType: "translation",
|
|
641
|
+
...buildClientAttribution(ctx.headers),
|
|
634
642
|
responseStatus: terminalFailureStatus,
|
|
635
643
|
responseTimeMs: Date.now() - requestStartTime,
|
|
636
644
|
errorType: "generation_error",
|
|
@@ -15,7 +15,14 @@
|
|
|
15
15
|
* Kebab-case, matching `CliProxyClientConfigurator.id`'s convention — a
|
|
16
16
|
* different registry, but the same repo-wide convention for CLI identifiers.
|
|
17
17
|
*/
|
|
18
|
-
export type LocalUsageCliId = "claude-code" | "codex" | "gemini-cli" | "opencode" | "qwen-code" | "copilot
|
|
18
|
+
export type LocalUsageCliId = "claude-code" | "codex" | "gemini-cli" | "opencode" | "qwen-code" | "copilot"
|
|
19
|
+
/**
|
|
20
|
+
* @deprecated Use "copilot". Retained because this literal is already part
|
|
21
|
+
* of the published `LocalUsageCliId`, so dropping it would break any caller
|
|
22
|
+
* that names it. `usage local --cli copilot-cli` still resolves, normalised
|
|
23
|
+
* to "copilot" at the input boundary.
|
|
24
|
+
*/
|
|
25
|
+
| "copilot-cli" | "cursor" | "amp" | "hermes" | "kiro" | "antigravity" | "grok";
|
|
19
26
|
/**
|
|
20
27
|
* How much to trust a computed cost figure.
|
|
21
28
|
*
|
|
@@ -123,6 +130,15 @@ export type LocalUsageAggregateReport = {
|
|
|
123
130
|
totals: Partial<Record<LocalUsageCliId, LocalUsageTotals>>;
|
|
124
131
|
/** CLIs whose reader could not be created, detected, or scanned, and why. */
|
|
125
132
|
failures: LocalUsageReaderFailure[];
|
|
133
|
+
/**
|
|
134
|
+
* Per-file problems from readers that otherwise succeeded.
|
|
135
|
+
*
|
|
136
|
+
* Distinct from `failures`, which is a reader that threw. A scan can read
|
|
137
|
+
* nine of ten transcripts and still be wrong by the tenth; without this the
|
|
138
|
+
* shortfall is invisible and the totals look authoritative. Readers have
|
|
139
|
+
* always collected these — nothing consumed them until now.
|
|
140
|
+
*/
|
|
141
|
+
scanErrors: LocalUsageScanError[];
|
|
126
142
|
/** CLIs with no local store on this machine — absent, not failed. */
|
|
127
143
|
notInstalled: LocalUsageCliId[];
|
|
128
144
|
};
|
|
@@ -151,6 +167,61 @@ export type LocalUsageCodexSessionRollup = {
|
|
|
151
167
|
/** token_count events where the cumulative total actually advanced. */
|
|
152
168
|
billableEvents: number;
|
|
153
169
|
};
|
|
170
|
+
/**
|
|
171
|
+
* The `usageMetadata` object exactly as Qwen Code writes it into a transcript
|
|
172
|
+
* line — Google GenAI's `usageMetadata` shape, camelCase, every field
|
|
173
|
+
* optional because not every assistant record carries one. See
|
|
174
|
+
* `qwenCodeReader.ts` for why `cachedContentTokenCount` is subtracted out of
|
|
175
|
+
* `promptTokenCount` rather than added.
|
|
176
|
+
*/
|
|
177
|
+
export type LocalUsageQwenRawUsage = {
|
|
178
|
+
promptTokenCount?: number;
|
|
179
|
+
candidatesTokenCount?: number;
|
|
180
|
+
thoughtsTokenCount?: number;
|
|
181
|
+
totalTokenCount?: number;
|
|
182
|
+
cachedContentTokenCount?: number;
|
|
183
|
+
};
|
|
184
|
+
/**
|
|
185
|
+
* The `tokens` object exactly as Gemini CLI writes it onto a `type: "gemini"`
|
|
186
|
+
* message record — mapped straight from the GenAI response's own
|
|
187
|
+
* `usageMetadata` by the CLI's own `recordMessageTokens()`. See
|
|
188
|
+
* `geminiCliReader.ts` for why `cached` is subtracted out of `input` rather
|
|
189
|
+
* than added, and why `thoughts`/`tool` fold into output.
|
|
190
|
+
*/
|
|
191
|
+
export type LocalUsageGeminiCliTokens = {
|
|
192
|
+
input?: number;
|
|
193
|
+
output?: number;
|
|
194
|
+
cached?: number;
|
|
195
|
+
thoughts?: number;
|
|
196
|
+
tool?: number;
|
|
197
|
+
total?: number;
|
|
198
|
+
};
|
|
199
|
+
/**
|
|
200
|
+
* A `type: "gemini"` message record as read out of a chat transcript line —
|
|
201
|
+
* whether it arrived bare-appended or unwrapped from a `$set.messages[]`
|
|
202
|
+
* bootstrap entry. See `geminiCliReader.ts` for both shapes.
|
|
203
|
+
*/
|
|
204
|
+
export type LocalUsageGeminiMessageRecord = {
|
|
205
|
+
id?: string;
|
|
206
|
+
type?: string;
|
|
207
|
+
model?: string;
|
|
208
|
+
tokens?: LocalUsageGeminiCliTokens;
|
|
209
|
+
};
|
|
210
|
+
/**
|
|
211
|
+
* One row of Copilot CLI's `assistant_usage_events` SQLite table, restricted
|
|
212
|
+
* to the columns `copilotCliReader.ts` actually reads. `cache_read_tokens`
|
|
213
|
+
* and `cache_write_tokens` are both subsets of `input_tokens` — see that
|
|
214
|
+
* reader's module header for the arithmetic proof.
|
|
215
|
+
*/
|
|
216
|
+
export type LocalUsageCopilotUsageRow = {
|
|
217
|
+
model: string | null;
|
|
218
|
+
input_tokens: number | null;
|
|
219
|
+
output_tokens: number | null;
|
|
220
|
+
cache_read_tokens: number | null;
|
|
221
|
+
cache_write_tokens: number | null;
|
|
222
|
+
reasoning_tokens: number | null;
|
|
223
|
+
created_at: string | null;
|
|
224
|
+
};
|
|
154
225
|
/**
|
|
155
226
|
* The slice of `node:sqlite`'s `DatabaseSync` the OpenCode reader uses.
|
|
156
227
|
*
|
|
@@ -29,6 +29,17 @@ export type CliProxyClientConfigurator = {
|
|
|
29
29
|
* alone and return false.
|
|
30
30
|
*/
|
|
31
31
|
restore: (proxyBaseUrl: string) => Promise<boolean>;
|
|
32
|
+
/**
|
|
33
|
+
* Something the user must still do for apply() to take effect.
|
|
34
|
+
*
|
|
35
|
+
* Writing a file is not the same as being in effect. Copilot reads its
|
|
36
|
+
* provider settings from the environment only, so its configurator writes a
|
|
37
|
+
* script the user has to source; until they do, the proxy reports a green
|
|
38
|
+
* check for a file nothing reads. Returning a string here lets a client say
|
|
39
|
+
* "written, but not yet live, and here is the one line that fixes it".
|
|
40
|
+
* Return null when nothing is outstanding.
|
|
41
|
+
*/
|
|
42
|
+
postApplyNote?: (proxyBaseUrl: string) => Promise<string | null>;
|
|
32
43
|
};
|
|
33
44
|
/** Outcome of applying one configurator, for per-client CLI reporting. */
|
|
34
45
|
export type CliProxyClientApplyResult = {
|
|
@@ -36,6 +47,12 @@ export type CliProxyClientApplyResult = {
|
|
|
36
47
|
displayName: string;
|
|
37
48
|
/** True only when the configurator actually wrote configuration. */
|
|
38
49
|
applied: boolean;
|
|
50
|
+
/**
|
|
51
|
+
* Set when the write landed but is not yet in effect — see
|
|
52
|
+
* CliProxyClientConfigurator.postApplyNote. Callers must render this; a
|
|
53
|
+
* silent note is the failure it exists to prevent.
|
|
54
|
+
*/
|
|
55
|
+
note?: string;
|
|
39
56
|
/** Present when the configurator threw; the caller decides how loud to be. */
|
|
40
57
|
error?: Error;
|
|
41
58
|
};
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*
|
|
14
14
|
* @module utils/providerRetry
|
|
15
15
|
*/
|
|
16
|
-
import { NeuroLinkError } from "./errorHandling.js";
|
|
16
|
+
import { NeuroLinkError, isAbortError } from "./errorHandling.js";
|
|
17
17
|
import { logger } from "./logger.js";
|
|
18
18
|
import { APICallError } from "./generationErrors.js";
|
|
19
19
|
import { parseRetryAfterMs } from "./retryAfter.js";
|
|
@@ -143,7 +143,50 @@ export function isOpenAIQuotaExhaustedError(error) {
|
|
|
143
143
|
}
|
|
144
144
|
return false;
|
|
145
145
|
}
|
|
146
|
+
/**
|
|
147
|
+
* True when `error` is an abort, or wraps one at any depth via `cause`.
|
|
148
|
+
*
|
|
149
|
+
* `isAbortError` alone is not enough here because it inspects one value, and
|
|
150
|
+
* by the time a provider failure reaches the retry wrapper it has usually been
|
|
151
|
+
* re-thrown inside a provider-specific error whose `name` no longer says
|
|
152
|
+
* "AbortError". The depth bound stops a self-referential or maliciously deep
|
|
153
|
+
* `cause` chain from hanging the classifier; nothing legitimate nests further.
|
|
154
|
+
*/
|
|
155
|
+
function isAbortLike(error, depth = 0) {
|
|
156
|
+
if (depth > 8 || error === null || typeof error !== "object") {
|
|
157
|
+
return isAbortError(error);
|
|
158
|
+
}
|
|
159
|
+
if (isAbortError(error)) {
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
const { cause } = error;
|
|
163
|
+
return cause === undefined || cause === error
|
|
164
|
+
? false
|
|
165
|
+
: isAbortLike(cause, depth + 1);
|
|
166
|
+
}
|
|
146
167
|
export function isRetryableProviderError(error) {
|
|
168
|
+
// Ahead of everything, for the same reason as the quota branch below but a
|
|
169
|
+
// worse failure: the caller has already walked away. Retrying a cancelled
|
|
170
|
+
// turn cannot succeed — the signal stays aborted, so every further attempt
|
|
171
|
+
// rejects without reaching the network — and it costs the full ladder,
|
|
172
|
+
// 2 retries at the NO_HINT_FLOOR_MS floor, before the turn ends.
|
|
173
|
+
//
|
|
174
|
+
// Measured on SageMaker before this guard: abort at 400ms, provider failed
|
|
175
|
+
// at 232ms with "Request aborted", then two more attempts 10s apart each
|
|
176
|
+
// failing in 1ms without a request leaving the process, and the turn finally
|
|
177
|
+
// threw at 21,996ms — 55x the time the cancellation should have taken, with
|
|
178
|
+
// `name` by then reading "Error" rather than "AbortError".
|
|
179
|
+
//
|
|
180
|
+
// The cause chain matters and is not defensive padding. Providers wrap
|
|
181
|
+
// transport failures in their own error class on the way up: SageMaker's
|
|
182
|
+
// handleSageMakerError() stamps an unrecognised error `statusCode: 500`
|
|
183
|
+
// while keeping the original as `cause`. That fabricated 500 is what made an
|
|
184
|
+
// abort look retryable here — the duck-typed status branch below reads it
|
|
185
|
+
// and returns true, outranking the wrapper's own `retryable: false`. Reading
|
|
186
|
+
// through `cause` finds the abort whatever wrapped it.
|
|
187
|
+
if (isAbortLike(error)) {
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
147
190
|
// Before every other branch, including the SDK's own flag: a 429 carrying
|
|
148
191
|
// `insufficient_quota` is marked retryable by the AI SDK because it only
|
|
149
192
|
// looks at the status code. Retrying it burns the full ladder — 3 attempts
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@juspay/neurolink",
|
|
3
|
-
"version": "12.
|
|
3
|
+
"version": "12.6.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": {
|