@juspay/neurolink 10.4.3 → 10.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/auth/tokenStore.d.ts +7 -0
  3. package/dist/auth/tokenStore.js +54 -0
  4. package/dist/browser/neurolink.min.js +374 -374
  5. package/dist/cli/commands/proxy.d.ts +1 -0
  6. package/dist/cli/commands/proxy.js +183 -24
  7. package/dist/lib/auth/tokenStore.d.ts +7 -0
  8. package/dist/lib/auth/tokenStore.js +54 -0
  9. package/dist/lib/processors/config/mimeConstants.js +2 -0
  10. package/dist/lib/proxy/proxyTranslationEngine.d.ts +1 -0
  11. package/dist/lib/proxy/proxyTranslationEngine.js +56 -12
  12. package/dist/lib/proxy/rawStreamCapture.js +47 -1
  13. package/dist/lib/proxy/sseInterceptor.js +47 -1
  14. package/dist/lib/proxy/tokenRefresh.d.ts +6 -0
  15. package/dist/lib/proxy/tokenRefresh.js +70 -15
  16. package/dist/lib/proxy/usageStats.d.ts +25 -3
  17. package/dist/lib/proxy/usageStats.js +546 -55
  18. package/dist/lib/server/routes/claudeProxyRoutes.d.ts +2 -0
  19. package/dist/lib/server/routes/claudeProxyRoutes.js +198 -222
  20. package/dist/lib/types/proxy.d.ts +60 -1
  21. package/dist/lib/utils/fileDetector.js +41 -7
  22. package/dist/lib/utils/imageProcessor.js +19 -20
  23. package/dist/lib/utils/isoBmff.d.ts +24 -0
  24. package/dist/lib/utils/isoBmff.js +83 -0
  25. package/dist/processors/config/mimeConstants.js +2 -0
  26. package/dist/proxy/proxyTranslationEngine.d.ts +1 -0
  27. package/dist/proxy/proxyTranslationEngine.js +56 -12
  28. package/dist/proxy/rawStreamCapture.js +47 -1
  29. package/dist/proxy/sseInterceptor.js +47 -1
  30. package/dist/proxy/tokenRefresh.d.ts +6 -0
  31. package/dist/proxy/tokenRefresh.js +70 -15
  32. package/dist/proxy/usageStats.d.ts +25 -3
  33. package/dist/proxy/usageStats.js +546 -55
  34. package/dist/server/routes/claudeProxyRoutes.d.ts +2 -0
  35. package/dist/server/routes/claudeProxyRoutes.js +198 -222
  36. package/dist/types/proxy.d.ts +60 -1
  37. package/dist/utils/fileDetector.js +41 -7
  38. package/dist/utils/imageProcessor.js +19 -20
  39. package/dist/utils/isoBmff.d.ts +24 -0
  40. package/dist/utils/isoBmff.js +82 -0
  41. package/package.json +3 -2
@@ -671,6 +671,29 @@ export type AnthropicUpstreamFetchResult = {
671
671
  sawNetworkError: boolean;
672
672
  upstreamSpan?: Span;
673
673
  };
674
+ export type ProxyTerminalErrorCategory = "authentication" | "client_cancelled" | "fallback_exhausted" | "invalid_request" | "proxy_error" | "rate_limit" | "stream_error" | "unclassified" | "upstream_error" | "other";
675
+ /** Compact, redacted terminal failure retained independently of body logs. */
676
+ export type ProxyTerminalErrorSummary = {
677
+ id: string;
678
+ at: number;
679
+ status: number;
680
+ category: ProxyTerminalErrorCategory;
681
+ requestId?: string;
682
+ account?: string;
683
+ accountType?: string;
684
+ errorType?: string;
685
+ errorCode?: string;
686
+ terminalOutcome?: string;
687
+ message?: string;
688
+ };
689
+ /** Optional terminal context supplied when a final request error is recorded. */
690
+ export type ProxyTerminalErrorDetails = {
691
+ requestId?: string;
692
+ errorType?: string;
693
+ errorCode?: string;
694
+ terminalOutcome?: string;
695
+ message?: string;
696
+ };
674
697
  export type AccountStats = {
675
698
  label: string;
676
699
  type: string;
@@ -700,6 +723,19 @@ export type ProxyStats = {
700
723
  totalQuotaRateLimits: number;
701
724
  accounts: Record<string, AccountStats>;
702
725
  };
726
+ /** Bounded terminal-error state stored separately from counters and body logs. */
727
+ export type ProxyTerminalErrorJournal = {
728
+ startedAt: number;
729
+ totalErrors: number;
730
+ counts: Record<ProxyTerminalErrorCategory, number>;
731
+ recent: ProxyTerminalErrorSummary[];
732
+ };
733
+ export type ProxyUsageStatsSnapshot = {
734
+ stats: ProxyStats;
735
+ statsVersion: number;
736
+ terminalErrors: ProxyTerminalErrorJournal;
737
+ terminalErrorsVersion: number;
738
+ };
703
739
  /** Durability and reconciliation state for the proxy usage counters. */
704
740
  export type ProxyStatsPersistenceStatus = {
705
741
  enabled: boolean;
@@ -712,6 +748,14 @@ export type ProxyStatsPersistenceStatus = {
712
748
  lastReconciledAt: number | null;
713
749
  lastRecoveryAt: number | null;
714
750
  lastError: string | null;
751
+ terminalErrorsFilePath?: string | null;
752
+ terminalErrorsRevision?: number;
753
+ terminalErrorsPending?: number;
754
+ terminalErrorsInFlight?: number;
755
+ terminalErrorsUnpersisted?: number;
756
+ terminalErrorsLastFlushedAt?: number | null;
757
+ terminalErrorsLastRecoveryAt?: number | null;
758
+ terminalErrorsLastError?: string | null;
715
759
  };
716
760
  /** Versioned on-disk snapshot shared by overlapping proxy workers. */
717
761
  export type PersistedProxyStatsSnapshot = {
@@ -720,6 +764,13 @@ export type PersistedProxyStatsSnapshot = {
720
764
  updatedAt: number;
721
765
  stats: ProxyStats;
722
766
  };
767
+ /** Versioned terminal-error snapshot isolated from rolling-worker counter writes. */
768
+ export type PersistedProxyTerminalErrorSnapshot = {
769
+ schemaVersion: 1;
770
+ revision: number;
771
+ updatedAt: number;
772
+ journal: ProxyTerminalErrorJournal;
773
+ };
723
774
  /** Ownership metadata for the proxy statistics cross-process lock. */
724
775
  export type ProxyStatsLockOwner = {
725
776
  token: string;
@@ -729,6 +780,7 @@ export type ProxyStatsLockOwner = {
729
780
  /** Construction options for an isolated proxy statistics store. */
730
781
  export type ProxyUsageStatsStoreOptions = {
731
782
  filePath?: string;
783
+ terminalErrorsFilePath?: string;
732
784
  flushIntervalMs?: number;
733
785
  lockTimeoutMs?: number;
734
786
  staleLockMs?: number;
@@ -1951,6 +2003,11 @@ export type StatusStats = {
1951
2003
  totalRateLimits: number;
1952
2004
  totalTransientRateLimits?: number;
1953
2005
  totalQuotaRateLimits?: number;
2006
+ terminalErrors?: ProxyTerminalErrorJournal;
2007
+ lastTerminalError?: ProxyTerminalErrorSummary | null;
2008
+ terminalErrorDetailsComparable?: boolean;
2009
+ terminalErrorDetailsMissing?: number;
2010
+ terminalErrorDetailsExcess?: number;
1954
2011
  accounts?: {
1955
2012
  label: string;
1956
2013
  type: string;
@@ -1963,7 +2020,9 @@ export type StatusStats = {
1963
2020
  transientRateLimits?: number;
1964
2021
  quotaRateLimits?: number;
1965
2022
  cooling: boolean;
1966
- status?: "active" | "cooling" | "disabled" | "excluded" | "removed" | "unattributed";
2023
+ allowed?: boolean;
2024
+ expired?: boolean;
2025
+ status?: "active" | "cooling" | "disabled" | "expired" | "excluded" | "removed" | "internal" | "unattributed";
1967
2026
  }[];
1968
2027
  persistence?: ProxyStatsPersistenceStatus;
1969
2028
  };
@@ -23,6 +23,7 @@ async function getArchiveProcessor() {
23
23
  import { tracers, ATTR, withSpan } from "../telemetry/index.js";
24
24
  import { CSVProcessor } from "./csvProcessor.js";
25
25
  import { ImageProcessor } from "./imageProcessor.js";
26
+ import { detectIsoBmffImageMimeType, hasFtypBoxSignature } from "./isoBmff.js";
26
27
  import { logger } from "./logger.js";
27
28
  import { withTimeout } from "./errorHandling.js";
28
29
  import { normalizeUrlForCache, redactUrlForError, sanitizeErrorCause, } from "./logSanitize.js";
@@ -1659,17 +1660,35 @@ class MagicBytesStrategy {
1659
1660
  if (this.isWebP(input)) {
1660
1661
  return this.result("image", "image/webp", 95);
1661
1662
  }
1663
+ if (input.length >= 2 && input[0] === 0x42 && input[1] === 0x4d) {
1664
+ return this.result("image", "image/bmp", 95);
1665
+ }
1666
+ if (input.length >= 4 &&
1667
+ ((input[0] === 0x49 &&
1668
+ input[1] === 0x49 &&
1669
+ input[2] === 0x2a &&
1670
+ input[3] === 0x00) ||
1671
+ (input[0] === 0x4d &&
1672
+ input[1] === 0x4d &&
1673
+ input[2] === 0x00 &&
1674
+ input[3] === 0x2a))) {
1675
+ return this.result("image", "image/tiff", 95);
1676
+ }
1677
+ if (input.length >= 4 &&
1678
+ input[0] === 0x00 &&
1679
+ input[1] === 0x00 &&
1680
+ input[2] === 0x01 &&
1681
+ input[3] === 0x00 &&
1682
+ !hasFtypBoxSignature(input)) {
1683
+ return this.result("image", "image/x-icon", 95);
1684
+ }
1662
1685
  if (this.isPDF(input)) {
1663
1686
  return this.result("pdf", "application/pdf", 95);
1664
1687
  }
1665
1688
  // ISO-BMFF ("ftyp" at offset 4): MP4 video, QuickTime MOV, or M4A/M4B/M4P
1666
1689
  // audio all share this box — disambiguate by the major brand at offset 8-11,
1667
1690
  // otherwise an .m4a audio file is misrouted to the video pipeline.
1668
- if (input.length >= 8 &&
1669
- input[4] === 0x66 &&
1670
- input[5] === 0x74 &&
1671
- input[6] === 0x79 &&
1672
- input[7] === 0x70) {
1691
+ if (hasFtypBoxSignature(input)) {
1673
1692
  const brand = input.length >= 12 ? input.toString("latin1", 8, 12) : "";
1674
1693
  // AVIF images share the ISO-BMFF ftyp box with MP4/MOV; the major brand
1675
1694
  // ('avif' still, 'avis' sequence, 'avio' intra-only AV1 image/sequence
@@ -1677,8 +1696,9 @@ class MagicBytesStrategy {
1677
1696
  // major_brand by real encoders) distinguishes them. Detect before the
1678
1697
  // audio/video branches so an AVIF buffer isn't misrouted to the video
1679
1698
  // pipeline (#286).
1680
- if (/^(avif|avis|avio)/.test(brand)) {
1681
- return this.result("image", "image/avif", 95);
1699
+ const imageMimeType = detectIsoBmffImageMimeType(input);
1700
+ if (imageMimeType) {
1701
+ return this.result("image", imageMimeType, 95);
1682
1702
  }
1683
1703
  if (/^(M4A|M4B|M4P|F4A|F4B)/.test(brand)) {
1684
1704
  return this.result("audio", "audio/mp4", 95);
@@ -1775,6 +1795,16 @@ class MagicBytesStrategy {
1775
1795
  if (input.length >= 2 && input[0] === 0x1f && input[1] === 0x8b) {
1776
1796
  return this.result("archive", "application/gzip", 90);
1777
1797
  }
1798
+ // 7z: 37 7A BC AF 27 1C
1799
+ if (input.length >= 6 &&
1800
+ input[0] === 0x37 &&
1801
+ input[1] === 0x7a &&
1802
+ input[2] === 0xbc &&
1803
+ input[3] === 0xaf &&
1804
+ input[4] === 0x27 &&
1805
+ input[5] === 0x1c) {
1806
+ return this.result("archive", "application/x-7z-compressed", 95);
1807
+ }
1778
1808
  // RAR: "Rar!"
1779
1809
  if (input.length >= 4 &&
1780
1810
  input[0] === 0x52 &&
@@ -2006,6 +2036,8 @@ class ExtensionStrategy {
2006
2036
  // AI providers don't support SVG format, so we process it as sanitized text
2007
2037
  svg: "svg",
2008
2038
  avif: "image",
2039
+ heic: "image",
2040
+ heif: "image",
2009
2041
  pdf: "pdf",
2010
2042
  // Video formats
2011
2043
  mp4: "video",
@@ -2179,6 +2211,8 @@ class ExtensionStrategy {
2179
2211
  tif: "image/tiff",
2180
2212
  svg: "image/svg+xml",
2181
2213
  avif: "image/avif",
2214
+ heic: "image/heic",
2215
+ heif: "image/heif",
2182
2216
  pdf: "application/pdf",
2183
2217
  // Video MIME types
2184
2218
  mp4: "video/mp4",
@@ -11,6 +11,7 @@ import { SYSTEM_LIMITS } from "../core/constants.js";
11
11
  import { SIZE_LIMITS_BYTES } from "../processors/config/sizeLimits.js";
12
12
  import { getImageCache } from "./imageCache.js";
13
13
  import { ErrorFactory, NeuroLinkError } from "./errorHandling.js";
14
+ import { detectIsoBmffImageMimeType, hasFtypBoxSignature } from "./isoBmff.js";
14
15
  /**
15
16
  * Minimum bytes required just to detect an image format from magic bytes (#293).
16
17
  */
@@ -104,7 +105,7 @@ function isRetryableDownloadError(error) {
104
105
  }
105
106
  /**
106
107
  * Reject `detectImageType()`'s `application/octet-stream` sentinel — the
107
- * honest "no known image signature (PNG/JPEG/GIF/WebP/BMP/TIFF/SVG/AVIF)
108
+ * honest "no known image signature (PNG/JPEG/GIF/WebP/BMP/TIFF/ICO/SVG/AVIF/HEIC/HEIF)
108
109
  * matched" fallback (#261/#286). Packaging that sentinel into a data URI
109
110
  * hands vision providers (OpenAI/Anthropic/Google) a MIME type they reject
110
111
  * outright with an HTTP 400, so every public conversion path that turns
@@ -118,7 +119,7 @@ function assertKnownImageType(mediaType) {
118
119
  if (mediaType === "application/octet-stream") {
119
120
  logger.error("Unable to detect a supported image format from file content");
120
121
  throw new Error("Unsupported or corrupted image: no known image signature " +
121
- "(PNG/JPEG/GIF/WebP/BMP/TIFF/SVG/AVIF) was found in the file content.");
122
+ "(PNG/JPEG/GIF/WebP/BMP/TIFF/ICO/SVG/AVIF/HEIC/HEIF) was found in the file content.");
122
123
  }
123
124
  }
124
125
  /**
@@ -457,24 +458,22 @@ export class ImageProcessor {
457
458
  return "image/svg+xml";
458
459
  }
459
460
  }
460
- // AVIF (ISOBMFF): "ftyp" box at offset 4, major brand at offset 8.
461
- // Accept the AVIF-spec brand variants 'avif' (still image), 'avis'
462
- // (image sequence), and 'avio' (intra-only AV1 image/sequence the
463
- // spec lists it under compatible_brands rather than major_brand, but
464
- // real-world encoders emit it as major_brand too, so it's accepted
465
- // here) and compare bytes directly rather than via Buffer.toString()
466
- // (which would utf-8-decode non-ASCII bytes) so the check is exact
467
- // (#286).
468
- if (input.length >= 12) {
469
- const isFtyp = input[4] === 0x66 && // 'f'
470
- input[5] === 0x74 && // 't'
471
- input[6] === 0x79 && // 'y'
472
- input[7] === 0x70; // 'p'
473
- const brand = String.fromCharCode(input[8], input[9], input[10], input[11]);
474
- if (isFtyp &&
475
- (brand === "avif" || brand === "avis" || brand === "avio")) {
476
- return "image/avif";
477
- }
461
+ // ISO-BMFF images: "ftyp" box at offset 4, major brand at offset 8.
462
+ // AVIF has codec-specific major brands. HEIC uses HEVC image/sequence
463
+ // brands, while generic HEIF structural brands (mif1/msf1) need a
464
+ // compatible codec brand to avoid mistaking AVIF or generic MP4 data
465
+ // for HEIF.
466
+ const isoBmffMimeType = detectIsoBmffImageMimeType(input);
467
+ if (isoBmffMimeType) {
468
+ return isoBmffMimeType;
469
+ }
470
+ // ICO: 00 00 01 00 (icon type=1)
471
+ if (input[0] === 0x00 &&
472
+ input[1] === 0x00 &&
473
+ input[2] === 0x01 &&
474
+ input[3] === 0x00 &&
475
+ !hasFtypBoxSignature(input)) {
476
+ return "image/x-icon";
478
477
  }
479
478
  // BMP: "BM" magic (0x42 0x4D)
480
479
  if (input[0] === 0x42 && input[1] === 0x4d) {
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Shared ISO-BMFF (`ftyp`) image-brand detection.
3
+ *
4
+ * FileDetector and ImageProcessor must use the same brand tables and declared
5
+ * box boundary when classifying AVIF, HEIC, and structural HEIF brands. Keeping
6
+ * the scan here prevents the two consumers from drifting as new brands are
7
+ * added.
8
+ */
9
+ export declare const AVIF_FTYP_BRANDS: Set<string>;
10
+ export declare const HEIC_FTYP_BRANDS: Set<string>;
11
+ export declare const HEIF_STRUCTURAL_FTYP_BRANDS: Set<string>;
12
+ /**
13
+ * Whether the buffer starts with an ISO-BMFF `ftyp` box signature.
14
+ */
15
+ export declare function hasFtypBoxSignature(input: Buffer): boolean;
16
+ /**
17
+ * Read compatible brands without scanning past the declared `ftyp` box.
18
+ */
19
+ export declare function getCompatibleFtypBrands(input: Buffer): Set<string>;
20
+ /**
21
+ * Return the image MIME type encoded by an ISO-BMFF `ftyp` box, or null when
22
+ * the box belongs to a non-image ISO-BMFF flavor.
23
+ */
24
+ export declare function detectIsoBmffImageMimeType(input: Buffer): string | null;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Shared ISO-BMFF (`ftyp`) image-brand detection.
3
+ *
4
+ * FileDetector and ImageProcessor must use the same brand tables and declared
5
+ * box boundary when classifying AVIF, HEIC, and structural HEIF brands. Keeping
6
+ * the scan here prevents the two consumers from drifting as new brands are
7
+ * added.
8
+ */
9
+ export const AVIF_FTYP_BRANDS = new Set(["avif", "avis", "avio"]);
10
+ export const HEIC_FTYP_BRANDS = new Set([
11
+ "heic",
12
+ "heix",
13
+ "hevc",
14
+ "hevx",
15
+ "heim",
16
+ "heis",
17
+ "hevm",
18
+ "hevs",
19
+ ]);
20
+ export const HEIF_STRUCTURAL_FTYP_BRANDS = new Set(["mif1", "msf1"]);
21
+ /**
22
+ * Whether the buffer starts with an ISO-BMFF `ftyp` box signature.
23
+ */
24
+ export function hasFtypBoxSignature(input) {
25
+ return (input.length >= 8 &&
26
+ input[4] === 0x66 &&
27
+ input[5] === 0x74 &&
28
+ input[6] === 0x79 &&
29
+ input[7] === 0x70);
30
+ }
31
+ /**
32
+ * Read compatible brands without scanning past the declared `ftyp` box.
33
+ */
34
+ export function getCompatibleFtypBrands(input) {
35
+ const compatibleBrands = new Set();
36
+ if (input.length < 20) {
37
+ return compatibleBrands;
38
+ }
39
+ const declaredBoxSize = input.readUInt32BE(0);
40
+ const boxEnd = declaredBoxSize === 0
41
+ ? input.length
42
+ : Math.min(input.length, declaredBoxSize);
43
+ for (let offset = 16; offset + 4 <= boxEnd; offset += 4) {
44
+ compatibleBrands.add(input.toString("latin1", offset, offset + 4));
45
+ }
46
+ return compatibleBrands;
47
+ }
48
+ function hasFtypBrand(compatibleBrands, candidates) {
49
+ for (const brand of compatibleBrands) {
50
+ if (candidates.has(brand)) {
51
+ return true;
52
+ }
53
+ }
54
+ return false;
55
+ }
56
+ /**
57
+ * Return the image MIME type encoded by an ISO-BMFF `ftyp` box, or null when
58
+ * the box belongs to a non-image ISO-BMFF flavor.
59
+ */
60
+ export function detectIsoBmffImageMimeType(input) {
61
+ if (input.length < 12 || !hasFtypBoxSignature(input)) {
62
+ return null;
63
+ }
64
+ const brand = input.toString("latin1", 8, 12);
65
+ if (AVIF_FTYP_BRANDS.has(brand)) {
66
+ return "image/avif";
67
+ }
68
+ if (HEIC_FTYP_BRANDS.has(brand)) {
69
+ return "image/heic";
70
+ }
71
+ if (!HEIF_STRUCTURAL_FTYP_BRANDS.has(brand)) {
72
+ return null;
73
+ }
74
+ const compatibleBrands = getCompatibleFtypBrands(input);
75
+ if (hasFtypBrand(compatibleBrands, HEIC_FTYP_BRANDS)) {
76
+ return "image/heif";
77
+ }
78
+ if (hasFtypBrand(compatibleBrands, AVIF_FTYP_BRANDS)) {
79
+ return "image/avif";
80
+ }
81
+ return null;
82
+ }
83
+ //# sourceMappingURL=isoBmff.js.map
@@ -283,6 +283,8 @@ export const EXTENSION_MIME_MAP = {
283
283
  ".webp": IMAGE_MIME_TYPES.WEBP,
284
284
  ".bmp": IMAGE_MIME_TYPES.BMP,
285
285
  ".ico": IMAGE_MIME_TYPES.ICO,
286
+ ".heic": IMAGE_MIME_TYPES.HEIC,
287
+ ".heif": IMAGE_MIME_TYPES.HEIF,
286
288
  // Source code
287
289
  ".js": TEXT_MIME_TYPES.JAVASCRIPT,
288
290
  ".mjs": TEXT_MIME_TYPES.JAVASCRIPT,
@@ -77,6 +77,7 @@ export declare function handleTranslatedJsonRequest(args: {
77
77
  attempts: ProxyTranslationAttempt[];
78
78
  tracer?: ProxyTracer;
79
79
  requestStartTime: number;
80
+ terminalFailureStatus?: number;
80
81
  }): Promise<unknown>;
81
82
  /**
82
83
  * Build the /v1/models response in OpenAI list format.
@@ -247,6 +247,7 @@ export async function handleTranslatedStreamRequest(args) {
247
247
  let keepAliveTimer;
248
248
  let cancelled = false;
249
249
  let succeeded = false;
250
+ let streamInterruptedAfterOutput = false;
250
251
  let translatedModel;
251
252
  let finalStreamError = "No translation providers succeeded";
252
253
  let upstreamIterator;
@@ -267,6 +268,9 @@ export async function handleTranslatedStreamRequest(args) {
267
268
  }, KEEPALIVE_INTERVAL_MS);
268
269
  try {
269
270
  for (let attemptIndex = 0; attemptIndex < attempts.length; attemptIndex++) {
271
+ if (cancelled) {
272
+ break;
273
+ }
270
274
  const attempt = attempts[attemptIndex];
271
275
  lastAttemptLabel = attempt.label ?? "translation";
272
276
  logger.always(`[proxy:${format}] attempt ${attemptIndex + 1}/${attempts.length}: ${attempt.label}`);
@@ -295,10 +299,14 @@ export async function handleTranslatedStreamRequest(args) {
295
299
  }
296
300
  }
297
301
  }
302
+ if (cancelled) {
303
+ return;
304
+ }
298
305
  const toolCalls = streamResult.toolCalls ?? [];
299
306
  if (!hasTranslatedOutput(collectedText, toolCalls)) {
300
307
  finalStreamError = `Translated provider ${attempt.label} returned no content or tool calls`;
301
308
  logger.debug(`${tag} translation attempt ${attempt.label} returned no content or tool calls`);
309
+ recordAttemptError(lastAttemptLabel, "translation", 502);
302
310
  continue;
303
311
  }
304
312
  if (!cancelled && toolCalls.length) {
@@ -331,7 +339,6 @@ export async function handleTranslatedStreamRequest(args) {
331
339
  tracer?.recordMetrics();
332
340
  translatedModel = streamResult.model;
333
341
  succeeded = true;
334
- recordFinalSuccess(lastAttemptLabel, "translation");
335
342
  return;
336
343
  }
337
344
  catch (streamErr) {
@@ -343,6 +350,8 @@ export async function handleTranslatedStreamRequest(args) {
343
350
  ? streamErr.message
344
351
  : String(streamErr);
345
352
  if (collectedText.trim().length > 0) {
353
+ streamInterruptedAfterOutput = true;
354
+ recordAttemptError(lastAttemptLabel, "translation", 502);
346
355
  logger.always(`${tag} mid-stream error: ${finalStreamError}`);
347
356
  for (const frame of serializer.emitError(`Upstream stream interrupted: ${finalStreamError}`)) {
348
357
  controller.enqueue(encoder.encode(frame));
@@ -354,7 +363,6 @@ export async function handleTranslatedStreamRequest(args) {
354
363
  }
355
364
  }
356
365
  // All attempts exhausted
357
- recordFinalError(500, lastAttemptLabel, "translation");
358
366
  if (!cancelled) {
359
367
  logger.always(`${tag} all translation attempts failed: ${finalStreamError}`);
360
368
  for (const frame of serializer.emitError(finalStreamError)) {
@@ -372,12 +380,38 @@ export async function handleTranslatedStreamRequest(args) {
372
380
  if (tracer && translatedModel && translatedModel !== requestModel) {
373
381
  tracer.setModelSubstitution(requestModel, translatedModel);
374
382
  }
375
- if (!succeeded) {
376
- tracer?.setError("generation_error", finalStreamError.slice(0, 500));
383
+ const terminalStatus = cancelled
384
+ ? 499
385
+ : succeeded
386
+ ? 200
387
+ : streamInterruptedAfterOutput
388
+ ? 502
389
+ : 500;
390
+ const terminalErrorType = cancelled
391
+ ? "client_cancelled"
392
+ : streamInterruptedAfterOutput
393
+ ? "stream_error"
394
+ : succeeded
395
+ ? undefined
396
+ : "generation_error";
397
+ const terminalErrorMessage = cancelled
398
+ ? "Client cancelled the streaming response"
399
+ : succeeded
400
+ ? undefined
401
+ : finalStreamError;
402
+ if (terminalErrorType && terminalErrorMessage) {
403
+ tracer?.setError(terminalErrorType, terminalErrorMessage.slice(0, 500));
404
+ recordFinalError(terminalStatus, lastAttemptLabel, "translation", {
405
+ requestId: ctx.requestId,
406
+ errorType: terminalErrorType,
407
+ terminalOutcome: terminalErrorType,
408
+ message: terminalErrorMessage,
409
+ });
410
+ }
411
+ else {
412
+ recordFinalSuccess(lastAttemptLabel, "translation");
377
413
  }
378
- // Use the real outcome status so trace data matches the logged
379
- // responseStatus below (success path is 200, exhausted-attempts path is 500).
380
- tracer?.end(succeeded ? 200 : 500, Date.now() - requestStartTime);
414
+ tracer?.end(terminalStatus, Date.now() - requestStartTime);
381
415
  const traceCtx = tracer?.getTraceContext();
382
416
  logRequest({
383
417
  timestamp: new Date().toISOString(),
@@ -389,8 +423,12 @@ export async function handleTranslatedStreamRequest(args) {
389
423
  toolCount: Object.keys(parsed.tools).length,
390
424
  account: "translation",
391
425
  accountType: "translation",
392
- responseStatus: succeeded ? 200 : 500,
426
+ responseStatus: terminalStatus,
393
427
  responseTimeMs: Date.now() - requestStartTime,
428
+ ...(terminalErrorType ? { errorType: terminalErrorType } : {}),
429
+ ...(terminalErrorMessage
430
+ ? { errorMessage: terminalErrorMessage.slice(0, 500) }
431
+ : {}),
394
432
  ...(traceCtx?.traceId ? { traceId: traceCtx.traceId } : {}),
395
433
  ...(traceCtx?.spanId ? { spanId: traceCtx.spanId } : {}),
396
434
  });
@@ -424,7 +462,7 @@ export async function handleTranslatedStreamRequest(args) {
424
462
  * Handles a translated non-streaming request for either Claude or OpenAI format.
425
463
  */
426
464
  export async function handleTranslatedJsonRequest(args) {
427
- const { ctx, format, requestModel, parsed, attempts, tracer, requestStartTime, } = args;
465
+ const { ctx, format, requestModel, parsed, attempts, tracer, requestStartTime, terminalFailureStatus = 500, } = args;
428
466
  const tag = logTag(format);
429
467
  let lastAttemptError = "No translation providers succeeded";
430
468
  let lastAttemptLabel = "translation";
@@ -448,6 +486,7 @@ export async function handleTranslatedJsonRequest(args) {
448
486
  if (!hasTranslatedOutput(collectedText, streamResult.toolCalls)) {
449
487
  lastAttemptError = `Translated provider ${attempt.label} returned no content or tool calls`;
450
488
  logger.debug(`${tag} translation attempt ${attempt.label} returned no content or tool calls`);
489
+ recordAttemptError(lastAttemptLabel, "translation", 502);
451
490
  continue;
452
491
  }
453
492
  const internal = {
@@ -505,9 +544,14 @@ export async function handleTranslatedJsonRequest(args) {
505
544
  recordAttemptError(lastAttemptLabel, "translation", 500);
506
545
  }
507
546
  }
508
- recordFinalError(500, lastAttemptLabel, "translation");
547
+ recordFinalError(terminalFailureStatus, lastAttemptLabel, "translation", {
548
+ requestId: ctx.requestId,
549
+ errorType: "generation_error",
550
+ terminalOutcome: "handler_error",
551
+ message: lastAttemptError,
552
+ });
509
553
  tracer?.setError("generation_error", lastAttemptError.slice(0, 500));
510
- tracer?.end(500, Date.now() - requestStartTime);
554
+ tracer?.end(terminalFailureStatus, Date.now() - requestStartTime);
511
555
  const traceCtx = tracer?.getTraceContext();
512
556
  logRequest({
513
557
  timestamp: new Date().toISOString(),
@@ -519,7 +563,7 @@ export async function handleTranslatedJsonRequest(args) {
519
563
  toolCount: Object.keys(parsed.tools).length,
520
564
  account: "translation",
521
565
  accountType: "translation",
522
- responseStatus: 500,
566
+ responseStatus: terminalFailureStatus,
523
567
  responseTimeMs: Date.now() - requestStartTime,
524
568
  errorType: "generation_error",
525
569
  errorMessage: lastAttemptError.slice(0, 500),
@@ -60,7 +60,11 @@ export function createRawStreamCapture() {
60
60
  },
61
61
  });
62
62
  const innerWriter = transform.writable.getWriter();
63
+ let writableController;
63
64
  const writable = new WritableStream({
65
+ start(controller) {
66
+ writableController = controller;
67
+ },
64
68
  write(chunk) {
65
69
  return innerWriter.write(chunk);
66
70
  },
@@ -72,9 +76,51 @@ export function createRawStreamCapture() {
72
76
  return innerWriter.abort(reason);
73
77
  },
74
78
  });
79
+ // A downstream reader cancellation errors the TransformStream's writable
80
+ // side, but this wrapper otherwise hides that state from the upstream pipe.
81
+ // Mirror it onto the wrapper so cancellation reaches the source reader.
82
+ void innerWriter.closed.catch((reason) => {
83
+ settle();
84
+ try {
85
+ writableController.error(reason);
86
+ }
87
+ catch {
88
+ // The wrapper may already be closing or aborted.
89
+ }
90
+ });
91
+ const innerReader = transform.readable.getReader();
92
+ const readable = new ReadableStream({
93
+ async pull(controller) {
94
+ try {
95
+ const { done, value } = await innerReader.read();
96
+ if (done) {
97
+ controller.close();
98
+ }
99
+ else {
100
+ controller.enqueue(value);
101
+ }
102
+ }
103
+ catch (error) {
104
+ controller.error(error);
105
+ }
106
+ },
107
+ async cancel(reason) {
108
+ settle();
109
+ try {
110
+ writableController.error(reason);
111
+ }
112
+ catch {
113
+ // The wrapper may already be closing or aborted.
114
+ }
115
+ await Promise.allSettled([
116
+ innerReader.cancel(reason),
117
+ innerWriter.abort(reason),
118
+ ]);
119
+ },
120
+ });
75
121
  return {
76
122
  stream: {
77
- readable: transform.readable,
123
+ readable,
78
124
  writable,
79
125
  },
80
126
  capture,