@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.
- package/CHANGELOG.md +6 -2
- package/dist/browser/neurolink.min.js +396 -396
- package/dist/cli/commands/proxy.js +42 -0
- package/dist/cli/commands/proxyAnalyze.js +10 -1
- package/dist/cli/proxy-clients/claudeCode.js +42 -10
- package/dist/cli/proxy-clients/openCode.js +37 -15
- package/dist/cli/proxy-clients/qwenCode.js +33 -9
- package/dist/cli/proxy-clients/registry.js +10 -2
- package/dist/cli/proxy-clients/snapshot.d.ts +52 -0
- package/dist/cli/proxy-clients/snapshot.js +98 -0
- package/dist/lib/providers/googleAiStudio/client.js +6 -3
- package/dist/lib/providers/googleVertex/client.js +6 -3
- package/dist/lib/proxy/codexUsage.d.ts +68 -0
- package/dist/lib/proxy/codexUsage.js +247 -0
- package/dist/lib/proxy/proxyAnalysis.js +87 -3
- package/dist/lib/proxy/proxyFetch.d.ts +1 -0
- package/dist/lib/proxy/proxyFetch.js +29 -0
- package/dist/lib/proxy/proxyTracer.d.ts +13 -2
- package/dist/lib/proxy/proxyTracer.js +29 -7
- package/dist/lib/proxy/proxyTranslationEngine.js +22 -5
- package/dist/lib/server/routes/codexProxyRoutes.js +29 -1
- package/dist/lib/server/routes/openaiProxyRoutes.js +5 -0
- package/dist/lib/types/proxy.d.ts +65 -0
- package/dist/lib/utils/pricing.d.ts +9 -0
- package/dist/lib/utils/pricing.js +136 -1
- package/dist/providers/googleAiStudio/client.js +6 -3
- package/dist/providers/googleVertex/client.js +6 -3
- package/dist/proxy/codexUsage.d.ts +68 -0
- package/dist/proxy/codexUsage.js +246 -0
- package/dist/proxy/proxyAnalysis.js +87 -3
- package/dist/proxy/proxyFetch.d.ts +1 -0
- package/dist/proxy/proxyFetch.js +29 -0
- package/dist/proxy/proxyTracer.d.ts +13 -2
- package/dist/proxy/proxyTracer.js +29 -7
- package/dist/proxy/proxyTranslationEngine.js +22 -5
- package/dist/server/routes/codexProxyRoutes.js +29 -1
- package/dist/server/routes/openaiProxyRoutes.js +5 -0
- package/dist/types/proxy.d.ts +65 -0
- package/dist/utils/pricing.d.ts +9 -0
- package/dist/utils/pricing.js +136 -1
- 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
|
|
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
|
-
|
|
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
|
});
|
|
@@ -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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
715
|
+
TelemetryService.getInstance().recordAIRequest(this.billingProvider, this.model, totalTokens, durationMs, cost > 0 ? cost : undefined);
|
|
694
716
|
}
|
|
695
717
|
// -------------------------------------------------------------------------
|
|
696
718
|
// Context propagation
|