@juspay/neurolink 12.5.3 → 12.6.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,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
+ }
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { handleSageMakerError, SageMakerError, isRetryableError, getRetryDelay, } from "./errors.js";
8
8
  import { logger } from "../../utils/logger.js";
9
+ import { isAbortError } from "../../utils/errorHandling.js";
9
10
  import { tryImport } from "../../utils/tryImport.js";
10
11
  /**
11
12
  * Lazily load `@aws-sdk/client-sagemaker-runtime`.
@@ -114,7 +115,13 @@ export class SageMakerRuntimeClient {
114
115
  };
115
116
  const command = new InvokeEndpointCommand(input);
116
117
  const client = await this.getClient();
117
- const response = (await this.executeWithRetry(() => client.send(command), params.EndpointName));
118
+ const response = (await this.executeWithRetry(
119
+ // The signal goes to the transport, not just to whatever loop is
120
+ // above us: without it an aborted call keeps its HTTP request in
121
+ // flight until the endpoint answers. An AbortError matches none of
122
+ // RETRYABLE_ERROR_CONDITIONS, so executeWithRetry surfaces it rather
123
+ // than re-issuing a request the caller has already abandoned.
124
+ () => client.send(command, { abortSignal: params.abortSignal }), params.EndpointName));
118
125
  const duration = Date.now() - startTime;
119
126
  logger.debug("SageMaker endpoint invocation successful", {
120
127
  endpointName: params.EndpointName,
@@ -130,6 +137,15 @@ export class SageMakerRuntimeClient {
130
137
  };
131
138
  }
132
139
  catch (error) {
140
+ // The signal now reaches the transport, so this catch sees real
141
+ // AbortErrors for the first time — and must not wrap them.
142
+ // SageMakerError's constructor overwrites `.name`, and its generic
143
+ // fallback stamps `statusCode: 500`, which the retry classifier reads as
144
+ // "transient, try again". That fabricated status is what turned a
145
+ // cancellation into three attempts and 22 seconds.
146
+ if (isAbortError(error)) {
147
+ throw error;
148
+ }
133
149
  const duration = Date.now() - startTime;
134
150
  logger.error("SageMaker endpoint invocation failed", {
135
151
  endpointName: params.EndpointName,
@@ -169,7 +185,10 @@ export class SageMakerRuntimeClient {
169
185
  };
170
186
  const command = new InvokeEndpointWithResponseStreamCommand(input);
171
187
  const client = await this.getClient();
172
- const response = (await this.executeWithRetry(() => client.send(command), params.EndpointName));
188
+ const response = (await this.executeWithRetry(
189
+ // As above — an abort must tear down the response stream's connection,
190
+ // not merely stop the consumer reading from it.
191
+ () => client.send(command, { abortSignal: params.abortSignal }), params.EndpointName));
173
192
  logger.debug("SageMaker streaming invocation started", {
174
193
  endpointName: params.EndpointName,
175
194
  setupDuration: Date.now() - startTime,
@@ -192,6 +211,15 @@ export class SageMakerRuntimeClient {
192
211
  };
193
212
  }
194
213
  catch (error) {
214
+ // The signal now reaches the transport, so the streaming catch sees real
215
+ // AbortErrors for the first time — and must not wrap them.
216
+ // SageMakerError's constructor overwrites `.name`, and its generic
217
+ // fallback stamps `statusCode: 500`, which the retry classifier reads as
218
+ // "transient, try again". That fabricated status is what turned a
219
+ // cancellation into three attempts and 22 seconds.
220
+ if (isAbortError(error)) {
221
+ throw error;
222
+ }
195
223
  const duration = Date.now() - startTime;
196
224
  logger.error("SageMaker streaming invocation failed", {
197
225
  endpointName: params.EndpointName,
@@ -34,6 +34,15 @@ export declare class SageMakerLanguageModel implements SageMakerAsLanguageModel
34
34
  private config;
35
35
  private modelConfig;
36
36
  constructor(modelId: string, config: SageMakerConfig, modelConfig: SageMakerModelConfig);
37
+ /**
38
+ * Read the caller's abort signal out of the AI SDK's call options.
39
+ *
40
+ * `doGenerate`/`doStream` type `options` as `Record<string, unknown>`, so the
41
+ * value arrives as `unknown` even though `LanguageModelV2CallOptions`
42
+ * declares `abortSignal?: AbortSignal`. `instanceof` is a real runtime check
43
+ * rather than an assertion, which is what rule 14 asks for here.
44
+ */
45
+ private readAbortSignal;
37
46
  /**
38
47
  * Generate text synchronously using SageMaker endpoint
39
48
  */
@@ -10,6 +10,7 @@ import { handleSageMakerError } from "./errors.js";
10
10
  import { estimateTokenUsage, createSageMakerStream, parseUsageFromResponseBody, } from "./streaming.js";
11
11
  import { createAdaptiveSemaphore } from "./adaptive-semaphore.js";
12
12
  import { logger } from "../../utils/logger.js";
13
+ import { isAbortError } from "../../utils/errorHandling.js";
13
14
  /**
14
15
  * Base synthetic streaming delay in milliseconds for simulating real-time response
15
16
  * Can be configured via SAGEMAKER_BASE_STREAMING_DELAY_MS environment variable
@@ -113,6 +114,18 @@ export class SageMakerLanguageModel {
113
114
  specificationVersion: this.specificationVersion,
114
115
  });
115
116
  }
117
+ /**
118
+ * Read the caller's abort signal out of the AI SDK's call options.
119
+ *
120
+ * `doGenerate`/`doStream` type `options` as `Record<string, unknown>`, so the
121
+ * value arrives as `unknown` even though `LanguageModelV2CallOptions`
122
+ * declares `abortSignal?: AbortSignal`. `instanceof` is a real runtime check
123
+ * rather than an assertion, which is what rule 14 asks for here.
124
+ */
125
+ readAbortSignal(options) {
126
+ const signal = options.abortSignal;
127
+ return signal instanceof AbortSignal ? signal : undefined;
128
+ }
116
129
  /**
117
130
  * Generate text synchronously using SageMaker endpoint
118
131
  */
@@ -134,6 +147,7 @@ export class SageMakerLanguageModel {
134
147
  Body: JSON.stringify(sagemakerRequest),
135
148
  ContentType: "application/json",
136
149
  Accept: "application/json",
150
+ abortSignal: this.readAbortSignal(options),
137
151
  });
138
152
  // Parse SageMaker response
139
153
  const responseBody = JSON.parse(new TextDecoder().decode(response.Body));
@@ -223,6 +237,11 @@ export class SageMakerLanguageModel {
223
237
  duration,
224
238
  error: error instanceof Error ? error.message : String(error),
225
239
  });
240
+ // An abort is not a provider failure: wrapping it here would restore
241
+ // the fabricated 500 the client guard just avoided.
242
+ if (isAbortError(error)) {
243
+ throw error;
244
+ }
226
245
  throw handleSageMakerError(error, this.modelConfig.endpointName);
227
246
  }
228
247
  }
@@ -260,6 +279,7 @@ export class SageMakerLanguageModel {
260
279
  Body: JSON.stringify(requestWithStreaming),
261
280
  ContentType: this.modelConfig.contentType || "application/json",
262
281
  Accept: this.modelConfig.accept || "application/json",
282
+ abortSignal: this.readAbortSignal(options),
263
283
  });
264
284
  // Create intelligent streaming response
265
285
  const stream = await createSageMakerStream(response.Body, this.modelConfig.endpointName, this.config, {
@@ -298,6 +318,13 @@ export class SageMakerLanguageModel {
298
318
  };
299
319
  }
300
320
  catch (streamingError) {
321
+ // A cancelled turn is not a missing capability. This fallback exists
322
+ // for endpoints that cannot stream; re-issuing doGenerate() here with
323
+ // the same already-aborted signal only adds a wasted call and a
324
+ // misleading warning before the abort surfaces anyway.
325
+ if (isAbortError(streamingError)) {
326
+ throw streamingError;
327
+ }
301
328
  logger.warn("Streaming failed, falling back to non-streaming", {
302
329
  endpointName: this.modelConfig.endpointName,
303
330
  error: streamingError instanceof Error
@@ -351,6 +378,11 @@ export class SageMakerLanguageModel {
351
378
  logger.error("SageMaker doStream failed", {
352
379
  error: error instanceof Error ? error.message : String(error),
353
380
  });
381
+ // An abort is not a provider failure: wrapping it here would restore
382
+ // the fabricated 500 the client guard just avoided.
383
+ if (isAbortError(error)) {
384
+ throw error;
385
+ }
354
386
  throw handleSageMakerError(error, this.modelConfig.endpointName);
355
387
  }
356
388
  }
@@ -681,6 +713,11 @@ export class SageMakerLanguageModel {
681
713
  error: error instanceof Error ? error.message : String(error),
682
714
  batchSize: prompts.length,
683
715
  });
716
+ // An abort is not a provider failure: wrapping it here would restore
717
+ // the fabricated 500 the client guard just avoided.
718
+ if (isAbortError(error)) {
719
+ throw error;
720
+ }
684
721
  throw handleSageMakerError(error, this.modelConfig.endpointName);
685
722
  }
686
723
  }
@@ -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
  *
@@ -1282,6 +1282,16 @@ export type InvokeEndpointParams = {
1282
1282
  TargetVariant?: string;
1283
1283
  /** Inference ID for request tracking */
1284
1284
  InferenceId?: string;
1285
+ /**
1286
+ * Cancels the in-flight HTTP request, not just the loop around it.
1287
+ *
1288
+ * Named in camelCase deliberately: every other field here mirrors an AWS
1289
+ * `InvokeEndpointCommandInput` member and keeps its PascalCase, whereas this
1290
+ * one is a transport option handed to `client.send()` as
1291
+ * `@smithy/types` `HttpHandlerOptions` — it is never part of the command
1292
+ * payload, and spelling it differently keeps that boundary visible.
1293
+ */
1294
+ abortSignal?: AbortSignal;
1285
1295
  };
1286
1296
  /**
1287
1297
  * Response from SageMaker endpoint invocation