@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.
- package/CHANGELOG.md +3 -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/gemini.js +55 -13
- 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/providers/sagemaker/client.js +30 -2
- package/dist/providers/sagemaker/language-model.d.ts +9 -0
- package/dist/providers/sagemaker/language-model.js +37 -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/providers.d.ts +10 -0
- 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
|
+
}
|
|
@@ -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(
|
|
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(
|
|
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
|
-
/**
|
|
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
|
*
|
|
@@ -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
|