@juspay/neurolink 11.12.0 → 11.13.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.
Files changed (41) hide show
  1. package/CHANGELOG.md +6 -2
  2. package/dist/browser/neurolink.min.js +396 -396
  3. package/dist/cli/commands/proxy.js +42 -0
  4. package/dist/cli/commands/proxyAnalyze.js +10 -1
  5. package/dist/cli/proxy-clients/claudeCode.js +42 -10
  6. package/dist/cli/proxy-clients/openCode.js +37 -15
  7. package/dist/cli/proxy-clients/qwenCode.js +33 -9
  8. package/dist/cli/proxy-clients/registry.js +10 -2
  9. package/dist/cli/proxy-clients/snapshot.d.ts +52 -0
  10. package/dist/cli/proxy-clients/snapshot.js +98 -0
  11. package/dist/lib/providers/googleAiStudio/client.js +6 -3
  12. package/dist/lib/providers/googleVertex/client.js +6 -3
  13. package/dist/lib/proxy/codexUsage.d.ts +68 -0
  14. package/dist/lib/proxy/codexUsage.js +247 -0
  15. package/dist/lib/proxy/proxyAnalysis.js +87 -3
  16. package/dist/lib/proxy/proxyFetch.d.ts +1 -0
  17. package/dist/lib/proxy/proxyFetch.js +29 -0
  18. package/dist/lib/proxy/proxyTracer.d.ts +13 -2
  19. package/dist/lib/proxy/proxyTracer.js +29 -7
  20. package/dist/lib/proxy/proxyTranslationEngine.js +22 -5
  21. package/dist/lib/server/routes/codexProxyRoutes.js +29 -1
  22. package/dist/lib/server/routes/openaiProxyRoutes.js +5 -0
  23. package/dist/lib/types/proxy.d.ts +65 -0
  24. package/dist/lib/utils/pricing.d.ts +9 -0
  25. package/dist/lib/utils/pricing.js +136 -1
  26. package/dist/providers/googleAiStudio/client.js +6 -3
  27. package/dist/providers/googleVertex/client.js +6 -3
  28. package/dist/proxy/codexUsage.d.ts +68 -0
  29. package/dist/proxy/codexUsage.js +246 -0
  30. package/dist/proxy/proxyAnalysis.js +87 -3
  31. package/dist/proxy/proxyFetch.d.ts +1 -0
  32. package/dist/proxy/proxyFetch.js +29 -0
  33. package/dist/proxy/proxyTracer.d.ts +13 -2
  34. package/dist/proxy/proxyTracer.js +29 -7
  35. package/dist/proxy/proxyTranslationEngine.js +22 -5
  36. package/dist/server/routes/codexProxyRoutes.js +29 -1
  37. package/dist/server/routes/openaiProxyRoutes.js +5 -0
  38. package/dist/types/proxy.d.ts +65 -0
  39. package/dist/utils/pricing.d.ts +9 -0
  40. package/dist/utils/pricing.js +136 -1
  41. package/package.json +1 -1
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Codex (OpenAI Responses) SSE usage tap.
3
+ *
4
+ * The Codex proxy engine relays `upstream.body` to the client untouched and
5
+ * logs before a single byte is read, so no Codex request has ever carried token
6
+ * counts. This module adds a pass-through tap that scrapes `usage` out of the
7
+ * stream without holding back or altering any bytes.
8
+ *
9
+ * ## Safety contract
10
+ *
11
+ * This sits in the hot path of a live proxy, so it is built to be incapable of
12
+ * breaking a stream:
13
+ *
14
+ * - every chunk is enqueued **before** it is inspected;
15
+ * - all parsing runs inside try/catch, and a throw is swallowed;
16
+ * - a stream whose shape is unrecognised resolves `usage` to `null`, which is
17
+ * exactly today's behaviour (a log with no token fields).
18
+ *
19
+ * The worst case is therefore "no tokens recorded", never a truncated or
20
+ * corrupted response.
21
+ *
22
+ * ## Wire shape
23
+ *
24
+ * **Verified against real traffic.** Captured from a live `codex exec` run
25
+ * through the proxy on 2026-08-21; the trimmed sample is at
26
+ * `test/fixtures/codex-response-usage.sse` and is asserted against in the
27
+ * codex suite. The real shape is
28
+ *
29
+ * event: response.completed
30
+ * data: {"type":"response.completed","response":{"usage":{
31
+ * "input_tokens":N,"output_tokens":M,
32
+ * "input_tokens_details":{"cached_tokens":K,"cache_write_tokens":W},
33
+ * "output_tokens_details":{"reasoning_tokens":R}}}}
34
+ *
35
+ * Note that `response.created` arrives first carrying `usage: null`, which is
36
+ * why the scanner keeps the last non-null result rather than the first.
37
+ *
38
+ * It also accepts a `usage` object at the top level of any event and the
39
+ * `prompt_tokens`/`completion_tokens` spellings. A `null` result means "not
40
+ * observed", never "zero tokens".
41
+ */
42
+ import type { CodexStreamUsage } from "../types/index.js";
43
+ /**
44
+ * Pull usage out of one parsed SSE `data:` payload.
45
+ *
46
+ * Returns null when the payload carries no recognisable usage object, so the
47
+ * caller can keep the last non-null result rather than overwriting it with a
48
+ * later event that happens not to carry usage.
49
+ */
50
+ export declare function extractCodexUsage(payload: unknown): CodexStreamUsage | null;
51
+ /**
52
+ * Scan a slice of SSE text for usage, returning the last one found.
53
+ *
54
+ * Exported for tests: it is the whole parsing decision, and driving it through
55
+ * a real Codex stream would need a live ChatGPT subscription.
56
+ */
57
+ export declare function scanCodexSSEForUsage(text: string): CodexStreamUsage | null;
58
+ /**
59
+ * A pass-through TransformStream that reports the usage seen on a Codex SSE
60
+ * stream.
61
+ *
62
+ * `usage` resolves when the stream ends: to the last usage observed, or null if
63
+ * none was. It never rejects.
64
+ */
65
+ export declare function createCodexUsageTap(): {
66
+ stream: TransformStream<Uint8Array, Uint8Array>;
67
+ usage: Promise<CodexStreamUsage | null>;
68
+ };
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Codex (OpenAI Responses) SSE usage tap.
3
+ *
4
+ * The Codex proxy engine relays `upstream.body` to the client untouched and
5
+ * logs before a single byte is read, so no Codex request has ever carried token
6
+ * counts. This module adds a pass-through tap that scrapes `usage` out of the
7
+ * stream without holding back or altering any bytes.
8
+ *
9
+ * ## Safety contract
10
+ *
11
+ * This sits in the hot path of a live proxy, so it is built to be incapable of
12
+ * breaking a stream:
13
+ *
14
+ * - every chunk is enqueued **before** it is inspected;
15
+ * - all parsing runs inside try/catch, and a throw is swallowed;
16
+ * - a stream whose shape is unrecognised resolves `usage` to `null`, which is
17
+ * exactly today's behaviour (a log with no token fields).
18
+ *
19
+ * The worst case is therefore "no tokens recorded", never a truncated or
20
+ * corrupted response.
21
+ *
22
+ * ## Wire shape
23
+ *
24
+ * **Verified against real traffic.** Captured from a live `codex exec` run
25
+ * through the proxy on 2026-08-21; the trimmed sample is at
26
+ * `test/fixtures/codex-response-usage.sse` and is asserted against in the
27
+ * codex suite. The real shape is
28
+ *
29
+ * event: response.completed
30
+ * data: {"type":"response.completed","response":{"usage":{
31
+ * "input_tokens":N,"output_tokens":M,
32
+ * "input_tokens_details":{"cached_tokens":K,"cache_write_tokens":W},
33
+ * "output_tokens_details":{"reasoning_tokens":R}}}}
34
+ *
35
+ * Note that `response.created` arrives first carrying `usage: null`, which is
36
+ * why the scanner keeps the last non-null result rather than the first.
37
+ *
38
+ * It also accepts a `usage` object at the top level of any event and the
39
+ * `prompt_tokens`/`completion_tokens` spellings. A `null` result means "not
40
+ * observed", never "zero tokens".
41
+ */
42
+ import { appendFileSync } from "node:fs";
43
+ const nonNegativeInt = (value) => typeof value === "number" && Number.isFinite(value) && value > 0
44
+ ? Math.floor(value)
45
+ : 0;
46
+ /**
47
+ * Pull usage out of one parsed SSE `data:` payload.
48
+ *
49
+ * Returns null when the payload carries no recognisable usage object, so the
50
+ * caller can keep the last non-null result rather than overwriting it with a
51
+ * later event that happens not to carry usage.
52
+ */
53
+ export function extractCodexUsage(payload) {
54
+ if (payload === null || typeof payload !== "object") {
55
+ return null;
56
+ }
57
+ const root = payload;
58
+ const response = root.response;
59
+ const usage = (response && typeof response === "object" && response.usage
60
+ ? response.usage
61
+ : root.usage);
62
+ if (!usage || typeof usage !== "object") {
63
+ return null;
64
+ }
65
+ const input = usage.input_tokens ?? usage.prompt_tokens;
66
+ const output = usage.output_tokens ?? usage.completion_tokens;
67
+ if (input === undefined && output === undefined) {
68
+ return null;
69
+ }
70
+ const inputDetails = usage.input_tokens_details;
71
+ const outputDetails = usage.output_tokens_details;
72
+ return {
73
+ inputTokens: nonNegativeInt(input),
74
+ outputTokens: nonNegativeInt(output),
75
+ cacheReadTokens: nonNegativeInt(inputDetails?.cached_tokens),
76
+ cacheCreationTokens: nonNegativeInt(inputDetails?.cache_write_tokens),
77
+ reasoningTokens: nonNegativeInt(outputDetails?.reasoning_tokens),
78
+ };
79
+ }
80
+ /**
81
+ * Scan a slice of SSE text for usage, returning the last one found.
82
+ *
83
+ * Exported for tests: it is the whole parsing decision, and driving it through
84
+ * a real Codex stream would need a live ChatGPT subscription.
85
+ */
86
+ export function scanCodexSSEForUsage(text) {
87
+ let found = null;
88
+ for (const line of text.split("\n")) {
89
+ if (!line.startsWith("data:")) {
90
+ continue;
91
+ }
92
+ const raw = line.slice(5).trim();
93
+ if (!raw || raw === "[DONE]") {
94
+ continue;
95
+ }
96
+ try {
97
+ const usage = extractCodexUsage(JSON.parse(raw));
98
+ if (usage) {
99
+ found = usage;
100
+ }
101
+ }
102
+ catch {
103
+ // Partial or non-JSON payload — the next chunk may complete it. Never
104
+ // let a malformed line escape into the relay.
105
+ }
106
+ }
107
+ return found;
108
+ }
109
+ /**
110
+ * Maximum bytes written by the opt-in raw capture. One `response.completed`
111
+ * event is a few hundred bytes; 256 KiB is generous and bounds a runaway file.
112
+ */
113
+ const CAPTURE_LIMIT_BYTES = 256 * 1024;
114
+ /**
115
+ * Opt-in raw capture of one Codex SSE stream, for confirming the `usage` wire
116
+ * shape against real traffic.
117
+ *
118
+ * Off unless `NEUROLINK_PROXY_CODEX_CAPTURE` names a file. It is deliberately
119
+ * env-gated and undocumented in the CLI: the captured bytes are the assistant's
120
+ * actual response, so this is a debugging tool the operator turns on
121
+ * deliberately, not something that runs by default. Capture stops at the first
122
+ * completed stream and is capped.
123
+ */
124
+ function createCaptureSink() {
125
+ const target = process.env.NEUROLINK_PROXY_CODEX_CAPTURE;
126
+ if (!target) {
127
+ return null;
128
+ }
129
+ let written = 0;
130
+ let started = false;
131
+ return (chunk) => {
132
+ if (written >= CAPTURE_LIMIT_BYTES) {
133
+ return;
134
+ }
135
+ try {
136
+ // Append only the new bytes. Rewriting the accumulated buffer on every
137
+ // chunk is quadratic in stream length and runs in a live relay's
138
+ // transform(), so a long response would do hundreds of growing
139
+ // synchronous writes.
140
+ // Slice to the remaining capacity rather than writing the whole chunk.
141
+ // The guard above only says the cap was not ALREADY reached, so a single
142
+ // large chunk arriving at 255 KiB would otherwise land in full and the
143
+ // file would end up far past its bound — the cap has to hold per write,
144
+ // not per stream.
145
+ const remaining = CAPTURE_LIMIT_BYTES - written;
146
+ const slice = chunk.byteLength > remaining ? chunk.subarray(0, remaining) : chunk;
147
+ appendFileSync(target, slice, { flag: started ? "a" : "w" });
148
+ started = true;
149
+ written += slice.byteLength;
150
+ }
151
+ catch {
152
+ // Capture is best-effort telemetry; never let it touch the relay.
153
+ }
154
+ };
155
+ }
156
+ /**
157
+ * A pass-through TransformStream that reports the usage seen on a Codex SSE
158
+ * stream.
159
+ *
160
+ * `usage` resolves when the stream ends: to the last usage observed, or null if
161
+ * none was. It never rejects.
162
+ */
163
+ export function createCodexUsageTap() {
164
+ let settleUsage = () => { };
165
+ const usage = new Promise((resolve) => {
166
+ settleUsage = resolve;
167
+ });
168
+ // flush() and cancel() are mutually exclusive in principle, but a
169
+ // double-settle must be harmless rather than relied upon.
170
+ let settled = false;
171
+ const settle = (value) => {
172
+ if (settled) {
173
+ return;
174
+ }
175
+ settled = true;
176
+ settleUsage(value);
177
+ };
178
+ const decoder = new TextDecoder();
179
+ const capture = createCaptureSink();
180
+ let carry = "";
181
+ let latest = null;
182
+ /**
183
+ * Ceiling on the unterminated tail we are willing to hold.
184
+ *
185
+ * `carry` normally holds a fraction of one SSE line, because every newline
186
+ * flushes it. A stream that never sends one — a hung upstream, a
187
+ * non-SSE body relayed by mistake — would otherwise grow it without bound
188
+ * for the life of the request. One `response.completed` event is a few
189
+ * hundred bytes, so a megabyte is far past any real event, and dropping the
190
+ * tail costs at most the usage reading this tap is allowed to miss anyway.
191
+ */
192
+ const CARRY_LIMIT_CHARS = 1024 * 1024;
193
+ const transformer = {
194
+ transform(chunk, controller) {
195
+ // Bytes go out first and unconditionally: nothing below can delay or
196
+ // alter what the client receives.
197
+ controller.enqueue(chunk);
198
+ try {
199
+ capture?.(chunk);
200
+ carry += decoder.decode(chunk, { stream: true });
201
+ // Keep only the trailing partial line; events are newline-delimited.
202
+ const lastBreak = carry.lastIndexOf("\n");
203
+ if (lastBreak === -1) {
204
+ if (carry.length > CARRY_LIMIT_CHARS) {
205
+ // No line break in a megabyte: this is not the SSE stream we can
206
+ // read. Give up on the tail rather than grow forever.
207
+ carry = "";
208
+ }
209
+ return;
210
+ }
211
+ const complete = carry.slice(0, lastBreak);
212
+ carry = carry.slice(lastBreak + 1);
213
+ const seen = scanCodexSSEForUsage(complete);
214
+ if (seen) {
215
+ latest = seen;
216
+ }
217
+ }
218
+ catch {
219
+ // Telemetry must never break the relay.
220
+ }
221
+ },
222
+ flush() {
223
+ try {
224
+ const seen = scanCodexSSEForUsage(carry);
225
+ if (seen) {
226
+ latest = seen;
227
+ }
228
+ }
229
+ catch {
230
+ // ignored — see above
231
+ }
232
+ settle(latest);
233
+ },
234
+ /**
235
+ * A client hanging up mid-response, or an upstream error, aborts the
236
+ * stream rather than closing it — so flush() never runs. Without this the
237
+ * usage promise would never settle and every aborted request would leak a
238
+ * pending handler. Report whatever was seen before the abort.
239
+ */
240
+ cancel() {
241
+ settle(latest);
242
+ },
243
+ };
244
+ const stream = new TransformStream(transformer);
245
+ return { stream, usage };
246
+ }
247
+ //# sourceMappingURL=codexUsage.js.map
@@ -4,6 +4,7 @@ import { homedir } from "node:os";
4
4
  import { createInterface } from "node:readline";
5
5
  import { isAbsolute, join, relative, resolve, sep } from "node:path";
6
6
  import { ACCOUNT_COOLING_REASONS, PROXY_ACCOUNT_TYPES, PROXY_ACCOUNT_ROUTING_MODES, PROXY_ACCOUNT_ROUTING_REASONS, PROXY_ACCOUNT_ROUTING_STRATEGIES, } from "./routingEvidence.js";
7
+ import { calculateCost, hasPricing, isExactPricingMatch, } from "../utils/pricing.js";
7
8
  const LIFECYCLE_FILE_PATTERN = /^proxy-lifecycle-\d{4}-\d{2}-\d{2}\.jsonl$/;
8
9
  const REQUEST_FILE_PATTERN = /^proxy-\d{4}-\d{2}-\d{2}\.jsonl$/;
9
10
  const ATTEMPT_FILE_PATTERN = /^proxy-attempts-\d{4}-\d{2}-\d{2}\.jsonl$/;
@@ -358,6 +359,13 @@ function summarizeFinalRequests(finalRequests, terminalStreamErrors, attemptsByR
358
359
  let cacheReadTokens = 0;
359
360
  let cacheCreationTokens = 0;
360
361
  let inputTokens = 0;
362
+ let outputTokens = 0;
363
+ let estimatedCostUsd = 0;
364
+ let requestsPriced = 0;
365
+ let requestsPricedByPrefix = 0;
366
+ let requestsUnpriced = 0;
367
+ const modelsPricedByPrefix = new Set();
368
+ const unpricedModels = new Set();
361
369
  const finalRequestLatency = [];
362
370
  const singleAttemptDelta = [];
363
371
  const errorTypes = {};
@@ -394,13 +402,50 @@ function summarizeFinalRequests(finalRequests, terminalStreamErrors, attemptsByR
394
402
  singleAttemptDelta.push(request.durationMs - requestAttempts.totalDurationMs);
395
403
  }
396
404
  if (request.inputTokens !== null ||
405
+ request.outputTokens !== null ||
397
406
  request.cacheReadTokens !== null ||
398
407
  request.cacheCreationTokens !== null) {
399
408
  requestsWithUsage += 1;
400
409
  inputTokens += request.inputTokens ?? 0;
410
+ outputTokens += request.outputTokens ?? 0;
401
411
  cacheReadTokens += request.cacheReadTokens ?? 0;
402
412
  cacheCreationTokens += request.cacheCreationTokens ?? 0;
403
413
  requestsWithCacheRead += (request.cacheReadTokens ?? 0) > 0 ? 1 : 0;
414
+ if (request.model) {
415
+ // Records written before `provider` existed carry only a model name.
416
+ // "openai-compatible" resolves to the cross-provider table search in
417
+ // pricing.ts, which finds the model wherever it lives — a far better
418
+ // guess than assuming Anthropic and pricing a GPT model at $0.
419
+ const cost = calculateCost(request.provider ?? "openai-compatible", request.model, {
420
+ input: request.inputTokens ?? 0,
421
+ output: request.outputTokens ?? 0,
422
+ total: (request.inputTokens ?? 0) +
423
+ (request.outputTokens ?? 0) +
424
+ (request.cacheCreationTokens ?? 0) +
425
+ (request.cacheReadTokens ?? 0),
426
+ cacheCreationTokens: request.cacheCreationTokens ?? 0,
427
+ cacheReadTokens: request.cacheReadTokens ?? 0,
428
+ });
429
+ // Ask the table directly rather than inferring from cost > 0: a real
430
+ // request with trivial usage can round to $0.000000 and is priced, not
431
+ // unpriced.
432
+ const priced = hasPricing(request.provider ?? "openai-compatible", request.model);
433
+ if (priced) {
434
+ estimatedCostUsd += cost;
435
+ requestsPriced += 1;
436
+ // A prefix fallback means the rate was inherited from a
437
+ // similarly-named model, not quoted for this one. Surface it rather
438
+ // than presenting a guess as a figure.
439
+ if (!isExactPricingMatch(request.provider ?? "openai-compatible", request.model)) {
440
+ requestsPricedByPrefix += 1;
441
+ modelsPricedByPrefix.add(request.model);
442
+ }
443
+ }
444
+ else {
445
+ requestsUnpriced += 1;
446
+ unpricedModels.add(request.model);
447
+ }
448
+ }
404
449
  }
405
450
  }
406
451
  return {
@@ -419,6 +464,13 @@ function summarizeFinalRequests(finalRequests, terminalStreamErrors, attemptsByR
419
464
  cacheReadTokens,
420
465
  cacheCreationTokens,
421
466
  inputTokens,
467
+ outputTokens,
468
+ estimatedCostUsd: Number(estimatedCostUsd.toFixed(6)),
469
+ requestsPriced,
470
+ requestsPricedByPrefix,
471
+ modelsPricedByPrefix: [...modelsPricedByPrefix].sort(),
472
+ requestsUnpriced,
473
+ unpricedModels: [...unpricedModels].sort(),
422
474
  requestHitRate: requestsWithUsage > 0
423
475
  ? Number((requestsWithCacheRead / requestsWithUsage).toFixed(4))
424
476
  : null,
@@ -695,13 +747,26 @@ export async function analyzeProxyLogs(options) {
695
747
  for (const filePath of requestFiles) {
696
748
  linesRead += await readJsonLines(filePath, (record) => {
697
749
  const timestamp = observeTimestamp("requests", record);
698
- if (timestamp === null || timestamp < sinceMs || timestamp > untilMs) {
750
+ if (timestamp === null) {
699
751
  return;
700
752
  }
701
753
  const requestId = stringValue(record.requestId);
702
754
  if (!requestId) {
703
755
  return;
704
756
  }
757
+ // A streamed request is logged twice — once when the response headers
758
+ // are known, again when the body finishes and its token counts arrive
759
+ // — and those two writes can straddle the window edge. A Codex turn
760
+ // whose headers land at 23:59:50 and whose stream ends at 00:00:05 has
761
+ // every token it spent in the second record. Filtering that record out
762
+ // by its own timestamp would leave the request counted as completed
763
+ // but contributing nothing to tokens or cost, with nothing in the
764
+ // report to say so. A request the window already admitted therefore
765
+ // keeps accepting its own later records.
766
+ const alreadyAdmitted = finalRequests.has(requestId) || terminalStreamErrors.has(requestId);
767
+ if (!alreadyAdmitted && (timestamp < sinceMs || timestamp > untilMs)) {
768
+ return;
769
+ }
705
770
  if (finiteNumber(record.terminalStatus) !== null) {
706
771
  terminalStreamErrors.add(requestId);
707
772
  return;
@@ -723,19 +788,38 @@ export async function analyzeProxyLogs(options) {
723
788
  else {
724
789
  absentRoutingDecisions += 1;
725
790
  }
726
- finalRequests.set(requestId, {
791
+ const parsed = {
727
792
  timestamp: new Date(timestamp).toISOString(),
728
793
  status,
729
794
  durationMs: finiteNumber(record.responseTimeMs),
730
795
  account: stringValue(record.account) ?? "unknown",
731
796
  accountType: stringValue(record.accountType) ?? "unknown",
797
+ model: stringValue(record.model),
798
+ provider: stringValue(record.provider),
732
799
  inputTokens: finiteNumber(record.inputTokens),
800
+ outputTokens: finiteNumber(record.outputTokens),
733
801
  cacheReadTokens: finiteNumber(record.cacheReadTokens),
734
802
  cacheCreationTokens: finiteNumber(record.cacheCreationTokens),
735
803
  errorType: stringValue(record.errorType),
736
804
  errorCode: stringValue(record.errorCode),
737
805
  routingDecision,
738
- });
806
+ };
807
+ // A request may be logged twice: once when the response headers are
808
+ // known, and again when a streamed body finishes and its token counts
809
+ // become available (the Codex engine does this). Merge rather than
810
+ // replace, so the later usage-bearing record cannot drop an errorType
811
+ // the first one carried, and vice versa.
812
+ const previous = finalRequests.get(requestId);
813
+ finalRequests.set(requestId, previous
814
+ ? {
815
+ ...previous,
816
+ ...Object.fromEntries(Object.entries(parsed).filter(([, value]) => value !== null && value !== undefined)),
817
+ // Attribute the request to when it was first seen. A late
818
+ // completion record must not move it out of the window that
819
+ // admitted it.
820
+ timestamp: previous.timestamp,
821
+ }
822
+ : parsed);
739
823
  }, () => {
740
824
  malformedLines += 1;
741
825
  });
@@ -47,3 +47,4 @@ export declare function getProxyStatus(): {
47
47
  method: string;
48
48
  capabilities: string[];
49
49
  };
50
+ export declare function warnGoogleSdkIgnoresProxy(providerLabel: string): void;
@@ -697,4 +697,33 @@ export function getProxyStatus() {
697
697
  ],
698
698
  };
699
699
  }
700
+ /**
701
+ * One-time warning that the @google/genai SDK cannot honour a configured
702
+ * proxy.
703
+ *
704
+ * Both Google providers passed `httpOptions: { fetch: createProxyFetch() }`,
705
+ * which does nothing. `HttpOptions` in @google/genai 1.46.0 declares only
706
+ * baseUrl, baseUrlResourceScope, apiVersion, headers, timeout, extraBody and
707
+ * retryOptions — there is no `fetch` on it. That property belongs to a
708
+ * different interface (`ClientOptions`), which `GoogleGenAIOptions` does not
709
+ * accept, and the SDK's request path calls global `fetch`. The option was
710
+ * silently dropped and requests went direct.
711
+ *
712
+ * It type-checked only because those constructors are reached through a
713
+ * loosely-typed local alias, which turns off excess-property checking.
714
+ *
715
+ * This SDK version offers no supported injection point, so rather than keep a
716
+ * line that reads like working proxy support, the situation is reported once
717
+ * per process — and only to someone who actually configured a proxy. Silent
718
+ * bypass is the worst outcome available: a corporate user believes their
719
+ * traffic is proxied when it is not.
720
+ */
721
+ let proxyUnsupportedWarned = false;
722
+ export function warnGoogleSdkIgnoresProxy(providerLabel) {
723
+ if (proxyUnsupportedWarned || !getProxyStatus().enabled) {
724
+ return;
725
+ }
726
+ proxyUnsupportedWarned = true;
727
+ logger.warn(`[${providerLabel}] A proxy is configured, but the @google/genai SDK provides no way to route its requests through it (HttpOptions has no 'fetch', and GoogleGenAIOptions accepts none). Requests from this provider go direct.`);
728
+ }
700
729
  //# sourceMappingURL=proxyFetch.js.map
@@ -20,7 +20,18 @@ declare class ProxyTracer {
20
20
  private readonly proxyTracer;
21
21
  private readonly bridge;
22
22
  private readonly requestId;
23
- private readonly model;
23
+ /**
24
+ * Model used for costing. Starts as the model the client asked for and is
25
+ * updated by setModelSubstitution(), so cost follows the model that actually
26
+ * served the request rather than the one that was requested.
27
+ */
28
+ private model;
29
+ /**
30
+ * Provider used for costing. See ProxyRequestContext.provider. Mutable for
31
+ * the same reason `model` is: a fallback can serve the request from a
32
+ * different provider, and pricing has to follow it.
33
+ */
34
+ private billingProvider;
24
35
  private readonly startTime;
25
36
  private readonly isStream;
26
37
  private accountEmail?;
@@ -60,7 +71,7 @@ declare class ProxyTracer {
60
71
  * Record that the proxy substituted a different model than was requested.
61
72
  * Sets span attributes and increments the substitution metric counter.
62
73
  */
63
- setModelSubstitution(requestedModel: string, actualModel: string): void;
74
+ setModelSubstitution(requestedModel: string, actualModel: string, actualProvider?: string): void;
64
75
  setFallbackInfo(info: {
65
76
  triggered: boolean;
66
77
  provider?: string;
@@ -191,16 +191,28 @@ class ProxyTracer {
191
191
  proxyTracer = getTracer("neurolink.proxy");
192
192
  bridge = new OtelBridge();
193
193
  requestId;
194
+ /**
195
+ * Model used for costing. Starts as the model the client asked for and is
196
+ * updated by setModelSubstitution(), so cost follows the model that actually
197
+ * served the request rather than the one that was requested.
198
+ */
194
199
  model;
200
+ /**
201
+ * Provider used for costing. See ProxyRequestContext.provider. Mutable for
202
+ * the same reason `model` is: a fallback can serve the request from a
203
+ * different provider, and pricing has to follow it.
204
+ */
205
+ billingProvider;
195
206
  startTime;
196
207
  isStream;
197
208
  accountEmail;
198
209
  usage;
199
210
  mode = "full";
200
- constructor(rootSpan, requestId, model, stream) {
211
+ constructor(rootSpan, requestId, model, stream, billingProvider) {
201
212
  this.rootSpan = rootSpan;
202
213
  this.requestId = requestId;
203
214
  this.model = model;
215
+ this.billingProvider = billingProvider;
204
216
  this.startTime = Date.now();
205
217
  this.isStream = stream;
206
218
  }
@@ -257,7 +269,7 @@ class ProxyTracer {
257
269
  if (nlConversationId) {
258
270
  rootSpan.setAttribute("neurolink.conversation_id", nlConversationId);
259
271
  }
260
- const instance = new ProxyTracer(rootSpan, ctx.requestId, ctx.model, ctx.stream);
272
+ const instance = new ProxyTracer(rootSpan, ctx.requestId, ctx.model, ctx.stream, ctx.provider ?? "anthropic");
261
273
  // Set Langfuse context (fire-and-forget — non-blocking)
262
274
  // Prefer NeuroLink session/user from calling SDK over Claude Code session
263
275
  setLangfuseContext({
@@ -370,7 +382,7 @@ class ProxyTracer {
370
382
  "gen_ai.usage.total_tokens": totalTokens,
371
383
  });
372
384
  // Cost calculation via pricing.ts
373
- const cost = calculateCost("anthropic", this.model, {
385
+ const cost = calculateCost(this.billingProvider, this.model, {
374
386
  input: ctx.inputTokens,
375
387
  output: ctx.outputTokens,
376
388
  total: totalTokens,
@@ -433,12 +445,22 @@ class ProxyTracer {
433
445
  * Record that the proxy substituted a different model than was requested.
434
446
  * Sets span attributes and increments the substitution metric counter.
435
447
  */
436
- setModelSubstitution(requestedModel, actualModel) {
448
+ setModelSubstitution(requestedModel, actualModel, actualProvider) {
449
+ // Cost must follow the model that served the request, not the alias the
450
+ // client typed — otherwise a claude-* alias routed to another provider is
451
+ // billed at Claude rates. The provider has to move with it: leaving it at
452
+ // the tracer's default means the substituted model is looked up in the
453
+ // wrong pricing table, which yields no rate and so no charge at all.
454
+ this.model = actualModel;
455
+ if (actualProvider) {
456
+ this.billingProvider = actualProvider;
457
+ }
437
458
  this.rootSpan.setAttributes({
438
459
  "proxy.model_substituted": true,
439
460
  "proxy.original_model": requestedModel,
440
461
  "proxy.actual_model": actualModel,
441
462
  "gen_ai.response.model": actualModel,
463
+ ...(actualProvider ? { "proxy.actual_provider": actualProvider } : {}),
442
464
  });
443
465
  const m = getProxyMetrics();
444
466
  m.modelSubstitutionTotal.add(1, {
@@ -644,7 +666,7 @@ class ProxyTracer {
644
666
  this.usage.cacheCreationTokens +
645
667
  this.usage.cacheReadTokens +
646
668
  (this.usage.reasoningTokens ?? 0);
647
- const cost = calculateCost("anthropic", this.model, {
669
+ const cost = calculateCost(this.billingProvider, this.model, {
648
670
  input: this.usage.inputTokens,
649
671
  output: this.usage.outputTokens,
650
672
  total: totalTokens,
@@ -683,14 +705,14 @@ class ProxyTracer {
683
705
  this.usage.cacheReadTokens +
684
706
  (this.usage.reasoningTokens ?? 0);
685
707
  const durationMs = Date.now() - this.startTime;
686
- const cost = calculateCost("anthropic", this.model, {
708
+ const cost = calculateCost(this.billingProvider, this.model, {
687
709
  input: this.usage.inputTokens,
688
710
  output: this.usage.outputTokens,
689
711
  total: totalTokens,
690
712
  cacheCreationTokens: this.usage.cacheCreationTokens,
691
713
  cacheReadTokens: this.usage.cacheReadTokens,
692
714
  });
693
- TelemetryService.getInstance().recordAIRequest("anthropic", this.model, totalTokens, durationMs, cost > 0 ? cost : undefined);
715
+ TelemetryService.getInstance().recordAIRequest(this.billingProvider, this.model, totalTokens, durationMs, cost > 0 ? cost : undefined);
694
716
  }
695
717
  // -------------------------------------------------------------------------
696
718
  // Context propagation