@juspay/neurolink 9.93.0 → 9.93.2
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 +12 -0
- package/dist/browser/neurolink.min.js +379 -382
- package/dist/cli/commands/proxy.d.ts +3 -2
- package/dist/cli/commands/proxy.js +224 -66
- package/dist/cli/commands/proxyAnalyze.d.ts +3 -0
- package/dist/cli/commands/proxyAnalyze.js +147 -0
- package/dist/cli/parser.js +3 -1
- package/dist/cli/utils/serverUtils.js +4 -1
- package/dist/lib/proxy/globalInstaller.js +1 -1
- package/dist/lib/proxy/openaiFormat.js +1 -0
- package/dist/lib/proxy/proxyAnalysis.d.ts +3 -0
- package/dist/lib/proxy/proxyAnalysis.js +454 -0
- package/dist/lib/proxy/proxyHealth.d.ts +4 -0
- package/dist/lib/proxy/proxyHealth.js +21 -0
- package/dist/lib/proxy/sseInterceptor.d.ts +9 -1
- package/dist/lib/proxy/sseInterceptor.js +7 -6
- package/dist/lib/proxy/streamOutcome.d.ts +3 -1
- package/dist/lib/proxy/streamOutcome.js +77 -0
- package/dist/lib/proxy/updateCoordinator.d.ts +7 -0
- package/dist/lib/proxy/updateCoordinator.js +60 -0
- package/dist/lib/proxy/updateState.d.ts +4 -0
- package/dist/lib/proxy/updateState.js +51 -0
- package/dist/lib/proxy/workerLog.d.ts +6 -0
- package/dist/lib/proxy/workerLog.js +42 -0
- package/dist/lib/server/routes/claudeProxyRoutes.d.ts +1 -0
- package/dist/lib/server/routes/claudeProxyRoutes.js +114 -17
- package/dist/lib/server/routes/openaiProxyRoutes.d.ts +9 -0
- package/dist/lib/server/routes/openaiProxyRoutes.js +16 -1
- package/dist/lib/types/cli.d.ts +7 -0
- package/dist/lib/types/proxy.d.ts +187 -0
- package/dist/proxy/globalInstaller.js +1 -1
- package/dist/proxy/openaiFormat.js +1 -0
- package/dist/proxy/proxyAnalysis.d.ts +3 -0
- package/dist/proxy/proxyAnalysis.js +453 -0
- package/dist/proxy/proxyHealth.d.ts +4 -0
- package/dist/proxy/proxyHealth.js +21 -0
- package/dist/proxy/sseInterceptor.d.ts +9 -1
- package/dist/proxy/sseInterceptor.js +7 -6
- package/dist/proxy/streamOutcome.d.ts +3 -1
- package/dist/proxy/streamOutcome.js +77 -0
- package/dist/proxy/updateCoordinator.d.ts +7 -0
- package/dist/proxy/updateCoordinator.js +59 -0
- package/dist/proxy/updateState.d.ts +4 -0
- package/dist/proxy/updateState.js +51 -0
- package/dist/proxy/workerLog.d.ts +6 -0
- package/dist/proxy/workerLog.js +41 -0
- package/dist/server/routes/claudeProxyRoutes.d.ts +1 -0
- package/dist/server/routes/claudeProxyRoutes.js +114 -17
- package/dist/server/routes/openaiProxyRoutes.d.ts +9 -0
- package/dist/server/routes/openaiProxyRoutes.js +16 -1
- package/dist/types/cli.d.ts +7 -0
- package/dist/types/proxy.d.ts +187 -0
- package/package.json +4 -1
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
import { createReadStream } from "node:fs";
|
|
2
|
+
import { readdir } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { createInterface } from "node:readline";
|
|
5
|
+
import { join, resolve } from "node:path";
|
|
6
|
+
const LIFECYCLE_FILE_PATTERN = /^proxy-lifecycle-\d{4}-\d{2}-\d{2}\.jsonl$/;
|
|
7
|
+
const REQUEST_FILE_PATTERN = /^proxy-\d{4}-\d{2}-\d{2}\.jsonl$/;
|
|
8
|
+
const ATTEMPT_FILE_PATTERN = /^proxy-attempts-\d{4}-\d{2}-\d{2}\.jsonl$/;
|
|
9
|
+
const LIFECYCLE_EVENTS = new Set([
|
|
10
|
+
"request_accepted",
|
|
11
|
+
"response_headers",
|
|
12
|
+
"response_first_chunk",
|
|
13
|
+
"request_terminal",
|
|
14
|
+
]);
|
|
15
|
+
function increment(counter, key) {
|
|
16
|
+
counter[key] = (counter[key] ?? 0) + 1;
|
|
17
|
+
}
|
|
18
|
+
function finiteNumber(value) {
|
|
19
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
20
|
+
}
|
|
21
|
+
function stringValue(value) {
|
|
22
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
23
|
+
}
|
|
24
|
+
function percentile(sorted, fraction) {
|
|
25
|
+
if (sorted.length === 0) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
const index = Math.max(0, Math.ceil(sorted.length * fraction) - 1);
|
|
29
|
+
return Number(sorted[index].toFixed(3));
|
|
30
|
+
}
|
|
31
|
+
function summarizeLatency(values) {
|
|
32
|
+
const sorted = values.filter(Number.isFinite).sort((a, b) => a - b);
|
|
33
|
+
return {
|
|
34
|
+
count: sorted.length,
|
|
35
|
+
p50: percentile(sorted, 0.5),
|
|
36
|
+
p95: percentile(sorted, 0.95),
|
|
37
|
+
p99: percentile(sorted, 0.99),
|
|
38
|
+
max: sorted.length ? Number(sorted[sorted.length - 1].toFixed(3)) : null,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function parseSince(value, nowMs) {
|
|
42
|
+
const relative = /^(\d+(?:\.\d+)?)(m|h|d|w)$/i.exec(value.trim());
|
|
43
|
+
if (relative) {
|
|
44
|
+
const amount = Number(relative[1]);
|
|
45
|
+
const unitMs = {
|
|
46
|
+
m: 60_000,
|
|
47
|
+
h: 3_600_000,
|
|
48
|
+
d: 86_400_000,
|
|
49
|
+
w: 604_800_000,
|
|
50
|
+
};
|
|
51
|
+
return nowMs - amount * unitMs[relative[2].toLowerCase()];
|
|
52
|
+
}
|
|
53
|
+
const parsed = Date.parse(value);
|
|
54
|
+
if (!Number.isFinite(parsed)) {
|
|
55
|
+
throw new Error(`Invalid --since value "${value}". Use an ISO timestamp or a duration such as 6h, 1d, or 1w.`);
|
|
56
|
+
}
|
|
57
|
+
return parsed;
|
|
58
|
+
}
|
|
59
|
+
async function readJsonLines(filePath, onRecord, onMalformed) {
|
|
60
|
+
let linesRead = 0;
|
|
61
|
+
const lines = createInterface({
|
|
62
|
+
input: createReadStream(filePath, { encoding: "utf8" }),
|
|
63
|
+
crlfDelay: Infinity,
|
|
64
|
+
});
|
|
65
|
+
for await (const line of lines) {
|
|
66
|
+
if (!line.trim()) {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
linesRead += 1;
|
|
70
|
+
try {
|
|
71
|
+
const parsed = JSON.parse(line);
|
|
72
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
73
|
+
onMalformed();
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
onRecord(parsed);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
onMalformed();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return linesRead;
|
|
83
|
+
}
|
|
84
|
+
function accountEntry(accounts, account, accountType) {
|
|
85
|
+
const key = `${accountType}:${account}`;
|
|
86
|
+
const existing = accounts.get(key);
|
|
87
|
+
if (existing) {
|
|
88
|
+
return existing;
|
|
89
|
+
}
|
|
90
|
+
const created = {
|
|
91
|
+
account,
|
|
92
|
+
accountType,
|
|
93
|
+
attempts: 0,
|
|
94
|
+
attemptErrors: 0,
|
|
95
|
+
finalRequests: 0,
|
|
96
|
+
finalErrors: 0,
|
|
97
|
+
transientRateLimits: 0,
|
|
98
|
+
quotaRateLimits: 0,
|
|
99
|
+
unclassifiedRateLimits: 0,
|
|
100
|
+
};
|
|
101
|
+
accounts.set(key, created);
|
|
102
|
+
return created;
|
|
103
|
+
}
|
|
104
|
+
function summarizeFinalRequests(finalRequests, terminalStreamErrors, attemptsByRequest, accounts) {
|
|
105
|
+
let success = 0;
|
|
106
|
+
let errors = 0;
|
|
107
|
+
let finalRateLimits = 0;
|
|
108
|
+
let recoveredAfterRetry = 0;
|
|
109
|
+
let requestsWithUsage = 0;
|
|
110
|
+
let requestsWithCacheRead = 0;
|
|
111
|
+
let cacheReadTokens = 0;
|
|
112
|
+
let cacheCreationTokens = 0;
|
|
113
|
+
let inputTokens = 0;
|
|
114
|
+
const finalRequestLatency = [];
|
|
115
|
+
const singleAttemptDelta = [];
|
|
116
|
+
const errorTypes = {};
|
|
117
|
+
const errorCodes = {};
|
|
118
|
+
for (const [requestId, request] of finalRequests) {
|
|
119
|
+
const failed = request.status >= 400 || terminalStreamErrors.has(requestId);
|
|
120
|
+
if (failed) {
|
|
121
|
+
errors += 1;
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
success += 1;
|
|
125
|
+
}
|
|
126
|
+
finalRateLimits += request.status === 429 ? 1 : 0;
|
|
127
|
+
const accountStats = accountEntry(accounts, request.account, request.accountType);
|
|
128
|
+
accountStats.finalRequests += 1;
|
|
129
|
+
accountStats.finalErrors += failed ? 1 : 0;
|
|
130
|
+
if (failed) {
|
|
131
|
+
increment(errorTypes, terminalStreamErrors.has(requestId)
|
|
132
|
+
? "stream_error"
|
|
133
|
+
: (request.errorType ?? `http_${request.status}`));
|
|
134
|
+
if (request.errorCode) {
|
|
135
|
+
increment(errorCodes, request.errorCode);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (request.durationMs !== null && request.durationMs >= 0) {
|
|
139
|
+
finalRequestLatency.push(request.durationMs);
|
|
140
|
+
}
|
|
141
|
+
const requestAttempts = attemptsByRequest.get(requestId);
|
|
142
|
+
recoveredAfterRetry += !failed && requestAttempts?.hadError ? 1 : 0;
|
|
143
|
+
if (requestAttempts?.count === 1 &&
|
|
144
|
+
requestAttempts.durationCount === 1 &&
|
|
145
|
+
request.durationMs !== null &&
|
|
146
|
+
Number.isFinite(requestAttempts.totalDurationMs)) {
|
|
147
|
+
singleAttemptDelta.push(request.durationMs - requestAttempts.totalDurationMs);
|
|
148
|
+
}
|
|
149
|
+
if (request.inputTokens !== null ||
|
|
150
|
+
request.cacheReadTokens !== null ||
|
|
151
|
+
request.cacheCreationTokens !== null) {
|
|
152
|
+
requestsWithUsage += 1;
|
|
153
|
+
inputTokens += request.inputTokens ?? 0;
|
|
154
|
+
cacheReadTokens += request.cacheReadTokens ?? 0;
|
|
155
|
+
cacheCreationTokens += request.cacheCreationTokens ?? 0;
|
|
156
|
+
requestsWithCacheRead += (request.cacheReadTokens ?? 0) > 0 ? 1 : 0;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
requestTotals: {
|
|
161
|
+
completed: finalRequests.size,
|
|
162
|
+
success,
|
|
163
|
+
errors,
|
|
164
|
+
finalRateLimits,
|
|
165
|
+
recoveredAfterRetry,
|
|
166
|
+
errorTypes,
|
|
167
|
+
errorCodes,
|
|
168
|
+
},
|
|
169
|
+
cache: {
|
|
170
|
+
requestsWithUsage,
|
|
171
|
+
requestsWithCacheRead,
|
|
172
|
+
cacheReadTokens,
|
|
173
|
+
cacheCreationTokens,
|
|
174
|
+
inputTokens,
|
|
175
|
+
requestHitRate: requestsWithUsage > 0
|
|
176
|
+
? Number((requestsWithCacheRead / requestsWithUsage).toFixed(4))
|
|
177
|
+
: null,
|
|
178
|
+
},
|
|
179
|
+
finalRequestLatency,
|
|
180
|
+
singleAttemptDelta,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
async function discoverLogFiles(logsDir) {
|
|
184
|
+
const entries = await readdir(logsDir, { withFileTypes: true }).catch((error) => {
|
|
185
|
+
throw new Error(`Unable to read proxy logs at ${logsDir}: ${error.message}`);
|
|
186
|
+
});
|
|
187
|
+
const matching = (pattern) => entries
|
|
188
|
+
.filter((entry) => entry.isFile() && pattern.test(entry.name))
|
|
189
|
+
.map((entry) => join(logsDir, entry.name))
|
|
190
|
+
.sort();
|
|
191
|
+
return {
|
|
192
|
+
lifecycleFiles: matching(LIFECYCLE_FILE_PATTERN),
|
|
193
|
+
requestFiles: matching(REQUEST_FILE_PATTERN),
|
|
194
|
+
attemptFiles: matching(ATTEMPT_FILE_PATTERN),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
/** Analyze local proxy logs without reading request or response body artifacts. */
|
|
198
|
+
export async function analyzeProxyLogs(options) {
|
|
199
|
+
const nowMs = options?.nowMs ?? Date.now();
|
|
200
|
+
const sinceMs = parseSince(options?.since ?? "24h", nowMs);
|
|
201
|
+
const logsDir = resolve(options?.logsDir ?? join(homedir(), ".neurolink", "logs"));
|
|
202
|
+
const { lifecycleFiles, requestFiles, attemptFiles } = await discoverLogFiles(logsDir);
|
|
203
|
+
let linesRead = 0;
|
|
204
|
+
let malformedLines = 0;
|
|
205
|
+
let unsupportedLifecycleLines = 0;
|
|
206
|
+
const accepted = new Set();
|
|
207
|
+
const headers = new Set();
|
|
208
|
+
const firstChunks = new Set();
|
|
209
|
+
const terminal = new Set();
|
|
210
|
+
const terminalOutcomes = {};
|
|
211
|
+
const lifecycleErrorTypes = {};
|
|
212
|
+
const lifecycleErrorCodes = {};
|
|
213
|
+
const headersLatency = [];
|
|
214
|
+
const firstChunkLatency = [];
|
|
215
|
+
const terminalLatency = [];
|
|
216
|
+
const sequences = new Map();
|
|
217
|
+
for (const filePath of lifecycleFiles) {
|
|
218
|
+
linesRead += await readJsonLines(filePath, (record) => {
|
|
219
|
+
const timestamp = Date.parse(String(record.timestamp ?? ""));
|
|
220
|
+
if (!Number.isFinite(timestamp) || timestamp < sinceMs) {
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
const event = stringValue(record.event);
|
|
224
|
+
const requestId = stringValue(record.requestId);
|
|
225
|
+
if (record.schemaVersion !== 1 ||
|
|
226
|
+
!event ||
|
|
227
|
+
!LIFECYCLE_EVENTS.has(event) ||
|
|
228
|
+
!requestId) {
|
|
229
|
+
unsupportedLifecycleLines += 1;
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
const processId = stringValue(record.processInstanceId);
|
|
233
|
+
const sequence = finiteNumber(record.sequence);
|
|
234
|
+
if (processId && sequence !== null && Number.isInteger(sequence)) {
|
|
235
|
+
const values = sequences.get(processId) ?? [];
|
|
236
|
+
values.push(sequence);
|
|
237
|
+
sequences.set(processId, values);
|
|
238
|
+
}
|
|
239
|
+
const elapsed = finiteNumber(record.elapsedMs);
|
|
240
|
+
if (event === "request_accepted") {
|
|
241
|
+
accepted.add(requestId);
|
|
242
|
+
}
|
|
243
|
+
else if (event === "response_headers") {
|
|
244
|
+
headers.add(requestId);
|
|
245
|
+
if (elapsed !== null && elapsed >= 0) {
|
|
246
|
+
headersLatency.push(elapsed);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
else if (event === "response_first_chunk") {
|
|
250
|
+
firstChunks.add(requestId);
|
|
251
|
+
if (elapsed !== null && elapsed >= 0) {
|
|
252
|
+
firstChunkLatency.push(elapsed);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
else {
|
|
256
|
+
terminal.add(requestId);
|
|
257
|
+
if (elapsed !== null && elapsed >= 0) {
|
|
258
|
+
terminalLatency.push(elapsed);
|
|
259
|
+
}
|
|
260
|
+
increment(terminalOutcomes, stringValue(record.terminalOutcome) ?? "unknown");
|
|
261
|
+
const errorType = stringValue(record.errorType);
|
|
262
|
+
const errorCode = stringValue(record.errorCode);
|
|
263
|
+
if (errorType) {
|
|
264
|
+
increment(lifecycleErrorTypes, errorType);
|
|
265
|
+
}
|
|
266
|
+
if (errorCode) {
|
|
267
|
+
increment(lifecycleErrorCodes, errorCode);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}, () => {
|
|
271
|
+
malformedLines += 1;
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
let lifecycleSequenceGaps = 0;
|
|
275
|
+
let lifecycleSequenceDuplicates = 0;
|
|
276
|
+
for (const values of sequences.values()) {
|
|
277
|
+
values.sort((a, b) => a - b);
|
|
278
|
+
for (let index = 1; index < values.length; index += 1) {
|
|
279
|
+
const difference = values[index] - values[index - 1];
|
|
280
|
+
if (difference === 0) {
|
|
281
|
+
lifecycleSequenceDuplicates += 1;
|
|
282
|
+
}
|
|
283
|
+
else if (difference > 1) {
|
|
284
|
+
lifecycleSequenceGaps += difference - 1;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
const accounts = new Map();
|
|
289
|
+
const attemptsByRequest = new Map();
|
|
290
|
+
const attemptLatency = [];
|
|
291
|
+
let totalAttempts = 0;
|
|
292
|
+
let totalAttemptErrors = 0;
|
|
293
|
+
const attemptErrorTypes = {};
|
|
294
|
+
const attemptErrorCodes = {};
|
|
295
|
+
let attemptRateLimits = 0;
|
|
296
|
+
let transientRateLimits = 0;
|
|
297
|
+
let quotaRateLimits = 0;
|
|
298
|
+
let unclassifiedRateLimits = 0;
|
|
299
|
+
for (const filePath of attemptFiles) {
|
|
300
|
+
linesRead += await readJsonLines(filePath, (record) => {
|
|
301
|
+
const timestamp = Date.parse(String(record.timestamp ?? ""));
|
|
302
|
+
if (!Number.isFinite(timestamp) || timestamp < sinceMs) {
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
const requestId = stringValue(record.requestId);
|
|
306
|
+
const status = finiteNumber(record.responseStatus);
|
|
307
|
+
const duration = finiteNumber(record.attemptDurationMs);
|
|
308
|
+
if (!requestId || status === null) {
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
const account = stringValue(record.account) ?? "unknown";
|
|
312
|
+
const accountType = stringValue(record.accountType) ?? "unknown";
|
|
313
|
+
const accountStats = accountEntry(accounts, account, accountType);
|
|
314
|
+
totalAttempts += 1;
|
|
315
|
+
accountStats.attempts += 1;
|
|
316
|
+
const hadError = status >= 400 || !!stringValue(record.errorType);
|
|
317
|
+
if (hadError) {
|
|
318
|
+
totalAttemptErrors += 1;
|
|
319
|
+
accountStats.attemptErrors += 1;
|
|
320
|
+
increment(attemptErrorTypes, stringValue(record.errorType) ?? `http_${status}`);
|
|
321
|
+
const errorCode = stringValue(record.errorCode);
|
|
322
|
+
if (errorCode) {
|
|
323
|
+
increment(attemptErrorCodes, errorCode);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
const requestAttempts = attemptsByRequest.get(requestId) ?? {
|
|
327
|
+
count: 0,
|
|
328
|
+
hadError: false,
|
|
329
|
+
totalDurationMs: 0,
|
|
330
|
+
durationCount: 0,
|
|
331
|
+
};
|
|
332
|
+
requestAttempts.count += 1;
|
|
333
|
+
requestAttempts.hadError = requestAttempts.hadError || hadError;
|
|
334
|
+
if (duration !== null && duration >= 0) {
|
|
335
|
+
requestAttempts.totalDurationMs += duration;
|
|
336
|
+
requestAttempts.durationCount += 1;
|
|
337
|
+
attemptLatency.push(duration);
|
|
338
|
+
}
|
|
339
|
+
attemptsByRequest.set(requestId, requestAttempts);
|
|
340
|
+
if (status === 429) {
|
|
341
|
+
attemptRateLimits += 1;
|
|
342
|
+
if (record.rateLimitKind === "transient") {
|
|
343
|
+
transientRateLimits += 1;
|
|
344
|
+
accountStats.transientRateLimits += 1;
|
|
345
|
+
}
|
|
346
|
+
else if (record.rateLimitKind === "quota") {
|
|
347
|
+
quotaRateLimits += 1;
|
|
348
|
+
accountStats.quotaRateLimits += 1;
|
|
349
|
+
}
|
|
350
|
+
else {
|
|
351
|
+
unclassifiedRateLimits += 1;
|
|
352
|
+
accountStats.unclassifiedRateLimits += 1;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}, () => {
|
|
356
|
+
malformedLines += 1;
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
const finalRequests = new Map();
|
|
360
|
+
const terminalStreamErrors = new Set();
|
|
361
|
+
for (const filePath of requestFiles) {
|
|
362
|
+
linesRead += await readJsonLines(filePath, (record) => {
|
|
363
|
+
const timestamp = Date.parse(String(record.timestamp ?? ""));
|
|
364
|
+
if (!Number.isFinite(timestamp) || timestamp < sinceMs) {
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
const requestId = stringValue(record.requestId);
|
|
368
|
+
if (!requestId) {
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
if (finiteNumber(record.terminalStatus) !== null) {
|
|
372
|
+
terminalStreamErrors.add(requestId);
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
const status = finiteNumber(record.responseStatus);
|
|
376
|
+
if (status === null || !stringValue(record.method)) {
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
finalRequests.set(requestId, {
|
|
380
|
+
status,
|
|
381
|
+
durationMs: finiteNumber(record.responseTimeMs),
|
|
382
|
+
account: stringValue(record.account) ?? "unknown",
|
|
383
|
+
accountType: stringValue(record.accountType) ?? "unknown",
|
|
384
|
+
inputTokens: finiteNumber(record.inputTokens),
|
|
385
|
+
cacheReadTokens: finiteNumber(record.cacheReadTokens),
|
|
386
|
+
cacheCreationTokens: finiteNumber(record.cacheCreationTokens),
|
|
387
|
+
errorType: stringValue(record.errorType),
|
|
388
|
+
errorCode: stringValue(record.errorCode),
|
|
389
|
+
});
|
|
390
|
+
}, () => {
|
|
391
|
+
malformedLines += 1;
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
const finalSummary = summarizeFinalRequests(finalRequests, terminalStreamErrors, attemptsByRequest, accounts);
|
|
395
|
+
return {
|
|
396
|
+
generatedAt: new Date(nowMs).toISOString(),
|
|
397
|
+
since: new Date(sinceMs).toISOString(),
|
|
398
|
+
logsDir,
|
|
399
|
+
files: {
|
|
400
|
+
lifecycle: lifecycleFiles.length,
|
|
401
|
+
requests: requestFiles.length,
|
|
402
|
+
attempts: attemptFiles.length,
|
|
403
|
+
},
|
|
404
|
+
coverage: {
|
|
405
|
+
lifecycle: lifecycleFiles.length > 0,
|
|
406
|
+
finalRequests: requestFiles.length > 0,
|
|
407
|
+
attempts: attemptFiles.length > 0,
|
|
408
|
+
attemptLatency: attemptLatency.length > 0,
|
|
409
|
+
cacheUsage: finalSummary.cache.requestsWithUsage > 0,
|
|
410
|
+
},
|
|
411
|
+
dataQuality: {
|
|
412
|
+
linesRead,
|
|
413
|
+
malformedLines,
|
|
414
|
+
unsupportedLifecycleLines,
|
|
415
|
+
lifecycleSequenceGaps,
|
|
416
|
+
lifecycleSequenceDuplicates,
|
|
417
|
+
},
|
|
418
|
+
lifecycle: {
|
|
419
|
+
accepted: accepted.size,
|
|
420
|
+
headers: headers.size,
|
|
421
|
+
firstChunks: firstChunks.size,
|
|
422
|
+
terminal: terminal.size,
|
|
423
|
+
unsettled: [...accepted].filter((requestId) => !terminal.has(requestId))
|
|
424
|
+
.length,
|
|
425
|
+
terminalOutcomes,
|
|
426
|
+
errorTypes: lifecycleErrorTypes,
|
|
427
|
+
errorCodes: lifecycleErrorCodes,
|
|
428
|
+
},
|
|
429
|
+
requests: finalSummary.requestTotals,
|
|
430
|
+
attempts: {
|
|
431
|
+
total: totalAttempts,
|
|
432
|
+
errors: totalAttemptErrors,
|
|
433
|
+
errorTypes: attemptErrorTypes,
|
|
434
|
+
errorCodes: attemptErrorCodes,
|
|
435
|
+
},
|
|
436
|
+
rateLimits: {
|
|
437
|
+
attemptRateLimits,
|
|
438
|
+
transient: transientRateLimits,
|
|
439
|
+
quota: quotaRateLimits,
|
|
440
|
+
unclassified: unclassifiedRateLimits,
|
|
441
|
+
},
|
|
442
|
+
latencyMs: {
|
|
443
|
+
headers: summarizeLatency(headersLatency),
|
|
444
|
+
firstChunk: summarizeLatency(firstChunkLatency),
|
|
445
|
+
terminal: summarizeLatency(terminalLatency),
|
|
446
|
+
finalRequest: summarizeLatency(finalSummary.finalRequestLatency),
|
|
447
|
+
attempt: summarizeLatency(attemptLatency),
|
|
448
|
+
singleAttemptDelta: summarizeLatency(finalSummary.singleAttemptDelta),
|
|
449
|
+
},
|
|
450
|
+
cache: finalSummary.cache,
|
|
451
|
+
accounts: [...accounts.values()].sort((left, right) => right.attempts - left.attempts),
|
|
452
|
+
};
|
|
453
|
+
}
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import type { ProxyHealthResponse, ProxyReadinessState } from "../types/index.js";
|
|
2
2
|
export declare function createProxyReadinessState(startTimeMs?: number): ProxyReadinessState;
|
|
3
3
|
export declare function markProxyReady(state: ProxyReadinessState, readyAtMs?: number): void;
|
|
4
|
+
/** Stop admitting new inference requests while existing responses drain. */
|
|
5
|
+
export declare function markProxyDrainingForUpdate(state: ProxyReadinessState): boolean;
|
|
6
|
+
/** Reopen inference admission after a deferred or failed update attempt. */
|
|
7
|
+
export declare function resumeProxyConnections(state: ProxyReadinessState): boolean;
|
|
4
8
|
export declare function buildProxyHealthResponse(state: ProxyReadinessState, options: {
|
|
5
9
|
strategy: string;
|
|
6
10
|
passthrough: boolean;
|
|
@@ -3,19 +3,40 @@ export function createProxyReadinessState(startTimeMs = Date.now()) {
|
|
|
3
3
|
startTimeMs,
|
|
4
4
|
acceptingConnections: false,
|
|
5
5
|
ready: false,
|
|
6
|
+
drainingForUpdate: false,
|
|
6
7
|
};
|
|
7
8
|
}
|
|
8
9
|
export function markProxyReady(state, readyAtMs = Date.now()) {
|
|
9
10
|
state.acceptingConnections = true;
|
|
10
11
|
state.ready = true;
|
|
12
|
+
state.drainingForUpdate = false;
|
|
11
13
|
state.readyAtMs = readyAtMs;
|
|
12
14
|
}
|
|
15
|
+
/** Stop admitting new inference requests while existing responses drain. */
|
|
16
|
+
export function markProxyDrainingForUpdate(state) {
|
|
17
|
+
if (!state.ready) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
state.acceptingConnections = false;
|
|
21
|
+
state.drainingForUpdate = true;
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
/** Reopen inference admission after a deferred or failed update attempt. */
|
|
25
|
+
export function resumeProxyConnections(state) {
|
|
26
|
+
if (!state.ready) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
state.acceptingConnections = true;
|
|
30
|
+
state.drainingForUpdate = false;
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
13
33
|
export function buildProxyHealthResponse(state, options) {
|
|
14
34
|
const now = options.now ?? Date.now();
|
|
15
35
|
return {
|
|
16
36
|
status: state.ready ? "ok" : "starting",
|
|
17
37
|
ready: state.ready,
|
|
18
38
|
acceptingConnections: state.acceptingConnections,
|
|
39
|
+
drainingForUpdate: state.drainingForUpdate,
|
|
19
40
|
strategy: options.strategy,
|
|
20
41
|
passthrough: options.passthrough,
|
|
21
42
|
version: options.version,
|
|
@@ -15,7 +15,15 @@
|
|
|
15
15
|
* upstreamResponse.body.pipeThrough(stream).pipeTo(clientWritable);
|
|
16
16
|
* const data = await telemetry; // resolves on stream end
|
|
17
17
|
*/
|
|
18
|
-
import type { SSEInterceptorOptions, SSEInterceptorResult } from "../types/index.js";
|
|
18
|
+
import type { SSEInterceptorOptions, SSEInterceptorResult, ParsedSSEBuffer } from "../types/index.js";
|
|
19
|
+
/**
|
|
20
|
+
* Incrementally parse SSE events from a growing buffer of text.
|
|
21
|
+
*
|
|
22
|
+
* SSE events are separated by a blank line (`\n\n`). Each event consists of
|
|
23
|
+
* field lines (`event: ...`, `data: ...`). We consume complete events and
|
|
24
|
+
* return them, leaving any trailing partial event in the buffer.
|
|
25
|
+
*/
|
|
26
|
+
export declare function extractSSEEvents(buffer: string): ParsedSSEBuffer;
|
|
19
27
|
/**
|
|
20
28
|
* Create an SSE interceptor that extracts telemetry from an Anthropic
|
|
21
29
|
* streaming response while passing all bytes through unmodified.
|
|
@@ -35,22 +35,23 @@ const TRUNCATION_MARKER = "...[TRUNCATED]";
|
|
|
35
35
|
* field lines (`event: ...`, `data: ...`). We consume complete events and
|
|
36
36
|
* return them, leaving any trailing partial event in the buffer.
|
|
37
37
|
*/
|
|
38
|
-
function extractSSEEvents(buffer) {
|
|
38
|
+
export function extractSSEEvents(buffer) {
|
|
39
39
|
const events = [];
|
|
40
40
|
// Split on double-newline boundaries. The last segment may be an
|
|
41
41
|
// incomplete event if the chunk was split mid-event.
|
|
42
42
|
let cursor = 0;
|
|
43
43
|
while (cursor < buffer.length) {
|
|
44
|
-
const
|
|
45
|
-
if (
|
|
44
|
+
const boundaryMatch = /\r\n\r\n|\n\n|\r\r/.exec(buffer.slice(cursor));
|
|
45
|
+
if (!boundaryMatch || boundaryMatch.index === undefined) {
|
|
46
46
|
// No more complete events — everything from cursor onward is partial.
|
|
47
47
|
break;
|
|
48
48
|
}
|
|
49
|
+
const boundary = cursor + boundaryMatch.index;
|
|
49
50
|
const rawBlock = buffer.slice(cursor, boundary);
|
|
50
|
-
cursor = boundary +
|
|
51
|
+
cursor = boundary + boundaryMatch[0].length;
|
|
51
52
|
let eventType = "";
|
|
52
53
|
let dataValue = "";
|
|
53
|
-
const lines = rawBlock.split(
|
|
54
|
+
const lines = rawBlock.split(/\r\n|\n|\r/);
|
|
54
55
|
for (const line of lines) {
|
|
55
56
|
if (line.startsWith("event: ")) {
|
|
56
57
|
eventType = line.slice(7).trim();
|
|
@@ -336,7 +337,7 @@ function processEvent(acc, event) {
|
|
|
336
337
|
const message = nestedError?.message ?? payload.message;
|
|
337
338
|
acc.streamErrorMessage =
|
|
338
339
|
typeof message === "string" && message.trim()
|
|
339
|
-
? message.trim()
|
|
340
|
+
? truncateString(message.trim(), MAX_EVENT_DATA_BYTES)
|
|
340
341
|
: truncateString(event.data, MAX_EVENT_DATA_BYTES);
|
|
341
342
|
}
|
|
342
343
|
switch (event.event) {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
-
import type { StreamTerminalOutcome, StreamTerminalOutcomeTracker } from "../types/index.js";
|
|
1
|
+
import type { AnthropicStreamPreflightResult, StreamTerminalOutcome, StreamTerminalOutcomeTracker } from "../types/index.js";
|
|
2
|
+
/** Hold only pre-commit SSE frames so immediate upstream errors remain retryable. */
|
|
3
|
+
export declare function preflightAnthropicStream(reader: ReadableStreamDefaultReader<Uint8Array>): Promise<AnthropicStreamPreflightResult>;
|
|
2
4
|
export declare function createStreamTerminalOutcomeTracker(): StreamTerminalOutcomeTracker;
|
|
3
5
|
export declare function mergeStreamTerminalOutcome(outcome: StreamTerminalOutcome, sseErrorMessage?: string): StreamTerminalOutcome;
|
|
@@ -1,3 +1,80 @@
|
|
|
1
|
+
import { extractSSEEvents } from "./sseInterceptor.js";
|
|
2
|
+
const STREAM_PREFLIGHT_MAX_BYTES = 64 * 1024;
|
|
3
|
+
const STREAM_PREFLIGHT_MAX_CHUNKS = 32;
|
|
4
|
+
function parseSSEError(data) {
|
|
5
|
+
try {
|
|
6
|
+
const parsed = JSON.parse(data);
|
|
7
|
+
return {
|
|
8
|
+
errorType: parsed.error?.type ?? parsed.type ?? "stream_error",
|
|
9
|
+
message: parsed.error?.message ?? parsed.message ?? "Upstream SSE error",
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return {
|
|
14
|
+
errorType: "stream_error",
|
|
15
|
+
message: data.trim() || "Upstream SSE error",
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function isSSEErrorEvent(event) {
|
|
20
|
+
if (event.event === "error") {
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
const parsed = JSON.parse(event.data);
|
|
25
|
+
return parsed.type === "error" || parsed.error !== undefined;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Hold only pre-commit SSE frames so immediate upstream errors remain retryable. */
|
|
32
|
+
export async function preflightAnthropicStream(reader) {
|
|
33
|
+
const decoder = new TextDecoder();
|
|
34
|
+
const chunks = [];
|
|
35
|
+
let parseBuffer = "";
|
|
36
|
+
let totalBytes = 0;
|
|
37
|
+
while (totalBytes < STREAM_PREFLIGHT_MAX_BYTES &&
|
|
38
|
+
chunks.length < STREAM_PREFLIGHT_MAX_CHUNKS) {
|
|
39
|
+
let result;
|
|
40
|
+
try {
|
|
41
|
+
result = await reader.read();
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
return { kind: "transport_error", chunks, error };
|
|
45
|
+
}
|
|
46
|
+
if (result.done) {
|
|
47
|
+
parseBuffer += decoder.decode();
|
|
48
|
+
const { events } = extractSSEEvents(parseBuffer);
|
|
49
|
+
const errorEvent = events.find(isSSEErrorEvent);
|
|
50
|
+
if (errorEvent) {
|
|
51
|
+
return { kind: "sse_error", chunks, ...parseSSEError(errorEvent.data) };
|
|
52
|
+
}
|
|
53
|
+
return chunks.length > 0
|
|
54
|
+
? { kind: "ready", chunks }
|
|
55
|
+
: { kind: "empty", chunks };
|
|
56
|
+
}
|
|
57
|
+
if (!result.value || result.value.length === 0) {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
chunks.push(result.value);
|
|
61
|
+
totalBytes += result.value.byteLength;
|
|
62
|
+
parseBuffer += decoder.decode(result.value, { stream: true });
|
|
63
|
+
const parsed = extractSSEEvents(parseBuffer);
|
|
64
|
+
parseBuffer = parsed.remainder;
|
|
65
|
+
for (const event of parsed.events) {
|
|
66
|
+
if (isSSEErrorEvent(event)) {
|
|
67
|
+
return { kind: "sse_error", chunks, ...parseSSEError(event.data) };
|
|
68
|
+
}
|
|
69
|
+
if (event.event !== "ping" && (event.event || event.data)) {
|
|
70
|
+
return { kind: "ready", chunks };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return chunks.length > 0
|
|
75
|
+
? { kind: "ready", chunks }
|
|
76
|
+
: { kind: "empty", chunks };
|
|
77
|
+
}
|
|
1
78
|
export function createStreamTerminalOutcomeTracker() {
|
|
2
79
|
let settled = false;
|
|
3
80
|
let resolveOutcome;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ProxyUpdateWindowOptions, ProxyUpdateWindowResult } from "../types/index.js";
|
|
2
|
+
/**
|
|
3
|
+
* Prefer a naturally quiet window, then briefly drain new inference traffic.
|
|
4
|
+
* Existing requests are never interrupted; a bounded drain failure is returned
|
|
5
|
+
* to the caller so admission can be reopened and retried later.
|
|
6
|
+
*/
|
|
7
|
+
export declare function waitForProxyUpdateWindow(options: ProxyUpdateWindowOptions): Promise<ProxyUpdateWindowResult>;
|