@juspay/neurolink 12.5.2 → 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.
@@ -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
+ }
@@ -223,7 +223,15 @@ export function createBedrockLoopAdapter(config) {
223
223
  const response = await withInferenceProfileFallback(commandInput.modelId ?? "", config.region, (effectiveModelId) => withTimeout(config.client.send(new ConverseCommand({
224
224
  ...commandInput,
225
225
  modelId: effectiveModelId,
226
- })), STEP_TIMEOUT_MS, new Error("Bedrock API call timed out")));
226
+ }),
227
+ // Hand the signal to the transport, not just to the loop.
228
+ // `abortSignal` is documented public API, and loopEngine
229
+ // already honours it — but only *between* steps. Without this
230
+ // the HTTP request stays in flight after an abort until it
231
+ // answers or STEP_TIMEOUT_MS (120s) elapses, because nothing
232
+ // ever told the socket. `@smithy/types` HttpHandlerOptions
233
+ // takes the same AbortSignal the adapter is already given.
234
+ { abortSignal: signal }), STEP_TIMEOUT_MS, new Error("Bedrock API call timed out")));
227
235
  if (!response.output?.message) {
228
236
  throw new Error("Invalid response structure from Bedrock API");
229
237
  }
@@ -232,7 +240,11 @@ export function createBedrockLoopAdapter(config) {
232
240
  const response = await withInferenceProfileFallback(commandInput.modelId ?? "", config.region, (effectiveModelId) => withTimeout(config.client.send(new ConverseStreamCommand({
233
241
  ...commandInput,
234
242
  modelId: effectiveModelId,
235
- })), STEP_TIMEOUT_MS, new Error("Bedrock streaming API call timed out")));
243
+ }),
244
+ // As above: the streaming send needs the signal too, so an
245
+ // abort tears down the eventstream connection rather than
246
+ // leaving it open for the rest of the step budget.
247
+ { abortSignal: signal }), STEP_TIMEOUT_MS, new Error("Bedrock streaming API call timed out")));
236
248
  return readStreamedStep(response, channel, signal);
237
249
  },
238
250
  buildToolResultMessages(conversation, stepResult, toolResults) {
@@ -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
- /** Longest prefix wins, so a more specific match cannot be shadowed. */
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
- // Verified against this machine's proxy logs.
27
+ // "claude-cli/2.1.251 (external, cli)"
22
28
  ["claude-cli/", "claude-code"],
23
- // Verified: the AI SDK's own UA, used by NeuroLink's SDK callers.
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-cli" | "cursor" | "amp" | "hermes" | "kiro" | "antigravity" | "grok";
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.5.2",
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": {