@juspay/neurolink 12.17.1 → 12.17.3
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 +430 -421
- package/dist/cli/commands/proxy.js +455 -259
- package/dist/constants/contextWindows.d.ts +2 -0
- package/dist/constants/contextWindows.js +4 -0
- package/dist/neurolink.js +8 -3
- package/dist/providers/openaiChatCompletionsBase.js +60 -51
- package/dist/proxy/anthropicBudgetResponse.d.ts +3 -0
- package/dist/proxy/anthropicBudgetResponse.js +153 -0
- package/dist/proxy/bodyCaptureProcessing.d.ts +1 -0
- package/dist/proxy/bodyCaptureProcessing.js +23 -4
- package/dist/proxy/bodyCaptureWorker.js +20 -2
- package/dist/proxy/codexFallback.d.ts +10 -0
- package/dist/proxy/codexFallback.js +108 -24
- package/dist/proxy/codexUsage.js +2 -0
- package/dist/proxy/globalInstaller.d.ts +32 -1
- package/dist/proxy/globalInstaller.js +386 -3
- package/dist/proxy/otelLogSink.d.ts +3 -3
- package/dist/proxy/otelLogSink.js +137 -144
- package/dist/proxy/otlpLogTransport.d.ts +3 -0
- package/dist/proxy/otlpLogTransport.js +334 -0
- package/dist/proxy/proxyActivity.d.ts +22 -2
- package/dist/proxy/proxyActivity.js +114 -9
- package/dist/proxy/proxyAnalysis.js +82 -52
- package/dist/proxy/proxyClientCancellation.d.ts +7 -0
- package/dist/proxy/proxyClientCancellation.js +23 -0
- package/dist/proxy/proxyContextPreflight.d.ts +25 -0
- package/dist/proxy/proxyContextPreflight.js +337 -0
- package/dist/proxy/proxyFailureDetails.d.ts +19 -0
- package/dist/proxy/proxyFailureDetails.js +96 -0
- package/dist/proxy/proxyLifecycle.js +7 -0
- package/dist/proxy/proxyRouteBodyCapture.d.ts +13 -0
- package/dist/proxy/proxyRouteBodyCapture.js +112 -0
- package/dist/proxy/proxyTokenBudget.d.ts +40 -0
- package/dist/proxy/proxyTokenBudget.js +523 -0
- package/dist/proxy/proxyTracer.d.ts +3 -2
- package/dist/proxy/proxyTracer.js +46 -20
- package/dist/proxy/proxyTranslationEngine.d.ts +8 -10
- package/dist/proxy/proxyTranslationEngine.js +736 -376
- package/dist/proxy/requestLogger.d.ts +1 -0
- package/dist/proxy/requestLogger.js +107 -5
- package/dist/proxy/restartControl.js +9 -4
- package/dist/proxy/rollingWorkerProcess.js +65 -0
- package/dist/proxy/rollingWorkerSupervisor.js +7 -0
- package/dist/proxy/updateCoordinator.d.ts +14 -1
- package/dist/proxy/updateCoordinator.js +57 -2
- package/dist/server/routes/claudeProxyRoutes.d.ts +4 -5
- package/dist/server/routes/claudeProxyRoutes.js +809 -577
- package/dist/server/routes/codexProxyRoutes.js +191 -9
- package/dist/server/routes/openaiProxyRoutes.d.ts +1 -1
- package/dist/server/routes/openaiProxyRoutes.js +261 -69
- package/dist/types/cli.d.ts +3 -0
- package/dist/types/codex.d.ts +5 -1
- package/dist/types/index.d.ts +2 -0
- package/dist/types/index.js +2 -0
- package/dist/types/proxy.d.ts +148 -3
- package/dist/types/proxy.js +0 -15
- package/dist/types/proxyBudget.d.ts +74 -0
- package/dist/types/proxyBudget.js +1 -0
- package/dist/types/proxyContext.d.ts +35 -0
- package/dist/types/proxyContext.js +1 -0
- package/dist/utils/fileDetector.js +28 -7
- package/dist/utils/messageBuilder.js +51 -26
- package/dist/utils/pdfProcessor.js +26 -4
- package/docs/assets/dashboards/README.md +37 -0
- package/docs/assets/dashboards/neurolink-proxy-observability-dashboard.json +197 -89
- package/docs-site/static/search-index.json +1 -1
- package/package.json +7 -2
- package/scripts/observability/NATIVE-DURABLE.md +63 -0
- package/scripts/observability/otel-collector.proxy-native-durable.yaml +93 -0
- package/scripts/observability/proxy-telemetry-backend.mjs +32 -5
- package/scripts/observability/proxy-telemetry-check.mjs +396 -61
- package/scripts/observability/query-proxy-history.mjs +34 -5
|
@@ -14,14 +14,17 @@
|
|
|
14
14
|
import { ClaudeStreamSerializer, generateToolUseId, serializeClaudeResponse, } from "./claudeFormat.js";
|
|
15
15
|
import { buildGeminiResponse, createGeminiSerializerAdapter, } from "./geminiFormat.js";
|
|
16
16
|
import { generateOpenAIToolCallId, OpenAIStreamSerializer, serializeOpenAIResponse, } from "./openaiFormat.js";
|
|
17
|
+
import { prepareProxyRequestContext, ProxyContextPreflightError, } from "./proxyContextPreflight.js";
|
|
18
|
+
import { reserveProxyTokenBudget, settleProxyTokenBudget, getProxyTokenBudgetPolicy, getProxyTokenBudgetSessionKey, getProxyTokenBudgetError, } from "./proxyTokenBudget.js";
|
|
19
|
+
import { isProxyRequestFinalized, getProxyRequestAccounting, registerProxyResponseObserver, } from "./proxyActivity.js";
|
|
20
|
+
import { getProxyUpstreamFailure } from "./proxyFailureDetails.js";
|
|
21
|
+
import { sanitizeForLog } from "../utils/logSanitize.js";
|
|
22
|
+
import { createProxyRouteBodyCapture } from "./proxyRouteBodyCapture.js";
|
|
17
23
|
import { DEFAULT_PROXY_MODEL_IDS } from "../constants/proxyModels.js";
|
|
18
|
-
import { logRequest } from "./requestLogger.js";
|
|
24
|
+
import { logRequest, logRequestAttempt } from "./requestLogger.js";
|
|
19
25
|
import { buildClientAttribution } from "./clientAttribution.js";
|
|
20
26
|
import { recordAttempt, recordAttemptError, recordFinalError, recordFinalSuccess, } from "./usageStats.js";
|
|
21
|
-
import {
|
|
22
|
-
import { withTimeout } from "../utils/async/withTimeout.js";
|
|
23
|
-
import { ERROR_CODES, NeuroLinkError } from "../utils/errorHandling.js";
|
|
24
|
-
import { logger } from "../utils/logger.js";
|
|
27
|
+
import { raceWithAbort, withTimeout } from "../utils/async/withTimeout.js";
|
|
25
28
|
// Upper bound on a single translation attempt. Long enough for slow upstreams
|
|
26
29
|
// (Vertex/LiteLLM can take 60–90s on big requests) but short enough that a
|
|
27
30
|
// hung provider can't stall the request handler indefinitely.
|
|
@@ -245,275 +248,694 @@ function generateToolId(format) {
|
|
|
245
248
|
function defaultFinishReason(format) {
|
|
246
249
|
return format === "claude" ? "end_turn" : "stop";
|
|
247
250
|
}
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
+
/** Preserve unknown usage as absent; zero is only reported when observed. */
|
|
252
|
+
function translationUsage(result) {
|
|
253
|
+
const raw = result?.usage;
|
|
254
|
+
if (!raw) {
|
|
255
|
+
return {};
|
|
251
256
|
}
|
|
252
|
-
|
|
253
|
-
|
|
257
|
+
const number = (...values) => values.find((value) => typeof value === "number" && Number.isFinite(value) && value >= 0);
|
|
258
|
+
const details = raw.outputTokenDetails;
|
|
259
|
+
const inputs = raw.inputTokenDetails;
|
|
260
|
+
// NeuroLink TokenUsage uses disjoint input/cache buckets for every provider.
|
|
261
|
+
// Raw AI SDK usage instead includes cached input in inputTokens. Preserve
|
|
262
|
+
// that distinction for both cost metrics and token-budget settlement.
|
|
263
|
+
const inputIncludesCachedTokens = typeof raw.inputIncludesCachedTokens === "boolean"
|
|
264
|
+
? raw.inputIncludesCachedTokens
|
|
265
|
+
: raw.cacheReadTokens === undefined &&
|
|
266
|
+
raw.cacheCreationTokens === undefined &&
|
|
267
|
+
(raw.cachedInputTokens !== undefined || inputs !== undefined);
|
|
268
|
+
return {
|
|
269
|
+
inputIncludesCachedTokens,
|
|
270
|
+
inputTokens: number(raw.inputTokens, raw.promptTokens, raw.input),
|
|
271
|
+
outputTokens: number(raw.outputTokens, raw.completionTokens, raw.output),
|
|
272
|
+
cacheReadTokens: number(raw.cacheReadTokens, raw.cachedInputTokens, inputs?.cacheReadTokens),
|
|
273
|
+
cacheCreationTokens: number(raw.cacheCreationTokens, inputs?.cacheWriteTokens),
|
|
274
|
+
reasoningTokens: number(raw.reasoningTokens, raw.reasoning, details?.reasoningTokens),
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
/** Shared request owner, independent of a consumer asking for another chunk. */
|
|
278
|
+
function createTranslationRequest(args) {
|
|
279
|
+
const { ctx, requestModel, parsed, tracer, requestStartTime, stream } = args;
|
|
280
|
+
const capture = createProxyRouteBodyCapture(ctx, requestModel, stream, requestStartTime);
|
|
281
|
+
if (!ctx.path.endsWith("/v1/messages")) {
|
|
282
|
+
capture.request();
|
|
254
283
|
}
|
|
255
|
-
|
|
284
|
+
const controller = new AbortController();
|
|
285
|
+
const signal = ctx.abortSignal
|
|
286
|
+
? AbortSignal.any([ctx.abortSignal, controller.signal])
|
|
287
|
+
: controller.signal;
|
|
288
|
+
let result;
|
|
289
|
+
let contextPreflight;
|
|
290
|
+
let budgetLease;
|
|
291
|
+
let budgetSettlement;
|
|
292
|
+
let terminalWork;
|
|
293
|
+
let attempt;
|
|
294
|
+
let iterator;
|
|
295
|
+
let firstUsefulOutputMs;
|
|
296
|
+
let finalized = false;
|
|
297
|
+
let upstreamDispatched = false;
|
|
298
|
+
const evidence = () => ({
|
|
299
|
+
model: result?.model ?? attempt?.model ?? "unknown",
|
|
300
|
+
servingModelStatus: result?.model
|
|
301
|
+
? "observed"
|
|
302
|
+
: "unavailable",
|
|
303
|
+
requestedModel: requestModel,
|
|
304
|
+
provider: result?.provider ?? attempt?.provider,
|
|
305
|
+
accountKey: `${result?.provider ?? attempt?.provider ?? "auto"}:sdk-unattributed`,
|
|
306
|
+
contextPreflight,
|
|
307
|
+
tokenBudget: budgetLease ? { ...budgetLease.snapshot } : undefined,
|
|
308
|
+
// The SDK does not expose a credential/account identity. Never invent one
|
|
309
|
+
// from a provider label or conflate different providers as one account.
|
|
310
|
+
account: "unknown",
|
|
311
|
+
accountType: "translation",
|
|
312
|
+
accountIdentityStatus: "unavailable",
|
|
313
|
+
...translationUsage(result),
|
|
314
|
+
});
|
|
315
|
+
const finalize = (status, errorType, message, errorCode, retryable) => {
|
|
316
|
+
if (finalized) {
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
finalized = true;
|
|
320
|
+
if (isProxyRequestFinalized(ctx.requestId)) {
|
|
321
|
+
signal.removeEventListener("abort", onAbort);
|
|
322
|
+
tracer?.end(status, Date.now() - requestStartTime);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
signal.removeEventListener("abort", onAbort);
|
|
326
|
+
const e = evidence();
|
|
327
|
+
if (tracer && e.model !== requestModel) {
|
|
328
|
+
tracer.setModelSubstitution(requestModel, e.model, e.provider);
|
|
329
|
+
}
|
|
330
|
+
if (e.inputTokens !== undefined && e.outputTokens !== undefined) {
|
|
331
|
+
tracer?.setUsage({
|
|
332
|
+
inputTokens: e.inputTokens,
|
|
333
|
+
outputTokens: e.outputTokens,
|
|
334
|
+
cacheReadTokens: e.cacheReadTokens ?? 0,
|
|
335
|
+
cacheCreationTokens: e.cacheCreationTokens ?? 0,
|
|
336
|
+
inputIncludesCachedTokens: e.inputIncludesCachedTokens,
|
|
337
|
+
reasoningTokens: e.reasoningTokens,
|
|
338
|
+
});
|
|
339
|
+
tracer?.recordMetrics();
|
|
340
|
+
}
|
|
341
|
+
const ownsClientOutcome = getProxyRequestAccounting(ctx.requestId)?.accountingScope !== "internal";
|
|
342
|
+
if (errorType) {
|
|
343
|
+
tracer?.setError(errorType, message?.slice(0, 500) ?? errorType);
|
|
344
|
+
if (ownsClientOutcome) {
|
|
345
|
+
recordFinalError(status, attempt?.label ?? "translation", "translation", {
|
|
346
|
+
requestId: ctx.requestId,
|
|
347
|
+
errorType,
|
|
348
|
+
terminalOutcome: errorType,
|
|
349
|
+
message,
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
else if (ownsClientOutcome) {
|
|
354
|
+
recordFinalSuccess(attempt?.label ?? "translation", "translation");
|
|
355
|
+
}
|
|
356
|
+
tracer?.end(status, Date.now() - requestStartTime);
|
|
357
|
+
void logRequest({
|
|
358
|
+
timestamp: new Date().toISOString(),
|
|
359
|
+
requestId: ctx.requestId,
|
|
360
|
+
method: ctx.method,
|
|
361
|
+
path: ctx.path,
|
|
362
|
+
stream,
|
|
363
|
+
toolCount: Object.keys(parsed.tools).length,
|
|
364
|
+
...e,
|
|
365
|
+
...buildClientAttribution(ctx.headers),
|
|
366
|
+
responseStatus: status,
|
|
367
|
+
responseTimeMs: Date.now() - requestStartTime,
|
|
368
|
+
errorType,
|
|
369
|
+
errorCode,
|
|
370
|
+
retryable,
|
|
371
|
+
errorMessage: message?.slice(0, 500),
|
|
372
|
+
firstUsefulOutputMs,
|
|
373
|
+
firstUsefulOutputStatus: firstUsefulOutputMs === undefined ? "no_useful_output" : "observed",
|
|
374
|
+
...tracer?.getTraceContext(),
|
|
375
|
+
});
|
|
376
|
+
};
|
|
377
|
+
const cleanupIterator = () => {
|
|
378
|
+
if (iterator?.return) {
|
|
379
|
+
void withTimeout(Promise.resolve().then(() => iterator?.return?.()), 1000, "Translation cancellation timed out").catch(() => undefined);
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
const settleBudget = (dispatched = upstreamDispatched) => {
|
|
383
|
+
if (!budgetLease) {
|
|
384
|
+
return Promise.resolve();
|
|
385
|
+
}
|
|
386
|
+
if (budgetSettlement) {
|
|
387
|
+
return budgetSettlement;
|
|
388
|
+
}
|
|
389
|
+
const usage = evidence();
|
|
390
|
+
const observed = usage.inputTokens !== undefined && usage.outputTokens !== undefined
|
|
391
|
+
? usage.inputTokens +
|
|
392
|
+
usage.outputTokens +
|
|
393
|
+
(usage.inputIncludesCachedTokens
|
|
394
|
+
? 0
|
|
395
|
+
: (usage.cacheReadTokens ?? 0) + (usage.cacheCreationTokens ?? 0))
|
|
396
|
+
: undefined;
|
|
397
|
+
budgetSettlement = dispatched
|
|
398
|
+
? settleProxyTokenBudget(budgetLease, observed)
|
|
399
|
+
: budgetLease.cancelBeforeDispatch();
|
|
400
|
+
return budgetSettlement;
|
|
401
|
+
};
|
|
402
|
+
const onAbort = () => {
|
|
403
|
+
const timeout = signal.reason?.name === "TimeoutError";
|
|
404
|
+
cleanupIterator();
|
|
405
|
+
terminalWork = settleBudget()
|
|
406
|
+
.catch(() => undefined)
|
|
407
|
+
.then(() => {
|
|
408
|
+
finalize(timeout ? 504 : 499, timeout ? "upstream_timeout" : "client_cancelled", timeout
|
|
409
|
+
? "Request deadline exceeded"
|
|
410
|
+
: "Client cancelled the response");
|
|
411
|
+
});
|
|
412
|
+
};
|
|
413
|
+
registerProxyResponseObserver(ctx.metadata, {
|
|
414
|
+
onTerminal: () => terminalWork,
|
|
415
|
+
});
|
|
416
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
417
|
+
if (signal.aborted) {
|
|
418
|
+
onAbort();
|
|
419
|
+
}
|
|
420
|
+
return {
|
|
421
|
+
signal,
|
|
422
|
+
capture,
|
|
423
|
+
evidence,
|
|
424
|
+
finalize,
|
|
425
|
+
settleBudget,
|
|
426
|
+
abandon() {
|
|
427
|
+
// The outer Claude fallback chain owns the final response. Preserve this
|
|
428
|
+
// attempt's observed evidence without finalizing or borrowing another
|
|
429
|
+
// provider's account, model, or usage when that chain is exhausted.
|
|
430
|
+
ctx.metadata.sdkFallbackFailure = evidence();
|
|
431
|
+
signal.removeEventListener("abort", onAbort);
|
|
432
|
+
cleanupIterator();
|
|
433
|
+
},
|
|
434
|
+
stream,
|
|
435
|
+
cancel(reason) {
|
|
436
|
+
controller.abort(reason);
|
|
437
|
+
},
|
|
438
|
+
output() {
|
|
439
|
+
firstUsefulOutputMs ??= Date.now() - requestStartTime;
|
|
440
|
+
},
|
|
441
|
+
setAttempt(value) {
|
|
442
|
+
upstreamDispatched = false;
|
|
443
|
+
attempt = value;
|
|
444
|
+
result = undefined;
|
|
445
|
+
budgetLease = undefined;
|
|
446
|
+
budgetSettlement = undefined;
|
|
447
|
+
contextPreflight = undefined;
|
|
448
|
+
if (!stream) {
|
|
449
|
+
firstUsefulOutputMs = undefined;
|
|
450
|
+
}
|
|
451
|
+
iterator = undefined;
|
|
452
|
+
},
|
|
453
|
+
markDispatched() {
|
|
454
|
+
upstreamDispatched = true;
|
|
455
|
+
},
|
|
456
|
+
setBudget(value) {
|
|
457
|
+
budgetLease = value;
|
|
458
|
+
},
|
|
459
|
+
setContext(value) {
|
|
460
|
+
contextPreflight = value;
|
|
461
|
+
},
|
|
462
|
+
setResult(value) {
|
|
463
|
+
result = value;
|
|
464
|
+
},
|
|
465
|
+
setIterator(value) {
|
|
466
|
+
iterator = value;
|
|
467
|
+
},
|
|
468
|
+
cleanupIterator,
|
|
469
|
+
};
|
|
256
470
|
}
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
471
|
+
/** An attempt deadline covers setup AND stream consumption and aborts the SDK. */
|
|
472
|
+
async function* runTranslationAttempts(args) {
|
|
473
|
+
const { ctx, parsed, attempts, request, requestStartTime } = args;
|
|
474
|
+
let lastError = new Error("No translation providers succeeded");
|
|
475
|
+
let outputVisible = false;
|
|
476
|
+
for (let index = 0; index < attempts.length; index++) {
|
|
477
|
+
request.signal.throwIfAborted();
|
|
478
|
+
const attempt = attempts[index];
|
|
479
|
+
const started = Date.now();
|
|
480
|
+
const deadline = new AbortController();
|
|
481
|
+
let timer;
|
|
482
|
+
const signal = AbortSignal.any([request.signal, deadline.signal]);
|
|
483
|
+
request.setAttempt(attempt);
|
|
484
|
+
yield { reset: true };
|
|
485
|
+
let status = 200;
|
|
486
|
+
let errorType;
|
|
487
|
+
let errorMessage;
|
|
488
|
+
let errorCode;
|
|
489
|
+
let lease;
|
|
490
|
+
let dispatched = false;
|
|
491
|
+
let denied = false;
|
|
492
|
+
let failureRetryable;
|
|
493
|
+
let sdkResponse;
|
|
494
|
+
try {
|
|
495
|
+
const options = {
|
|
496
|
+
...buildTranslationOptions(parsed, {
|
|
497
|
+
provider: attempt.provider,
|
|
498
|
+
model: attempt.model,
|
|
499
|
+
}),
|
|
500
|
+
...args.options,
|
|
501
|
+
abortSignal: signal,
|
|
502
|
+
};
|
|
503
|
+
const prepared = prepareProxyRequestContext({
|
|
504
|
+
provider: attempt.provider ?? "auto",
|
|
505
|
+
model: attempt.model ?? parsed.model,
|
|
506
|
+
body: options,
|
|
507
|
+
toolReferenceBody: ctx.body,
|
|
508
|
+
maxOutputTokens: parsed.maxTokens,
|
|
509
|
+
});
|
|
510
|
+
request.setContext(prepared.evidence);
|
|
511
|
+
const reservation = reserveProxyTokenBudget({
|
|
512
|
+
provider: attempt.provider ?? "auto",
|
|
513
|
+
accountKey: `${attempt.provider ?? "auto"}:sdk-unattributed`,
|
|
514
|
+
sessionKey: getProxyTokenBudgetSessionKey(new Headers(ctx.headers)),
|
|
515
|
+
requestId: ctx.requestId,
|
|
516
|
+
reservationTokens: prepared.totalTokensReservation,
|
|
517
|
+
policy: getProxyTokenBudgetPolicy(),
|
|
518
|
+
estimateProvenance: "context_preflight",
|
|
519
|
+
});
|
|
520
|
+
// A cancelled RPC may still grant a lease: release it before dispatch.
|
|
521
|
+
void reservation.then((value) => {
|
|
522
|
+
if (signal.aborted && !dispatched) {
|
|
523
|
+
void value.cancelBeforeDispatch().catch(() => undefined);
|
|
524
|
+
}
|
|
525
|
+
}, () => undefined);
|
|
526
|
+
lease = await raceWithAbort(reservation, signal);
|
|
527
|
+
request.setBudget(lease);
|
|
528
|
+
signal.throwIfAborted();
|
|
529
|
+
// Resolve local admission before any HTTP 200/SSE headers are committed.
|
|
530
|
+
// No provider is started until the consumer asks for output.
|
|
531
|
+
yield { admitted: true };
|
|
532
|
+
signal.throwIfAborted();
|
|
533
|
+
timer = setTimeout(() => {
|
|
534
|
+
const error = new DOMException("Translation attempt deadline exceeded", "TimeoutError");
|
|
535
|
+
deadline.abort(error);
|
|
536
|
+
// Once output has reached the client, a timeout is terminal even if
|
|
537
|
+
// downstream pulls have stopped. Before output, only this attempt
|
|
538
|
+
// expires so a configured fallback can still run on the same request.
|
|
539
|
+
if (outputVisible) {
|
|
540
|
+
request.cancel(error);
|
|
541
|
+
}
|
|
542
|
+
}, args.attemptTimeoutMs ?? TRANSLATION_ATTEMPT_TIMEOUT_MS);
|
|
543
|
+
timer.unref?.();
|
|
544
|
+
dispatched = true;
|
|
545
|
+
request.markDispatched();
|
|
546
|
+
sdkResponse = request.capture.accumulator("sdk_response", "text/event-stream", index + 1);
|
|
547
|
+
request.capture.log({
|
|
548
|
+
phase: "sdk_request",
|
|
549
|
+
contentType: "application/json",
|
|
550
|
+
body: {
|
|
551
|
+
...prepared.body,
|
|
552
|
+
abortSignal: undefined,
|
|
553
|
+
tools: !("tools" in prepared.body) || prepared.body.tools === undefined
|
|
554
|
+
? undefined
|
|
555
|
+
: Object.fromEntries(Object.entries((prepared.body.tools ?? {})).map(([name, tool]) => [
|
|
556
|
+
name,
|
|
557
|
+
{
|
|
558
|
+
description: tool.description,
|
|
559
|
+
inputSchema: tool.inputSchema &&
|
|
560
|
+
typeof tool.inputSchema === "object" &&
|
|
561
|
+
"jsonSchema" in tool.inputSchema
|
|
562
|
+
? tool.inputSchema.jsonSchema
|
|
563
|
+
: tool.inputSchema,
|
|
564
|
+
},
|
|
565
|
+
])),
|
|
566
|
+
},
|
|
567
|
+
attempt: index + 1,
|
|
568
|
+
metadata: {
|
|
569
|
+
representation: "neurolink_sdk_options",
|
|
570
|
+
omittedRuntimeFields: ["abortSignal", "tools.*.execute"],
|
|
571
|
+
schemaRepresentation: "json_schema",
|
|
572
|
+
wireCapture: false,
|
|
573
|
+
},
|
|
574
|
+
});
|
|
575
|
+
recordAttempt(attempt.label ?? "translation", "translation");
|
|
576
|
+
const result = await raceWithAbort(ctx.neurolink.stream(prepared.body), signal);
|
|
577
|
+
request.setResult(result);
|
|
578
|
+
const iterator = result.stream[Symbol.asyncIterator]();
|
|
579
|
+
request.setIterator(iterator);
|
|
580
|
+
let hasText = false;
|
|
581
|
+
while (true) {
|
|
582
|
+
const next = await raceWithAbort(Promise.resolve(iterator.next()), signal);
|
|
583
|
+
if (next.done) {
|
|
584
|
+
break;
|
|
585
|
+
}
|
|
586
|
+
sdkResponse?.appendEvent(next.value);
|
|
587
|
+
const text = extractText(next.value);
|
|
588
|
+
if (text) {
|
|
589
|
+
hasText ||= text.trim().length > 0;
|
|
590
|
+
// Any delivered text prevents replay, including whitespace.
|
|
591
|
+
outputVisible = request.stream;
|
|
592
|
+
request.output();
|
|
593
|
+
yield { text };
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
sdkResponse?.appendEvent({
|
|
597
|
+
model: result.model,
|
|
598
|
+
provider: result.provider,
|
|
599
|
+
usage: result.usage,
|
|
600
|
+
toolCalls: result.toolCalls,
|
|
601
|
+
finishReason: result.finishReason,
|
|
602
|
+
});
|
|
603
|
+
if (!hasText && !result.toolCalls?.length) {
|
|
604
|
+
throw new Error(`Translated provider ${attempt.label} returned no content or tool calls`);
|
|
605
|
+
}
|
|
606
|
+
if (result.toolCalls?.length) {
|
|
607
|
+
request.output();
|
|
608
|
+
}
|
|
609
|
+
// Provider work is complete before downstream terminal frames are read.
|
|
610
|
+
// Slow clients must not retain provider admission or trigger an upstream timeout.
|
|
611
|
+
clearTimeout(timer);
|
|
612
|
+
await request.settleBudget();
|
|
613
|
+
yield { result };
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
catch (error) {
|
|
617
|
+
lastError =
|
|
618
|
+
deadline.signal.aborted && !request.signal.aborted
|
|
619
|
+
? deadline.signal.reason
|
|
620
|
+
: error;
|
|
621
|
+
const budgetError = getProxyTokenBudgetError(error);
|
|
622
|
+
const failure = getProxyUpstreamFailure(error);
|
|
623
|
+
failureRetryable = failure?.retryable;
|
|
624
|
+
denied =
|
|
625
|
+
!!budgetError ||
|
|
626
|
+
error instanceof ProxyContextPreflightError ||
|
|
627
|
+
failure?.retryable === false;
|
|
628
|
+
const timeout = deadline.signal.reason?.name === "TimeoutError" ||
|
|
629
|
+
request.signal.reason?.name === "TimeoutError";
|
|
630
|
+
status =
|
|
631
|
+
budgetError?.status ??
|
|
632
|
+
(error instanceof ProxyContextPreflightError
|
|
633
|
+
? error.status
|
|
634
|
+
: timeout
|
|
635
|
+
? 504
|
|
636
|
+
: request.signal.aborted
|
|
637
|
+
? 499
|
|
638
|
+
: (failure?.status ?? 502));
|
|
639
|
+
errorCode =
|
|
640
|
+
budgetError?.code ??
|
|
641
|
+
(error instanceof ProxyContextPreflightError
|
|
642
|
+
? error.code
|
|
643
|
+
: failure?.code);
|
|
644
|
+
errorType = budgetError
|
|
645
|
+
? "token_budget_rejected"
|
|
646
|
+
: error instanceof ProxyContextPreflightError
|
|
647
|
+
? "context_preflight_rejected"
|
|
648
|
+
: timeout
|
|
649
|
+
? "upstream_timeout"
|
|
650
|
+
: request.signal.aborted
|
|
651
|
+
? "client_cancelled"
|
|
652
|
+
: outputVisible
|
|
653
|
+
? "stream_error"
|
|
654
|
+
: "generation_error";
|
|
655
|
+
if (budgetError) {
|
|
656
|
+
request.finalize(budgetError.status, "token_budget_rejected", budgetError.message, budgetError.code, false);
|
|
657
|
+
}
|
|
658
|
+
if (error instanceof ProxyContextPreflightError) {
|
|
659
|
+
if (error.evidence) {
|
|
660
|
+
request.setContext(error.evidence);
|
|
661
|
+
}
|
|
662
|
+
request.finalize(status, errorType, error.message, error.code, false);
|
|
663
|
+
}
|
|
664
|
+
errorMessage =
|
|
665
|
+
failure?.message ??
|
|
666
|
+
sanitizeForLog(lastError instanceof Error ? lastError.message : String(lastError), 500);
|
|
667
|
+
if (dispatched) {
|
|
668
|
+
recordAttemptError(attempt.label ?? "translation", "translation", status);
|
|
669
|
+
}
|
|
670
|
+
if (request.signal.aborted || outputVisible || denied) {
|
|
671
|
+
throw lastError;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
finally {
|
|
675
|
+
sdkResponse?.finish(status);
|
|
676
|
+
clearTimeout(timer);
|
|
677
|
+
// Free provider work even if it ignores iterator.return during a stall.
|
|
678
|
+
deadline.abort();
|
|
679
|
+
request.cleanupIterator();
|
|
680
|
+
if (lease) {
|
|
681
|
+
try {
|
|
682
|
+
await request.settleBudget(dispatched);
|
|
683
|
+
}
|
|
684
|
+
catch (error) {
|
|
685
|
+
errorCode ??= getProxyTokenBudgetError(error)?.code;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
void logRequestAttempt({
|
|
689
|
+
timestamp: new Date().toISOString(),
|
|
690
|
+
requestId: ctx.requestId,
|
|
691
|
+
attempt: index + 1,
|
|
692
|
+
method: ctx.method,
|
|
693
|
+
path: ctx.path,
|
|
694
|
+
stream: request.stream,
|
|
695
|
+
toolCount: Object.keys(parsed.tools).length,
|
|
696
|
+
...request.evidence(),
|
|
697
|
+
upstreamDispatched: dispatched,
|
|
698
|
+
responseStatus: status,
|
|
699
|
+
responseTimeMs: Date.now() - requestStartTime,
|
|
700
|
+
attemptDurationMs: Date.now() - started,
|
|
701
|
+
errorType,
|
|
702
|
+
errorCode,
|
|
703
|
+
errorMessage,
|
|
704
|
+
retryable: failureRetryable ??
|
|
705
|
+
(!denied &&
|
|
706
|
+
!request.signal.aborted &&
|
|
707
|
+
!outputVisible &&
|
|
708
|
+
index + 1 < attempts.length),
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
throw lastError;
|
|
713
|
+
}
|
|
714
|
+
function buildTranslationErrorPayload(format, status, clientMessage, code, retryable) {
|
|
715
|
+
return format === "gemini"
|
|
716
|
+
? {
|
|
717
|
+
error: {
|
|
718
|
+
code: status,
|
|
719
|
+
status: status === 400
|
|
720
|
+
? "INVALID_ARGUMENT"
|
|
721
|
+
: status === 403
|
|
722
|
+
? "PERMISSION_DENIED"
|
|
723
|
+
: status === 429
|
|
724
|
+
? "RESOURCE_EXHAUSTED"
|
|
725
|
+
: "UNAVAILABLE",
|
|
726
|
+
message: clientMessage,
|
|
727
|
+
...(code ? { reason: code } : {}),
|
|
728
|
+
...(retryable !== undefined ? { retryable } : {}),
|
|
729
|
+
},
|
|
730
|
+
}
|
|
731
|
+
: format === "claude"
|
|
732
|
+
? {
|
|
733
|
+
type: "error",
|
|
734
|
+
error: {
|
|
735
|
+
type: status === 400
|
|
736
|
+
? "invalid_request_error"
|
|
737
|
+
: status === 403
|
|
738
|
+
? "permission_error"
|
|
739
|
+
: status === 429
|
|
740
|
+
? "rate_limit_error"
|
|
741
|
+
: "api_error",
|
|
742
|
+
message: clientMessage,
|
|
743
|
+
...(code ? { code } : {}),
|
|
744
|
+
...(retryable !== undefined ? { retryable } : {}),
|
|
745
|
+
},
|
|
746
|
+
}
|
|
747
|
+
: {
|
|
748
|
+
error: {
|
|
749
|
+
type: status === 400
|
|
750
|
+
? "invalid_request_error"
|
|
751
|
+
: status === 403
|
|
752
|
+
? "permission_error"
|
|
753
|
+
: status === 429
|
|
754
|
+
? "rate_limit_error"
|
|
755
|
+
: "server_error",
|
|
756
|
+
message: clientMessage,
|
|
757
|
+
...(code ? { code } : {}),
|
|
758
|
+
...(retryable !== undefined ? { retryable } : {}),
|
|
759
|
+
},
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
function buildTranslationErrorResponse(format, status, clientMessage, code, capture, retryable) {
|
|
763
|
+
const payload = buildTranslationErrorPayload(format, status, clientMessage, code, retryable);
|
|
764
|
+
capture?.json(payload, status);
|
|
765
|
+
return new Response(JSON.stringify(payload), {
|
|
766
|
+
status,
|
|
767
|
+
headers: { "content-type": "application/json" },
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
/** Demand-driven serialization; a slow reader cannot grow an unbounded queue. */
|
|
267
771
|
export async function handleTranslatedStreamRequest(args) {
|
|
268
|
-
const {
|
|
269
|
-
const
|
|
772
|
+
const { format, requestModel } = args;
|
|
773
|
+
const request = createTranslationRequest({ ...args, stream: true });
|
|
270
774
|
const serializer = format === "claude"
|
|
271
775
|
? createClaudeSerializerAdapter(requestModel)
|
|
272
776
|
: format === "gemini"
|
|
273
777
|
? createGeminiSerializerAdapter(requestModel)
|
|
274
778
|
: createOpenAISerializerAdapter(requestModel);
|
|
275
|
-
|
|
779
|
+
let visible = false;
|
|
780
|
+
const source = runTranslationAttempts({ ...args, request });
|
|
781
|
+
let primed;
|
|
782
|
+
try {
|
|
783
|
+
do {
|
|
784
|
+
primed = await source.next();
|
|
785
|
+
} while (primed.done === false &&
|
|
786
|
+
(primed.value.reset ||
|
|
787
|
+
(args.prefetchFirstOutput && primed.value.admitted)));
|
|
788
|
+
}
|
|
789
|
+
catch (error) {
|
|
790
|
+
if (args.prefetchFirstOutput) {
|
|
791
|
+
request.abandon();
|
|
792
|
+
throw error;
|
|
793
|
+
}
|
|
794
|
+
const budgetError = getProxyTokenBudgetError(error);
|
|
795
|
+
const failure = getProxyUpstreamFailure(error);
|
|
796
|
+
const status = budgetError?.status ??
|
|
797
|
+
(error instanceof ProxyContextPreflightError
|
|
798
|
+
? error.status
|
|
799
|
+
: (failure?.status ?? 502));
|
|
800
|
+
const code = budgetError?.code ??
|
|
801
|
+
(error instanceof ProxyContextPreflightError
|
|
802
|
+
? error.code
|
|
803
|
+
: failure?.code);
|
|
804
|
+
const message = budgetError?.message ??
|
|
805
|
+
(error instanceof ProxyContextPreflightError
|
|
806
|
+
? error.message
|
|
807
|
+
: "Upstream generation failed");
|
|
808
|
+
request.finalize(status, "generation_error", failure?.message ?? message, code, failure?.retryable);
|
|
809
|
+
return buildTranslationErrorResponse(format, status, message, code, request.capture, failure?.retryable);
|
|
810
|
+
}
|
|
811
|
+
async function* output() {
|
|
812
|
+
if (primed?.done === false) {
|
|
813
|
+
yield primed.value;
|
|
814
|
+
}
|
|
815
|
+
yield* source;
|
|
816
|
+
}
|
|
817
|
+
async function* frames() {
|
|
818
|
+
try {
|
|
819
|
+
yield* serializer.start();
|
|
820
|
+
for await (const item of output()) {
|
|
821
|
+
if (item.text !== undefined) {
|
|
822
|
+
visible = true;
|
|
823
|
+
yield* serializer.pushDelta(item.text);
|
|
824
|
+
}
|
|
825
|
+
if (item.result) {
|
|
826
|
+
for (const tool of item.result.toolCalls ?? []) {
|
|
827
|
+
visible = true;
|
|
828
|
+
yield* serializer.pushToolUse(tool.toolCallId || generateToolId(format), tool.toolName || "unknown", extractToolArgs(tool));
|
|
829
|
+
}
|
|
830
|
+
yield* serializer.finish(item.result.finishReason ?? defaultFinishReason(format), extractUsageFromStreamResult(item.result.usage));
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
request.finalize(200);
|
|
834
|
+
}
|
|
835
|
+
catch (error) {
|
|
836
|
+
if (request.signal.aborted) {
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
const failure = getProxyUpstreamFailure(error);
|
|
840
|
+
const status = error instanceof Error && error.name === "TimeoutError"
|
|
841
|
+
? 504
|
|
842
|
+
: (failure?.status ?? 502);
|
|
843
|
+
const message = failure?.message ??
|
|
844
|
+
sanitizeForLog(error instanceof Error ? error.message : String(error), 500);
|
|
845
|
+
request.finalize(status, status === 504
|
|
846
|
+
? "upstream_timeout"
|
|
847
|
+
: visible
|
|
848
|
+
? "stream_error"
|
|
849
|
+
: "generation_error", message, failure?.code, failure?.retryable);
|
|
850
|
+
if (failure) {
|
|
851
|
+
const payload = buildTranslationErrorPayload(format, status, "Upstream generation failed", failure.code, failure.retryable);
|
|
852
|
+
yield `${format === "claude" ? "event: error\n" : ""}data: ${JSON.stringify(payload)}\n\n`;
|
|
853
|
+
}
|
|
854
|
+
else {
|
|
855
|
+
yield* serializer.emitError("Upstream generation failed");
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
const iterator = frames();
|
|
276
860
|
const encoder = new TextEncoder();
|
|
277
|
-
|
|
278
|
-
let
|
|
279
|
-
let
|
|
280
|
-
let
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
* translatedModel without reaching that point. Both guards are otherwise
|
|
290
|
-
* identical and translatedModel is assigned in exactly one place, so without
|
|
291
|
-
* this flag every substituted stream reports twice and
|
|
292
|
-
* proxy_model_substitution_total reads exactly double the real count.
|
|
293
|
-
*/
|
|
294
|
-
let substitutionRecorded = false;
|
|
295
|
-
let finalStreamError = "No translation providers succeeded";
|
|
296
|
-
let upstreamIterator;
|
|
297
|
-
let lastAttemptLabel = "translation";
|
|
861
|
+
const clientCapture = request.capture.accumulator("client_response", "text/event-stream");
|
|
862
|
+
let ended = false;
|
|
863
|
+
let pending;
|
|
864
|
+
let streamController;
|
|
865
|
+
const onAbort = () => {
|
|
866
|
+
clientCapture.finish(request.signal.reason?.name === "TimeoutError" ? 504 : 499);
|
|
867
|
+
ended = true;
|
|
868
|
+
streamController?.error(request.signal.reason ??
|
|
869
|
+
new DOMException("Client disconnected", "AbortError"));
|
|
870
|
+
void source.return().catch(() => undefined);
|
|
871
|
+
void iterator.return().catch(() => undefined);
|
|
872
|
+
};
|
|
298
873
|
const stream = new ReadableStream({
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
874
|
+
start(controller) {
|
|
875
|
+
streamController = controller;
|
|
876
|
+
request.signal.addEventListener("abort", onAbort, { once: true });
|
|
877
|
+
if (request.signal.aborted) {
|
|
878
|
+
onAbort();
|
|
303
879
|
}
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
880
|
+
},
|
|
881
|
+
async pull(controller) {
|
|
882
|
+
if (ended) {
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
pending ??= iterator.next();
|
|
886
|
+
// Keepalives use the same pull slot as data, so they cannot bypass backpressure.
|
|
887
|
+
let heartbeat;
|
|
312
888
|
try {
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
try {
|
|
323
|
-
const options = buildTranslationOptions(parsed, attempt.provider
|
|
324
|
-
? { provider: attempt.provider, model: attempt.model }
|
|
325
|
-
: {});
|
|
326
|
-
const streamResult = await withTimeout(ctx.neurolink.stream(options), TRANSLATION_ATTEMPT_TIMEOUT_MS, `Translation attempt ${attempt.label} timed out after ${TRANSLATION_ATTEMPT_TIMEOUT_MS}ms`);
|
|
327
|
-
const iterable = streamResult.stream;
|
|
328
|
-
upstreamIterator = iterable[Symbol.asyncIterator]();
|
|
329
|
-
while (true) {
|
|
330
|
-
if (cancelled) {
|
|
331
|
-
break;
|
|
332
|
-
}
|
|
333
|
-
const { value: chunk, done } = await upstreamIterator.next();
|
|
334
|
-
if (done || cancelled) {
|
|
335
|
-
break;
|
|
336
|
-
}
|
|
337
|
-
const text = extractText(chunk);
|
|
338
|
-
if (text) {
|
|
339
|
-
collectedText += text;
|
|
340
|
-
for (const frame of serializer.pushDelta(text)) {
|
|
341
|
-
controller.enqueue(encoder.encode(frame));
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
if (cancelled) {
|
|
346
|
-
return;
|
|
347
|
-
}
|
|
348
|
-
const toolCalls = streamResult.toolCalls ?? [];
|
|
349
|
-
if (!hasTranslatedOutput(collectedText, toolCalls)) {
|
|
350
|
-
finalStreamError = `Translated provider ${attempt.label} returned no content or tool calls`;
|
|
351
|
-
logger.debug(`${tag} translation attempt ${attempt.label} returned no content or tool calls`);
|
|
352
|
-
recordAttemptError(lastAttemptLabel, "translation", 502);
|
|
353
|
-
continue;
|
|
354
|
-
}
|
|
355
|
-
if (!cancelled && toolCalls.length) {
|
|
356
|
-
for (const toolCall of toolCalls) {
|
|
357
|
-
const toolName = toolCall.toolName ??
|
|
358
|
-
toolCall.name ??
|
|
359
|
-
"unknown";
|
|
360
|
-
const toolId = toolCall.toolCallId ||
|
|
361
|
-
generateToolId(format);
|
|
362
|
-
for (const frame of serializer.pushToolUse(toolId, toolName, extractToolArgs(toolCall))) {
|
|
363
|
-
controller.enqueue(encoder.encode(frame));
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
if (!cancelled) {
|
|
368
|
-
const reason = streamResult.finishReason ?? defaultFinishReason(format);
|
|
369
|
-
const resolvedUsage = extractUsageFromStreamResult(streamResult.usage);
|
|
370
|
-
for (const frame of serializer.finish(reason, resolvedUsage)) {
|
|
371
|
-
controller.enqueue(encoder.encode(frame));
|
|
372
|
-
}
|
|
373
|
-
}
|
|
374
|
-
translatedModel = streamResult.model;
|
|
375
|
-
translatedProvider = attempt.provider;
|
|
376
|
-
// Substitution BEFORE usage: setUsage() and recordMetrics() price
|
|
377
|
-
// the request immediately, against the tracer's current model and
|
|
378
|
-
// billing provider. Recording first and substituting afterwards
|
|
379
|
-
// bills the model the client ASKED for — which is exactly what
|
|
380
|
-
// ProxyTracer.setModelSubstitution() documents as wrong, since a
|
|
381
|
-
// claude-* alias served by another provider would be charged at
|
|
382
|
-
// Claude rates. The finally block below keeps its own call as a
|
|
383
|
-
// net for any future path that sets translatedModel without
|
|
384
|
-
// reaching here; substitutionRecorded stops the two from
|
|
385
|
-
// double-counting proxy_model_substitution_total.
|
|
386
|
-
if (tracer && translatedModel && translatedModel !== requestModel) {
|
|
387
|
-
tracer.setModelSubstitution(requestModel, translatedModel, translatedProvider);
|
|
388
|
-
substitutionRecorded = true;
|
|
389
|
-
}
|
|
390
|
-
// Track usage and metrics
|
|
391
|
-
const resolvedUsageForTracer = extractUsageFromStreamResult(streamResult.usage);
|
|
392
|
-
tracer?.setUsage({
|
|
393
|
-
inputTokens: resolvedUsageForTracer.input,
|
|
394
|
-
outputTokens: resolvedUsageForTracer.output,
|
|
395
|
-
cacheCreationTokens: 0,
|
|
396
|
-
cacheReadTokens: 0,
|
|
397
|
-
});
|
|
398
|
-
tracer?.recordMetrics();
|
|
399
|
-
succeeded = true;
|
|
400
|
-
return;
|
|
401
|
-
}
|
|
402
|
-
catch (streamErr) {
|
|
403
|
-
if (cancelled) {
|
|
404
|
-
return;
|
|
405
|
-
}
|
|
406
|
-
finalStreamError =
|
|
407
|
-
streamErr instanceof Error
|
|
408
|
-
? streamErr.message
|
|
409
|
-
: String(streamErr);
|
|
410
|
-
if (collectedText.trim().length > 0) {
|
|
411
|
-
streamInterruptedAfterOutput = true;
|
|
412
|
-
recordAttemptError(lastAttemptLabel, "translation", 502);
|
|
413
|
-
logger.always(`${tag} mid-stream error: ${finalStreamError}`);
|
|
414
|
-
for (const frame of serializer.emitError(`Upstream stream interrupted: ${finalStreamError}`)) {
|
|
415
|
-
controller.enqueue(encoder.encode(frame));
|
|
416
|
-
}
|
|
417
|
-
return;
|
|
418
|
-
}
|
|
419
|
-
logger.debug(`${tag} translation attempt ${attempt.label} failed: ${finalStreamError}`);
|
|
420
|
-
recordAttemptError(lastAttemptLabel, "translation", 500);
|
|
421
|
-
}
|
|
422
|
-
}
|
|
423
|
-
// All attempts exhausted
|
|
424
|
-
if (!cancelled) {
|
|
425
|
-
logger.always(`${tag} all translation attempts failed: ${finalStreamError}`);
|
|
426
|
-
for (const frame of serializer.emitError(finalStreamError)) {
|
|
427
|
-
controller.enqueue(encoder.encode(frame));
|
|
428
|
-
}
|
|
889
|
+
const next = await Promise.race([
|
|
890
|
+
pending,
|
|
891
|
+
new Promise((resolve) => {
|
|
892
|
+
heartbeat = setTimeout(() => resolve(null), 15000);
|
|
893
|
+
heartbeat.unref?.();
|
|
894
|
+
}),
|
|
895
|
+
]);
|
|
896
|
+
if (ended) {
|
|
897
|
+
return;
|
|
429
898
|
}
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
899
|
+
if (next === null) {
|
|
900
|
+
const bytes = encoder.encode(": keep-alive\n\n");
|
|
901
|
+
clientCapture.append(bytes);
|
|
902
|
+
controller.enqueue(bytes);
|
|
903
|
+
return;
|
|
434
904
|
}
|
|
435
|
-
|
|
905
|
+
pending = undefined;
|
|
906
|
+
if (next.done) {
|
|
907
|
+
ended = true;
|
|
908
|
+
request.signal.removeEventListener("abort", onAbort);
|
|
909
|
+
clientCapture.finish(200);
|
|
436
910
|
controller.close();
|
|
437
911
|
}
|
|
438
|
-
if (!substitutionRecorded &&
|
|
439
|
-
tracer &&
|
|
440
|
-
translatedModel &&
|
|
441
|
-
translatedModel !== requestModel) {
|
|
442
|
-
tracer.setModelSubstitution(requestModel, translatedModel, translatedProvider);
|
|
443
|
-
substitutionRecorded = true;
|
|
444
|
-
}
|
|
445
|
-
const terminalStatus = cancelled
|
|
446
|
-
? 499
|
|
447
|
-
: succeeded
|
|
448
|
-
? 200
|
|
449
|
-
: streamInterruptedAfterOutput
|
|
450
|
-
? 502
|
|
451
|
-
: 500;
|
|
452
|
-
const terminalErrorType = cancelled
|
|
453
|
-
? "client_cancelled"
|
|
454
|
-
: streamInterruptedAfterOutput
|
|
455
|
-
? "stream_error"
|
|
456
|
-
: succeeded
|
|
457
|
-
? undefined
|
|
458
|
-
: "generation_error";
|
|
459
|
-
const terminalErrorMessage = cancelled
|
|
460
|
-
? "Client cancelled the streaming response"
|
|
461
|
-
: succeeded
|
|
462
|
-
? undefined
|
|
463
|
-
: finalStreamError;
|
|
464
|
-
if (terminalErrorType && terminalErrorMessage) {
|
|
465
|
-
tracer?.setError(terminalErrorType, terminalErrorMessage.slice(0, 500));
|
|
466
|
-
recordFinalError(terminalStatus, lastAttemptLabel, "translation", {
|
|
467
|
-
requestId: ctx.requestId,
|
|
468
|
-
errorType: terminalErrorType,
|
|
469
|
-
terminalOutcome: terminalErrorType,
|
|
470
|
-
message: terminalErrorMessage,
|
|
471
|
-
});
|
|
472
|
-
}
|
|
473
912
|
else {
|
|
474
|
-
|
|
913
|
+
const bytes = encoder.encode(next.value);
|
|
914
|
+
clientCapture.append(bytes);
|
|
915
|
+
controller.enqueue(bytes);
|
|
475
916
|
}
|
|
476
|
-
tracer?.end(terminalStatus, Date.now() - requestStartTime);
|
|
477
|
-
const traceCtx = tracer?.getTraceContext();
|
|
478
|
-
logRequest({
|
|
479
|
-
timestamp: new Date().toISOString(),
|
|
480
|
-
requestId: ctx.requestId,
|
|
481
|
-
method: ctx.method,
|
|
482
|
-
path: ctx.path,
|
|
483
|
-
model: requestModel,
|
|
484
|
-
stream: true,
|
|
485
|
-
toolCount: Object.keys(parsed.tools).length,
|
|
486
|
-
account: "translation",
|
|
487
|
-
accountType: "translation",
|
|
488
|
-
// Without this the translated doors record no calling client at all.
|
|
489
|
-
// The Gemini door serves every request through this engine, so its
|
|
490
|
-
// rows carried a null clientApp and a null userAgent — not merely
|
|
491
|
-
// "unknown", but nothing to attribute after the fact either.
|
|
492
|
-
...buildClientAttribution(ctx.headers),
|
|
493
|
-
responseStatus: terminalStatus,
|
|
494
|
-
responseTimeMs: Date.now() - requestStartTime,
|
|
495
|
-
...(terminalErrorType ? { errorType: terminalErrorType } : {}),
|
|
496
|
-
...(terminalErrorMessage
|
|
497
|
-
? { errorMessage: terminalErrorMessage.slice(0, 500) }
|
|
498
|
-
: {}),
|
|
499
|
-
...(traceCtx?.traceId ? { traceId: traceCtx.traceId } : {}),
|
|
500
|
-
...(traceCtx?.spanId ? { spanId: traceCtx.spanId } : {}),
|
|
501
|
-
});
|
|
502
917
|
}
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
918
|
+
catch (error) {
|
|
919
|
+
if (!ended) {
|
|
920
|
+
clientCapture.finish(502);
|
|
921
|
+
ended = true;
|
|
922
|
+
controller.error(error);
|
|
923
|
+
}
|
|
509
924
|
}
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
}
|
|
925
|
+
finally {
|
|
926
|
+
if (heartbeat) {
|
|
927
|
+
clearTimeout(heartbeat);
|
|
928
|
+
}
|
|
514
929
|
}
|
|
515
930
|
},
|
|
516
|
-
|
|
931
|
+
cancel(reason) {
|
|
932
|
+
clientCapture.finish(499);
|
|
933
|
+
ended = true;
|
|
934
|
+
request.signal.removeEventListener("abort", onAbort);
|
|
935
|
+
request.cancel(reason);
|
|
936
|
+
void iterator.return().catch(() => undefined);
|
|
937
|
+
},
|
|
938
|
+
}, { highWaterMark: 0 });
|
|
517
939
|
return new Response(stream, {
|
|
518
940
|
headers: {
|
|
519
941
|
"content-type": "text/event-stream",
|
|
@@ -522,138 +944,76 @@ export async function handleTranslatedStreamRequest(args) {
|
|
|
522
944
|
},
|
|
523
945
|
});
|
|
524
946
|
}
|
|
525
|
-
// ---------------------------------------------------------------------------
|
|
526
|
-
// Unified JSON (non-streaming) handler
|
|
527
|
-
// ---------------------------------------------------------------------------
|
|
528
|
-
/**
|
|
529
|
-
* Handles a translated non-streaming request for either Claude or OpenAI format.
|
|
530
|
-
*/
|
|
531
947
|
export async function handleTranslatedJsonRequest(args) {
|
|
532
|
-
const
|
|
533
|
-
|
|
534
|
-
let
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
logger.always(`[proxy:${format}] attempt ${attemptIndex + 1}/${attempts.length}: ${attempt.label}`);
|
|
540
|
-
recordAttempt(lastAttemptLabel, "translation");
|
|
541
|
-
try {
|
|
542
|
-
const options = buildTranslationOptions(parsed, attempt.provider
|
|
543
|
-
? { provider: attempt.provider, model: attempt.model }
|
|
544
|
-
: {});
|
|
545
|
-
const streamResult = await withTimeout(ctx.neurolink.stream(options), TRANSLATION_ATTEMPT_TIMEOUT_MS, `Translation attempt ${attempt.label} timed out after ${TRANSLATION_ATTEMPT_TIMEOUT_MS}ms`);
|
|
546
|
-
let collectedText = "";
|
|
547
|
-
for await (const chunk of streamResult.stream) {
|
|
548
|
-
const text = extractText(chunk);
|
|
549
|
-
if (text) {
|
|
550
|
-
collectedText += text;
|
|
551
|
-
}
|
|
948
|
+
const request = createTranslationRequest({ ...args, stream: false });
|
|
949
|
+
let content = "";
|
|
950
|
+
let result;
|
|
951
|
+
try {
|
|
952
|
+
for await (const item of runTranslationAttempts({ ...args, request })) {
|
|
953
|
+
if (item.reset) {
|
|
954
|
+
content = "";
|
|
552
955
|
}
|
|
553
|
-
if (
|
|
554
|
-
|
|
555
|
-
logger.debug(`${tag} translation attempt ${attempt.label} returned no content or tool calls`);
|
|
556
|
-
recordAttemptError(lastAttemptLabel, "translation", 502);
|
|
557
|
-
continue;
|
|
956
|
+
if (item.text !== undefined) {
|
|
957
|
+
content += item.text;
|
|
558
958
|
}
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
model: streamResult.model,
|
|
562
|
-
finishReason: streamResult.finishReason ?? defaultFinishReason(format),
|
|
563
|
-
reasoning: undefined,
|
|
564
|
-
usage: streamResult.usage
|
|
565
|
-
? extractUsageFromStreamResult(streamResult.usage)
|
|
566
|
-
: undefined,
|
|
567
|
-
toolCalls: streamResult.toolCalls,
|
|
568
|
-
};
|
|
569
|
-
// Substitution BEFORE usage — see the streaming path for why: pricing
|
|
570
|
-
// happens inside setUsage()/recordMetrics(), so substituting afterwards
|
|
571
|
-
// bills the requested model instead of the one that served.
|
|
572
|
-
if (tracer && streamResult.model && streamResult.model !== requestModel) {
|
|
573
|
-
tracer.setModelSubstitution(requestModel, streamResult.model, attempt.provider);
|
|
959
|
+
if (item.result) {
|
|
960
|
+
result = item.result;
|
|
574
961
|
}
|
|
575
|
-
// Track usage and metrics
|
|
576
|
-
const resolvedUsage = extractUsageFromStreamResult(streamResult.usage);
|
|
577
|
-
tracer?.setUsage({
|
|
578
|
-
inputTokens: resolvedUsage.input,
|
|
579
|
-
outputTokens: resolvedUsage.output,
|
|
580
|
-
cacheCreationTokens: 0,
|
|
581
|
-
cacheReadTokens: 0,
|
|
582
|
-
});
|
|
583
|
-
tracer?.recordMetrics();
|
|
584
|
-
tracer?.end(200, Date.now() - requestStartTime);
|
|
585
|
-
recordFinalSuccess(lastAttemptLabel, "translation");
|
|
586
|
-
const traceCtx = tracer?.getTraceContext();
|
|
587
|
-
logRequest({
|
|
588
|
-
timestamp: new Date().toISOString(),
|
|
589
|
-
requestId: ctx.requestId,
|
|
590
|
-
method: ctx.method,
|
|
591
|
-
path: ctx.path,
|
|
592
|
-
model: requestModel,
|
|
593
|
-
stream: false,
|
|
594
|
-
toolCount: Object.keys(parsed.tools).length,
|
|
595
|
-
account: "translation",
|
|
596
|
-
accountType: "translation",
|
|
597
|
-
...buildClientAttribution(ctx.headers),
|
|
598
|
-
responseStatus: 200,
|
|
599
|
-
responseTimeMs: Date.now() - requestStartTime,
|
|
600
|
-
inputTokens: resolvedUsage.input,
|
|
601
|
-
outputTokens: resolvedUsage.output,
|
|
602
|
-
...(traceCtx?.traceId ? { traceId: traceCtx.traceId } : {}),
|
|
603
|
-
...(traceCtx?.spanId ? { spanId: traceCtx.spanId } : {}),
|
|
604
|
-
});
|
|
605
|
-
if (format === "claude") {
|
|
606
|
-
return serializeClaudeResponse(internal, requestModel);
|
|
607
|
-
}
|
|
608
|
-
if (format === "gemini") {
|
|
609
|
-
return buildGeminiResponse(internal.content, internal.finishReason ?? defaultFinishReason(format), resolvedUsage, internal.model ?? requestModel, internal.toolCalls);
|
|
610
|
-
}
|
|
611
|
-
return serializeOpenAIResponse(internal, requestModel);
|
|
612
962
|
}
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
963
|
+
request.signal.throwIfAborted();
|
|
964
|
+
request.finalize(200);
|
|
965
|
+
const usage = result?.usage
|
|
966
|
+
? extractUsageFromStreamResult(result.usage)
|
|
967
|
+
: undefined;
|
|
968
|
+
const internal = {
|
|
969
|
+
content,
|
|
970
|
+
model: result?.model,
|
|
971
|
+
finishReason: result?.finishReason ?? defaultFinishReason(args.format),
|
|
972
|
+
usage,
|
|
973
|
+
toolCalls: result?.toolCalls,
|
|
974
|
+
};
|
|
975
|
+
const response = args.format === "claude"
|
|
976
|
+
? serializeClaudeResponse(internal, args.requestModel)
|
|
977
|
+
: args.format === "gemini"
|
|
978
|
+
? buildGeminiResponse(content, internal.finishReason ?? defaultFinishReason(args.format), usage ?? { input: 0, output: 0, total: 0 }, internal.model ?? args.requestModel, internal.toolCalls)
|
|
979
|
+
: serializeOpenAIResponse(internal, args.requestModel);
|
|
980
|
+
request.capture.json(response, 200);
|
|
981
|
+
return response;
|
|
982
|
+
}
|
|
983
|
+
catch (error) {
|
|
984
|
+
if (args.deferFinalOnError && !request.signal.aborted) {
|
|
985
|
+
request.abandon();
|
|
986
|
+
throw error;
|
|
620
987
|
}
|
|
988
|
+
const failure = getProxyUpstreamFailure(error);
|
|
989
|
+
const message = failure?.message ??
|
|
990
|
+
sanitizeForLog(error instanceof Error ? error.message : String(error), 500);
|
|
991
|
+
const budgetError = getProxyTokenBudgetError(error);
|
|
992
|
+
const timeout = request.signal.reason?.name === "TimeoutError" ||
|
|
993
|
+
(error instanceof Error && error.name === "TimeoutError");
|
|
994
|
+
const status = budgetError?.status ??
|
|
995
|
+
(error instanceof ProxyContextPreflightError
|
|
996
|
+
? error.status
|
|
997
|
+
: timeout
|
|
998
|
+
? 504
|
|
999
|
+
: request.signal.aborted
|
|
1000
|
+
? 499
|
|
1001
|
+
: (failure?.status ?? args.terminalFailureStatus ?? 502));
|
|
1002
|
+
const code = budgetError?.code ??
|
|
1003
|
+
(error instanceof ProxyContextPreflightError
|
|
1004
|
+
? error.code
|
|
1005
|
+
: failure?.code);
|
|
1006
|
+
request.finalize(status, timeout
|
|
1007
|
+
? "upstream_timeout"
|
|
1008
|
+
: request.signal.aborted
|
|
1009
|
+
? "client_cancelled"
|
|
1010
|
+
: "generation_error", message, code, failure?.retryable);
|
|
1011
|
+
const clientMessage = budgetError?.message ??
|
|
1012
|
+
(error instanceof ProxyContextPreflightError
|
|
1013
|
+
? error.message
|
|
1014
|
+
: "Upstream generation failed");
|
|
1015
|
+
return buildTranslationErrorResponse(args.format, status, clientMessage, code, request.capture, failure?.retryable);
|
|
621
1016
|
}
|
|
622
|
-
recordFinalError(terminalFailureStatus, lastAttemptLabel, "translation", {
|
|
623
|
-
requestId: ctx.requestId,
|
|
624
|
-
errorType: "generation_error",
|
|
625
|
-
terminalOutcome: "handler_error",
|
|
626
|
-
message: lastAttemptError,
|
|
627
|
-
});
|
|
628
|
-
tracer?.setError("generation_error", lastAttemptError.slice(0, 500));
|
|
629
|
-
tracer?.end(terminalFailureStatus, Date.now() - requestStartTime);
|
|
630
|
-
const traceCtx = tracer?.getTraceContext();
|
|
631
|
-
logRequest({
|
|
632
|
-
timestamp: new Date().toISOString(),
|
|
633
|
-
requestId: ctx.requestId,
|
|
634
|
-
method: ctx.method,
|
|
635
|
-
path: ctx.path,
|
|
636
|
-
model: requestModel,
|
|
637
|
-
stream: false,
|
|
638
|
-
toolCount: Object.keys(parsed.tools).length,
|
|
639
|
-
account: "translation",
|
|
640
|
-
accountType: "translation",
|
|
641
|
-
...buildClientAttribution(ctx.headers),
|
|
642
|
-
responseStatus: terminalFailureStatus,
|
|
643
|
-
responseTimeMs: Date.now() - requestStartTime,
|
|
644
|
-
errorType: "generation_error",
|
|
645
|
-
errorMessage: lastAttemptError.slice(0, 500),
|
|
646
|
-
...(traceCtx?.traceId ? { traceId: traceCtx.traceId } : {}),
|
|
647
|
-
...(traceCtx?.spanId ? { spanId: traceCtx.spanId } : {}),
|
|
648
|
-
});
|
|
649
|
-
throw new NeuroLinkError({
|
|
650
|
-
code: ERROR_CODES.PROVIDER_NOT_AVAILABLE,
|
|
651
|
-
message: lastAttemptError,
|
|
652
|
-
category: ErrorCategory.EXECUTION,
|
|
653
|
-
severity: ErrorSeverity.HIGH,
|
|
654
|
-
retriable: false,
|
|
655
|
-
context: { attemptLabel: lastAttemptLabel, format },
|
|
656
|
-
});
|
|
657
1017
|
}
|
|
658
1018
|
// ---------------------------------------------------------------------------
|
|
659
1019
|
// Models list handler
|