@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.
Files changed (73) hide show
  1. package/CHANGELOG.md +6 -2
  2. package/dist/browser/neurolink.min.js +430 -421
  3. package/dist/cli/commands/proxy.js +455 -259
  4. package/dist/constants/contextWindows.d.ts +2 -0
  5. package/dist/constants/contextWindows.js +4 -0
  6. package/dist/neurolink.js +8 -3
  7. package/dist/providers/openaiChatCompletionsBase.js +60 -51
  8. package/dist/proxy/anthropicBudgetResponse.d.ts +3 -0
  9. package/dist/proxy/anthropicBudgetResponse.js +153 -0
  10. package/dist/proxy/bodyCaptureProcessing.d.ts +1 -0
  11. package/dist/proxy/bodyCaptureProcessing.js +23 -4
  12. package/dist/proxy/bodyCaptureWorker.js +20 -2
  13. package/dist/proxy/codexFallback.d.ts +10 -0
  14. package/dist/proxy/codexFallback.js +108 -24
  15. package/dist/proxy/codexUsage.js +2 -0
  16. package/dist/proxy/globalInstaller.d.ts +32 -1
  17. package/dist/proxy/globalInstaller.js +386 -3
  18. package/dist/proxy/otelLogSink.d.ts +3 -3
  19. package/dist/proxy/otelLogSink.js +137 -144
  20. package/dist/proxy/otlpLogTransport.d.ts +3 -0
  21. package/dist/proxy/otlpLogTransport.js +334 -0
  22. package/dist/proxy/proxyActivity.d.ts +22 -2
  23. package/dist/proxy/proxyActivity.js +114 -9
  24. package/dist/proxy/proxyAnalysis.js +82 -52
  25. package/dist/proxy/proxyClientCancellation.d.ts +7 -0
  26. package/dist/proxy/proxyClientCancellation.js +23 -0
  27. package/dist/proxy/proxyContextPreflight.d.ts +25 -0
  28. package/dist/proxy/proxyContextPreflight.js +337 -0
  29. package/dist/proxy/proxyFailureDetails.d.ts +19 -0
  30. package/dist/proxy/proxyFailureDetails.js +96 -0
  31. package/dist/proxy/proxyLifecycle.js +7 -0
  32. package/dist/proxy/proxyRouteBodyCapture.d.ts +13 -0
  33. package/dist/proxy/proxyRouteBodyCapture.js +112 -0
  34. package/dist/proxy/proxyTokenBudget.d.ts +40 -0
  35. package/dist/proxy/proxyTokenBudget.js +523 -0
  36. package/dist/proxy/proxyTracer.d.ts +3 -2
  37. package/dist/proxy/proxyTracer.js +46 -20
  38. package/dist/proxy/proxyTranslationEngine.d.ts +8 -10
  39. package/dist/proxy/proxyTranslationEngine.js +736 -376
  40. package/dist/proxy/requestLogger.d.ts +1 -0
  41. package/dist/proxy/requestLogger.js +107 -5
  42. package/dist/proxy/restartControl.js +9 -4
  43. package/dist/proxy/rollingWorkerProcess.js +65 -0
  44. package/dist/proxy/rollingWorkerSupervisor.js +7 -0
  45. package/dist/proxy/updateCoordinator.d.ts +14 -1
  46. package/dist/proxy/updateCoordinator.js +57 -2
  47. package/dist/server/routes/claudeProxyRoutes.d.ts +4 -5
  48. package/dist/server/routes/claudeProxyRoutes.js +809 -577
  49. package/dist/server/routes/codexProxyRoutes.js +191 -9
  50. package/dist/server/routes/openaiProxyRoutes.d.ts +1 -1
  51. package/dist/server/routes/openaiProxyRoutes.js +261 -69
  52. package/dist/types/cli.d.ts +3 -0
  53. package/dist/types/codex.d.ts +5 -1
  54. package/dist/types/index.d.ts +2 -0
  55. package/dist/types/index.js +2 -0
  56. package/dist/types/proxy.d.ts +148 -3
  57. package/dist/types/proxy.js +0 -15
  58. package/dist/types/proxyBudget.d.ts +74 -0
  59. package/dist/types/proxyBudget.js +1 -0
  60. package/dist/types/proxyContext.d.ts +35 -0
  61. package/dist/types/proxyContext.js +1 -0
  62. package/dist/utils/fileDetector.js +28 -7
  63. package/dist/utils/messageBuilder.js +51 -26
  64. package/dist/utils/pdfProcessor.js +26 -4
  65. package/docs/assets/dashboards/README.md +37 -0
  66. package/docs/assets/dashboards/neurolink-proxy-observability-dashboard.json +197 -89
  67. package/docs-site/static/search-index.json +1 -1
  68. package/package.json +7 -2
  69. package/scripts/observability/NATIVE-DURABLE.md +63 -0
  70. package/scripts/observability/otel-collector.proxy-native-durable.yaml +93 -0
  71. package/scripts/observability/proxy-telemetry-backend.mjs +32 -5
  72. package/scripts/observability/proxy-telemetry-check.mjs +396 -61
  73. 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 { ErrorCategory, ErrorSeverity } from "../constants/enums.js";
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
- function logTag(format) {
249
- if (format === "claude") {
250
- return "[proxy]";
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
- if (format === "gemini") {
253
- return "[proxy:gemini]";
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
- return "[proxy:openai]";
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
- // Unified streaming handler
259
- // ---------------------------------------------------------------------------
260
- /**
261
- * Handles a translated stream request for either Claude or OpenAI format.
262
- *
263
- * The streaming loop logic (iterate attempts, call neurolink.stream, collect
264
- * text, handle tool calls, keepalive timer) is identical across formats.
265
- * Only the serializer differs.
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 { ctx, format, requestModel, parsed, attempts, tracer, requestStartTime, } = args;
269
- const tag = logTag(format);
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
- const KEEPALIVE_INTERVAL_MS = 15_000;
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
- let keepAliveTimer;
278
- let cancelled = false;
279
- let succeeded = false;
280
- let streamInterruptedAfterOutput = false;
281
- let translatedModel;
282
- /** Provider that actually served the successful attempt, for costing. */
283
- let translatedProvider;
284
- /**
285
- * Whether the substitution has already been reported to the tracer.
286
- *
287
- * The success path records it before the tracer prices anything, and the
288
- * finally block keeps its own call as a net for any future path that sets
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
- async start(controller) {
300
- // Emit opening frame
301
- for (const frame of serializer.start()) {
302
- controller.enqueue(encoder.encode(frame));
874
+ start(controller) {
875
+ streamController = controller;
876
+ request.signal.addEventListener("abort", onAbort, { once: true });
877
+ if (request.signal.aborted) {
878
+ onAbort();
303
879
  }
304
- keepAliveTimer = setInterval(() => {
305
- try {
306
- controller.enqueue(encoder.encode(": keep-alive\n\n"));
307
- }
308
- catch {
309
- // Controller already closed.
310
- }
311
- }, KEEPALIVE_INTERVAL_MS);
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
- for (let attemptIndex = 0; attemptIndex < attempts.length; attemptIndex++) {
314
- if (cancelled) {
315
- break;
316
- }
317
- const attempt = attempts[attemptIndex];
318
- lastAttemptLabel = attempt.label ?? "translation";
319
- logger.always(`[proxy:${format}] attempt ${attemptIndex + 1}/${attempts.length}: ${attempt.label}`);
320
- recordAttempt(lastAttemptLabel, "translation");
321
- let collectedText = "";
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
- finally {
432
- if (keepAliveTimer) {
433
- clearInterval(keepAliveTimer);
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
- if (!cancelled) {
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
- recordFinalSuccess(lastAttemptLabel, "translation");
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
- cancel() {
505
- cancelled = true;
506
- if (keepAliveTimer) {
507
- clearInterval(keepAliveTimer);
508
- keepAliveTimer = undefined;
918
+ catch (error) {
919
+ if (!ended) {
920
+ clientCapture.finish(502);
921
+ ended = true;
922
+ controller.error(error);
923
+ }
509
924
  }
510
- if (upstreamIterator?.return) {
511
- upstreamIterator.return(undefined).catch((cancelErr) => {
512
- logger.debug(`${tag} upstream cancel error: ${cancelErr instanceof Error ? cancelErr.message : String(cancelErr)}`);
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 { ctx, format, requestModel, parsed, attempts, tracer, requestStartTime, terminalFailureStatus = 500, } = args;
533
- const tag = logTag(format);
534
- let lastAttemptError = "No translation providers succeeded";
535
- let lastAttemptLabel = "translation";
536
- for (let attemptIndex = 0; attemptIndex < attempts.length; attemptIndex++) {
537
- const attempt = attempts[attemptIndex];
538
- lastAttemptLabel = attempt.label ?? "translation";
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 (!hasTranslatedOutput(collectedText, streamResult.toolCalls)) {
554
- lastAttemptError = `Translated provider ${attempt.label} returned no content or tool calls`;
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
- const internal = {
560
- content: collectedText,
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
- catch (attemptError) {
614
- lastAttemptError =
615
- attemptError instanceof Error
616
- ? attemptError.message
617
- : String(attemptError);
618
- logger.debug(`${tag} translation attempt ${attempt.label} failed: ${lastAttemptError}`);
619
- recordAttemptError(lastAttemptLabel, "translation", 500);
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