@copilotkit/aimock 1.46.0 → 1.47.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 (183) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +12 -1
  4. package/README.md +4 -1
  5. package/dist/aws-event-stream.cjs +20 -23
  6. package/dist/aws-event-stream.cjs.map +1 -1
  7. package/dist/aws-event-stream.d.cts +0 -1
  8. package/dist/aws-event-stream.d.ts +0 -1
  9. package/dist/aws-event-stream.js +20 -24
  10. package/dist/aws-event-stream.js.map +1 -1
  11. package/dist/bedrock-converse.cjs +79 -82
  12. package/dist/bedrock-converse.cjs.map +1 -1
  13. package/dist/bedrock-converse.d.cts.map +1 -1
  14. package/dist/bedrock-converse.d.ts.map +1 -1
  15. package/dist/bedrock-converse.js +80 -83
  16. package/dist/bedrock-converse.js.map +1 -1
  17. package/dist/bedrock.cjs +112 -121
  18. package/dist/bedrock.cjs.map +1 -1
  19. package/dist/bedrock.d.cts.map +1 -1
  20. package/dist/bedrock.d.ts.map +1 -1
  21. package/dist/bedrock.js +113 -122
  22. package/dist/bedrock.js.map +1 -1
  23. package/dist/cohere.cjs +58 -106
  24. package/dist/cohere.cjs.map +1 -1
  25. package/dist/cohere.d.cts.map +1 -1
  26. package/dist/cohere.d.ts.map +1 -1
  27. package/dist/cohere.js +60 -108
  28. package/dist/cohere.js.map +1 -1
  29. package/dist/config-loader.d.cts.map +1 -1
  30. package/dist/config-loader.d.ts.map +1 -1
  31. package/dist/fixture-loader.cjs +103 -2
  32. package/dist/fixture-loader.cjs.map +1 -1
  33. package/dist/fixture-loader.d.cts.map +1 -1
  34. package/dist/fixture-loader.d.ts.map +1 -1
  35. package/dist/fixture-loader.js +104 -4
  36. package/dist/fixture-loader.js.map +1 -1
  37. package/dist/gemini-interactions.cjs +63 -121
  38. package/dist/gemini-interactions.cjs.map +1 -1
  39. package/dist/gemini-interactions.d.cts.map +1 -1
  40. package/dist/gemini-interactions.d.ts.map +1 -1
  41. package/dist/gemini-interactions.js +65 -123
  42. package/dist/gemini-interactions.js.map +1 -1
  43. package/dist/gemini.cjs +242 -155
  44. package/dist/gemini.cjs.map +1 -1
  45. package/dist/gemini.d.cts.map +1 -1
  46. package/dist/gemini.d.ts.map +1 -1
  47. package/dist/gemini.js +244 -157
  48. package/dist/gemini.js.map +1 -1
  49. package/dist/helpers.cjs +400 -7
  50. package/dist/helpers.cjs.map +1 -1
  51. package/dist/helpers.d.cts +51 -1
  52. package/dist/helpers.d.cts.map +1 -1
  53. package/dist/helpers.d.ts +51 -1
  54. package/dist/helpers.d.ts.map +1 -1
  55. package/dist/helpers.js +391 -8
  56. package/dist/helpers.js.map +1 -1
  57. package/dist/index.cjs +4 -2
  58. package/dist/index.d.cts +4 -4
  59. package/dist/index.d.ts +4 -4
  60. package/dist/index.js +4 -4
  61. package/dist/mcp-fakes.d.cts.map +1 -1
  62. package/dist/messages.cjs +159 -141
  63. package/dist/messages.cjs.map +1 -1
  64. package/dist/messages.d.cts.map +1 -1
  65. package/dist/messages.d.ts.map +1 -1
  66. package/dist/messages.js +161 -143
  67. package/dist/messages.js.map +1 -1
  68. package/dist/misbehavior.cjs +62 -3
  69. package/dist/misbehavior.cjs.map +1 -1
  70. package/dist/misbehavior.d.cts.map +1 -1
  71. package/dist/misbehavior.d.ts.map +1 -1
  72. package/dist/misbehavior.js +63 -4
  73. package/dist/misbehavior.js.map +1 -1
  74. package/dist/ndjson-writer.cjs +21 -23
  75. package/dist/ndjson-writer.cjs.map +1 -1
  76. package/dist/ndjson-writer.d.cts +0 -1
  77. package/dist/ndjson-writer.d.cts.map +1 -1
  78. package/dist/ndjson-writer.d.ts +0 -1
  79. package/dist/ndjson-writer.d.ts.map +1 -1
  80. package/dist/ndjson-writer.js +21 -24
  81. package/dist/ndjson-writer.js.map +1 -1
  82. package/dist/ollama.cjs +153 -147
  83. package/dist/ollama.cjs.map +1 -1
  84. package/dist/ollama.d.cts.map +1 -1
  85. package/dist/ollama.d.ts.map +1 -1
  86. package/dist/ollama.js +155 -149
  87. package/dist/ollama.js.map +1 -1
  88. package/dist/openrouter-chat.cjs +1 -1
  89. package/dist/openrouter-chat.js +1 -1
  90. package/dist/raw-validation.cjs +177 -0
  91. package/dist/raw-validation.cjs.map +1 -0
  92. package/dist/raw-validation.js +176 -0
  93. package/dist/raw-validation.js.map +1 -0
  94. package/dist/response/deliver.cjs +144 -0
  95. package/dist/response/deliver.cjs.map +1 -0
  96. package/dist/response/deliver.js +143 -0
  97. package/dist/response/deliver.js.map +1 -0
  98. package/dist/response/frame-loop.cjs +102 -0
  99. package/dist/response/frame-loop.cjs.map +1 -0
  100. package/dist/response/frame-loop.js +100 -0
  101. package/dist/response/frame-loop.js.map +1 -0
  102. package/dist/response/lifecycle.cjs +223 -0
  103. package/dist/response/lifecycle.cjs.map +1 -0
  104. package/dist/response/lifecycle.js +211 -0
  105. package/dist/response/lifecycle.js.map +1 -0
  106. package/dist/response/proxy.cjs +71 -0
  107. package/dist/response/proxy.cjs.map +1 -0
  108. package/dist/response/proxy.js +70 -0
  109. package/dist/response/proxy.js.map +1 -0
  110. package/dist/response/raw.cjs +228 -0
  111. package/dist/response/raw.cjs.map +1 -0
  112. package/dist/response/raw.js +227 -0
  113. package/dist/response/raw.js.map +1 -0
  114. package/dist/response/timing.cjs +157 -0
  115. package/dist/response/timing.cjs.map +1 -0
  116. package/dist/response/timing.js +150 -0
  117. package/dist/response/timing.js.map +1 -0
  118. package/dist/responses.cjs +269 -148
  119. package/dist/responses.cjs.map +1 -1
  120. package/dist/responses.d.cts.map +1 -1
  121. package/dist/responses.d.ts.map +1 -1
  122. package/dist/responses.js +270 -149
  123. package/dist/responses.js.map +1 -1
  124. package/dist/router.cjs +20 -4
  125. package/dist/router.cjs.map +1 -1
  126. package/dist/router.d.cts +7 -1
  127. package/dist/router.d.cts.map +1 -1
  128. package/dist/router.d.ts +7 -1
  129. package/dist/router.d.ts.map +1 -1
  130. package/dist/router.js +21 -5
  131. package/dist/router.js.map +1 -1
  132. package/dist/server.cjs +89 -120
  133. package/dist/server.cjs.map +1 -1
  134. package/dist/server.d.cts.map +1 -1
  135. package/dist/server.d.ts.map +1 -1
  136. package/dist/server.js +91 -122
  137. package/dist/server.js.map +1 -1
  138. package/dist/sse-writer.cjs +49 -30
  139. package/dist/sse-writer.cjs.map +1 -1
  140. package/dist/sse-writer.d.cts +0 -1
  141. package/dist/sse-writer.d.cts.map +1 -1
  142. package/dist/sse-writer.d.ts +0 -1
  143. package/dist/sse-writer.d.ts.map +1 -1
  144. package/dist/sse-writer.js +49 -31
  145. package/dist/sse-writer.js.map +1 -1
  146. package/dist/transcription.cjs +19 -33
  147. package/dist/transcription.cjs.map +1 -1
  148. package/dist/transcription.d.cts.map +1 -1
  149. package/dist/transcription.d.ts.map +1 -1
  150. package/dist/transcription.js +20 -34
  151. package/dist/transcription.js.map +1 -1
  152. package/dist/types.cjs.map +1 -1
  153. package/dist/types.d.cts +173 -10
  154. package/dist/types.d.cts.map +1 -1
  155. package/dist/types.d.ts +173 -10
  156. package/dist/types.d.ts.map +1 -1
  157. package/dist/types.js.map +1 -1
  158. package/dist/ws-framing.cjs +12 -6
  159. package/dist/ws-framing.cjs.map +1 -1
  160. package/dist/ws-framing.d.cts +2 -0
  161. package/dist/ws-framing.d.cts.map +1 -1
  162. package/dist/ws-framing.d.ts +2 -0
  163. package/dist/ws-framing.d.ts.map +1 -1
  164. package/dist/ws-framing.js +12 -6
  165. package/dist/ws-framing.js.map +1 -1
  166. package/dist/ws-gemini-live.cjs +120 -93
  167. package/dist/ws-gemini-live.cjs.map +1 -1
  168. package/dist/ws-gemini-live.js +120 -93
  169. package/dist/ws-gemini-live.js.map +1 -1
  170. package/dist/ws-realtime.cjs +80 -70
  171. package/dist/ws-realtime.cjs.map +1 -1
  172. package/dist/ws-realtime.d.cts.map +1 -1
  173. package/dist/ws-realtime.d.ts.map +1 -1
  174. package/dist/ws-realtime.js +80 -70
  175. package/dist/ws-realtime.js.map +1 -1
  176. package/dist/ws-responses.cjs +72 -53
  177. package/dist/ws-responses.cjs.map +1 -1
  178. package/dist/ws-responses.d.cts.map +1 -1
  179. package/dist/ws-responses.d.ts.map +1 -1
  180. package/dist/ws-responses.js +73 -54
  181. package/dist/ws-responses.js.map +1 -1
  182. package/package.json +1 -1
  183. package/skills/write-fixtures/SKILL.md +34 -2
@@ -1,13 +1,18 @@
1
+ const require_frame_loop = require('./response/frame-loop.cjs');
1
2
 
2
3
  //#region src/sse-writer.ts
3
4
  function delay(ms, signal) {
4
5
  if (ms <= 0 || signal?.aborted) return Promise.resolve();
5
6
  return new Promise((resolve) => {
6
- const timer = setTimeout(resolve, ms);
7
- signal?.addEventListener("abort", () => {
7
+ const onAbort = () => {
8
8
  clearTimeout(timer);
9
9
  resolve();
10
- }, { once: true });
10
+ };
11
+ const timer = setTimeout(() => {
12
+ signal?.removeEventListener("abort", onAbort);
13
+ resolve();
14
+ }, ms);
15
+ signal?.addEventListener("abort", onAbort, { once: true });
11
16
  });
12
17
  }
13
18
  /** OpenRouter's SSE keepalive comment line (colon-prefixed, per SSE syntax). */
@@ -48,33 +53,45 @@ function calculateTotalDelay(contentLength, chunkSize, profile, recordedTimings,
48
53
  }
49
54
  async function writeSSEStream(res, chunks, optionsOrLatency) {
50
55
  const opts = typeof optionsOrLatency === "number" ? { latency: optionsOrLatency } : optionsOrLatency ?? {};
51
- const latency = opts.latency ?? 0;
52
- const profile = opts.streamingProfile;
53
- const { recordedTimings, replaySpeed } = opts;
54
- const signal = opts.signal;
55
- const onChunkSent = opts.onChunkSent;
56
- if (res.writableEnded) return true;
57
- res.setHeader("Content-Type", "text/event-stream");
58
- res.setHeader("Cache-Control", "no-cache");
59
- res.setHeader("Connection", "keep-alive");
60
- if (opts.openRouterProcessing && !res.writableEnded) res.write(OPENROUTER_PROCESSING_COMMENT);
61
- let chunkIndex = 0;
62
- for (const chunk of chunks) {
63
- const chunkDelay = calculateDelay(chunkIndex, profile, latency, recordedTimings, replaySpeed);
64
- if (chunkDelay > 0) await delay(chunkDelay, signal);
65
- if (signal?.aborted) return false;
66
- if (res.writableEnded) return true;
67
- res.write(`data: ${JSON.stringify(chunk)}\n\n`);
68
- onChunkSent?.();
69
- if (signal?.aborted) return false;
70
- chunkIndex++;
71
- }
72
- if (!res.writableEnded) {
73
- if (opts.usageChunk) res.write(`data: ${JSON.stringify(opts.usageChunk)}\n\n`);
74
- res.write("data: [DONE]\n\n");
75
- res.end();
76
- }
77
- return true;
56
+ return require_frame_loop.runFrameLoop(require_frame_loop.httpSink(res), chunks, sseCodec({
57
+ done: true,
58
+ usageChunk: opts.usageChunk,
59
+ openRouterProcessing: opts.openRouterProcessing
60
+ }), {
61
+ latency: opts.latency,
62
+ streamingProfile: opts.streamingProfile,
63
+ recordedTimings: opts.recordedTimings,
64
+ replaySpeed: opts.replaySpeed
65
+ }, {
66
+ signal: chunks.length > 0 ? opts.signal : void 0,
67
+ onChunkSent: opts.onChunkSent
68
+ });
69
+ }
70
+ const SSE_HEADERS = {
71
+ "Content-Type": "text/event-stream",
72
+ "Cache-Control": "no-cache",
73
+ Connection: "keep-alive"
74
+ };
75
+ function sseCodec(opts) {
76
+ return {
77
+ headers: SSE_HEADERS,
78
+ ...opts.openRouterProcessing ? { prelude: () => OPENROUTER_PROCESSING_COMMENT } : {},
79
+ encode: (frame) => `data: ${JSON.stringify(frame)}\n\n`,
80
+ trailer: () => {
81
+ if (!opts.done) return [];
82
+ const parts = [];
83
+ if (opts.usageChunk) parts.push(`data: ${JSON.stringify(opts.usageChunk)}\n\n`);
84
+ parts.push("data: [DONE]\n\n");
85
+ return parts;
86
+ }
87
+ };
88
+ }
89
+ function typedSseCodec(opts) {
90
+ return {
91
+ headers: SSE_HEADERS,
92
+ encode: (frame) => `event: ${frame.type}\ndata: ${JSON.stringify(frame)}\n\n`,
93
+ trailer: () => opts?.terminalSentinel ? ["data: [DONE]\n\n"] : []
94
+ };
78
95
  }
79
96
  /**
80
97
  * Default rate-limit response headers matching OpenAI's format.
@@ -102,6 +119,8 @@ function writeErrorResponse(res, status, body, options) {
102
119
  exports.calculateDelay = calculateDelay;
103
120
  exports.calculateTotalDelay = calculateTotalDelay;
104
121
  exports.delay = delay;
122
+ exports.sseCodec = sseCodec;
123
+ exports.typedSseCodec = typedSseCodec;
105
124
  exports.writeErrorResponse = writeErrorResponse;
106
125
  exports.writeSSEStream = writeSSEStream;
107
126
  //# sourceMappingURL=sse-writer.cjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"sse-writer.cjs","names":[],"sources":["../src/sse-writer.ts"],"sourcesContent":["import type * as http from \"node:http\";\nimport type { SSEChunk, StreamingProfile, RecordedTimings } from \"./types.js\";\n\nexport function delay(ms: number, signal?: AbortSignal): Promise<void> {\n if (ms <= 0 || signal?.aborted) return Promise.resolve();\n return new Promise((resolve) => {\n const timer = setTimeout(resolve, ms);\n signal?.addEventListener(\n \"abort\",\n () => {\n clearTimeout(timer);\n resolve();\n },\n { once: true },\n );\n });\n}\n\nexport interface StreamOptions {\n latency?: number;\n streamingProfile?: StreamingProfile;\n recordedTimings?: RecordedTimings;\n replaySpeed?: number;\n signal?: AbortSignal;\n onChunkSent?: () => void;\n /** When set, emitted as the final chunk before [DONE] (OpenAI stream_options.include_usage). */\n usageChunk?: SSEChunk;\n /**\n * When true, a single OpenRouter `: OPENROUTER PROCESSING` SSE comment\n * (keepalive) line is emitted FIRST — before the first data chunk — matching\n * real OpenRouter bytes (or-capture/chat-stream.sse). A faithful client skips\n * `:`-prefixed lines; opt-in, default off (so strict OpenAI parsers aren't\n * surprised). Used only by the OpenRouter chat surface.\n */\n openRouterProcessing?: boolean;\n}\n\n/** OpenRouter's SSE keepalive comment line (colon-prefixed, per SSE syntax). */\nconst OPENROUTER_PROCESSING_COMMENT = \": OPENROUTER PROCESSING\\n\\n\";\n\nexport function calculateDelay(\n chunkIndex: number,\n profile?: StreamingProfile,\n fallbackLatency?: number,\n recordedTimings?: RecordedTimings,\n replaySpeed?: number,\n): number {\n const speed = replaySpeed ?? 1.0;\n let delayMs: number;\n\n if (profile) {\n // StreamingProfile has highest precedence\n let fromProfile = true;\n if (chunkIndex === 0 && profile.ttft !== undefined) {\n delayMs = profile.ttft;\n } else if (profile.tps !== undefined && profile.tps > 0) {\n delayMs = 1000 / profile.tps;\n } else {\n delayMs = fallbackLatency ?? 0;\n fromProfile = false;\n }\n // Jitter only applies when the delay came from ttft/tps, not fallback\n if (fromProfile && profile.jitter && profile.jitter > 0) {\n delayMs *= 1 + (Math.random() * 2 - 1) * profile.jitter;\n if (delayMs < 0) delayMs = 0;\n }\n } else if (recordedTimings) {\n // Recorded timings (second precedence)\n if (chunkIndex === 0) {\n delayMs = recordedTimings.ttftMs;\n } else {\n const idx = chunkIndex - 1;\n if (idx < recordedTimings.interChunkDelaysMs.length) {\n delayMs = recordedTimings.interChunkDelaysMs[idx];\n } else {\n // Excess chunks: derive average from recorded inter-chunk delays\n const totalInterChunk = recordedTimings.interChunkDelaysMs.reduce((a, b) => a + b, 0);\n delayMs =\n recordedTimings.interChunkDelaysMs.length > 0\n ? totalInterChunk / recordedTimings.interChunkDelaysMs.length\n : 0;\n }\n }\n } else {\n delayMs = fallbackLatency ?? 0;\n }\n\n delayMs = Math.max(0, delayMs);\n return speed > 0 ? delayMs / speed : delayMs;\n}\n\n// Internal (not part of the public API): the total wait before a non-streaming\n// (`stream: false`) JSON response is written. Sums calculateDelay over the\n// content chunk count that `contentLength` at `chunkSize` would produce, so a\n// fixture with an explicit `streamingProfile` or `recordedTimings` pays roughly\n// the same ttft/tps cost as its streamed content chunks. It is an\n// approximation, not an exact match: a streamed reply also pays delays for its\n// role and finish chunks. There is deliberately no fallback-latency parameter:\n// bare `latency` / `--latency` is an inter-SSE-chunk gap and never delays a\n// non-streaming response. With neither a profile nor recorded timings, this\n// returns 0.\n// Kept as a line comment, not JSDoc: the bundled .d.ts drops this non-public\n// declaration but keeps a JSDoc block, which would then attach to writeSSEStream.\nexport function calculateTotalDelay(\n contentLength: number,\n chunkSize: number,\n profile?: StreamingProfile,\n recordedTimings?: RecordedTimings,\n replaySpeed?: number,\n): number {\n const chunkCount = Math.max(1, Math.ceil(contentLength / chunkSize));\n let total = 0;\n for (let chunkIndex = 0; chunkIndex < chunkCount; chunkIndex++) {\n total += calculateDelay(chunkIndex, profile, undefined, recordedTimings, replaySpeed);\n }\n return total;\n}\n\nexport async function writeSSEStream(\n res: http.ServerResponse,\n chunks: SSEChunk[],\n optionsOrLatency?: number | StreamOptions,\n): Promise<boolean> {\n const opts: StreamOptions =\n typeof optionsOrLatency === \"number\" ? { latency: optionsOrLatency } : (optionsOrLatency ?? {});\n const latency = opts.latency ?? 0;\n const profile = opts.streamingProfile;\n const { recordedTimings, replaySpeed } = opts;\n const signal = opts.signal;\n const onChunkSent = opts.onChunkSent;\n\n if (res.writableEnded) return true;\n res.setHeader(\"Content-Type\", \"text/event-stream\");\n res.setHeader(\"Cache-Control\", \"no-cache\");\n res.setHeader(\"Connection\", \"keep-alive\");\n\n // OpenRouter emits exactly ONE `: OPENROUTER PROCESSING` keepalive comment\n // first — before any data frame (opt-in; see or-capture/chat-stream.sse). A\n // faithful client discards `:`-prefixed lines before JSON-parsing a `data:`.\n if (opts.openRouterProcessing && !res.writableEnded) {\n res.write(OPENROUTER_PROCESSING_COMMENT);\n }\n\n let chunkIndex = 0;\n for (const chunk of chunks) {\n const chunkDelay = calculateDelay(chunkIndex, profile, latency, recordedTimings, replaySpeed);\n if (chunkDelay > 0) {\n await delay(chunkDelay, signal);\n }\n if (signal?.aborted) return false;\n if (res.writableEnded) return true;\n res.write(`data: ${JSON.stringify(chunk)}\\n\\n`);\n onChunkSent?.();\n if (signal?.aborted) return false;\n chunkIndex++;\n }\n\n if (!res.writableEnded) {\n if (opts.usageChunk) {\n res.write(`data: ${JSON.stringify(opts.usageChunk)}\\n\\n`);\n }\n res.write(\"data: [DONE]\\n\\n\");\n res.end();\n }\n return true;\n}\n\n/**\n * Default rate-limit response headers matching OpenAI's format.\n * Values are static — aimock doesn't track actual request counts.\n */\nconst RATE_LIMIT_HEADERS: Record<string, string> = {\n \"x-ratelimit-limit-requests\": \"60\",\n \"x-ratelimit-limit-tokens\": \"150000\",\n \"x-ratelimit-remaining-requests\": \"0\",\n \"x-ratelimit-remaining-tokens\": \"0\",\n \"x-ratelimit-reset-requests\": \"1s\",\n \"x-ratelimit-reset-tokens\": \"6m0s\",\n};\n\nexport interface ErrorResponseOptions {\n /** Override the Retry-After header value (seconds). Default: 1. Only applied on 429. */\n retryAfter?: number;\n}\n\nexport function writeErrorResponse(\n res: http.ServerResponse,\n status: number,\n body: string,\n options?: ErrorResponseOptions,\n): void {\n const headers: Record<string, string> = { \"Content-Type\": \"application/json\" };\n if (status === 429) {\n headers[\"Retry-After\"] = String(options?.retryAfter ?? 1);\n Object.assign(headers, RATE_LIMIT_HEADERS);\n }\n res.writeHead(status, headers);\n res.end(body);\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"sse-writer.cjs","names":["runFrameLoop","httpSink"],"sources":["../src/sse-writer.ts"],"sourcesContent":["import type * as http from \"node:http\";\nimport type { SSEChunk, StreamingProfile, RecordedTimings } from \"./types.js\";\nimport { httpSink, runFrameLoop, type FrameCodec } from \"./response/frame-loop.js\";\n\nexport function delay(ms: number, signal?: AbortSignal): Promise<void> {\n if (ms <= 0 || signal?.aborted) return Promise.resolve();\n return new Promise((resolve) => {\n const onAbort = (): void => {\n clearTimeout(timer);\n resolve();\n };\n // Remove the abort listener when the timer fires, so a long stream sharing\n // one lifecycle signal does not keep one listener per delayed frame.\n const timer = setTimeout(() => {\n signal?.removeEventListener(\"abort\", onAbort);\n resolve();\n }, ms);\n signal?.addEventListener(\"abort\", onAbort, { once: true });\n });\n}\n\nexport interface StreamOptions {\n latency?: number;\n streamingProfile?: StreamingProfile;\n recordedTimings?: RecordedTimings;\n replaySpeed?: number;\n signal?: AbortSignal;\n onChunkSent?: () => void;\n /** When set, emitted as the final chunk before [DONE] (OpenAI stream_options.include_usage). */\n usageChunk?: SSEChunk;\n /**\n * When true, a single OpenRouter `: OPENROUTER PROCESSING` SSE comment\n * (keepalive) line is emitted FIRST — before the first data chunk — matching\n * real OpenRouter bytes (or-capture/chat-stream.sse). A faithful client skips\n * `:`-prefixed lines; opt-in, default off (so strict OpenAI parsers aren't\n * surprised). Used only by the OpenRouter chat surface.\n */\n openRouterProcessing?: boolean;\n}\n\n/** OpenRouter's SSE keepalive comment line (colon-prefixed, per SSE syntax). */\nconst OPENROUTER_PROCESSING_COMMENT = \": OPENROUTER PROCESSING\\n\\n\";\n\nexport function calculateDelay(\n chunkIndex: number,\n profile?: StreamingProfile,\n fallbackLatency?: number,\n recordedTimings?: RecordedTimings,\n replaySpeed?: number,\n): number {\n const speed = replaySpeed ?? 1.0;\n let delayMs: number;\n\n if (profile) {\n // StreamingProfile has highest precedence\n let fromProfile = true;\n if (chunkIndex === 0 && profile.ttft !== undefined) {\n delayMs = profile.ttft;\n } else if (profile.tps !== undefined && profile.tps > 0) {\n delayMs = 1000 / profile.tps;\n } else {\n delayMs = fallbackLatency ?? 0;\n fromProfile = false;\n }\n // Jitter only applies when the delay came from ttft/tps, not fallback\n if (fromProfile && profile.jitter && profile.jitter > 0) {\n delayMs *= 1 + (Math.random() * 2 - 1) * profile.jitter;\n if (delayMs < 0) delayMs = 0;\n }\n } else if (recordedTimings) {\n // Recorded timings (second precedence)\n if (chunkIndex === 0) {\n delayMs = recordedTimings.ttftMs;\n } else {\n const idx = chunkIndex - 1;\n if (idx < recordedTimings.interChunkDelaysMs.length) {\n delayMs = recordedTimings.interChunkDelaysMs[idx];\n } else {\n // Excess chunks: derive average from recorded inter-chunk delays\n const totalInterChunk = recordedTimings.interChunkDelaysMs.reduce((a, b) => a + b, 0);\n delayMs =\n recordedTimings.interChunkDelaysMs.length > 0\n ? totalInterChunk / recordedTimings.interChunkDelaysMs.length\n : 0;\n }\n }\n } else {\n delayMs = fallbackLatency ?? 0;\n }\n\n delayMs = Math.max(0, delayMs);\n return speed > 0 ? delayMs / speed : delayMs;\n}\n\n// Internal (not part of the public API): the total wait before a non-streaming\n// (`stream: false`) JSON response is written. Sums calculateDelay over the\n// content chunk count that `contentLength` at `chunkSize` would produce, so a\n// fixture with an explicit `streamingProfile` or `recordedTimings` pays roughly\n// the same ttft/tps cost as its streamed content chunks. It is an\n// approximation, not an exact match: a streamed reply also pays delays for its\n// role and finish chunks. There is deliberately no fallback-latency parameter:\n// bare `latency` / `--latency` is an inter-SSE-chunk gap and never delays a\n// non-streaming response. With neither a profile nor recorded timings, this\n// returns 0.\n// Kept as a line comment, not JSDoc: the bundled .d.ts drops this non-public\n// declaration but keeps a JSDoc block, which would then attach to writeSSEStream.\nexport function calculateTotalDelay(\n contentLength: number,\n chunkSize: number,\n profile?: StreamingProfile,\n recordedTimings?: RecordedTimings,\n replaySpeed?: number,\n): number {\n const chunkCount = Math.max(1, Math.ceil(contentLength / chunkSize));\n let total = 0;\n for (let chunkIndex = 0; chunkIndex < chunkCount; chunkIndex++) {\n total += calculateDelay(chunkIndex, profile, undefined, recordedTimings, replaySpeed);\n }\n return total;\n}\n\nexport async function writeSSEStream(\n res: http.ServerResponse,\n chunks: SSEChunk[],\n optionsOrLatency?: number | StreamOptions,\n): Promise<boolean> {\n const opts: StreamOptions =\n typeof optionsOrLatency === \"number\" ? { latency: optionsOrLatency } : (optionsOrLatency ?? {});\n // No lifecycle: an external caller gets close-awareness only through its own\n // `signal` (a gone client still returns true).\n // Base contract (public API): with no frames, an aborted signal does not\n // stop the writer; it still finishes the stream (any trailer, then end())\n // and returns true. The frame loop returns false there, so pass it no\n // signal when there are no frames (it reads the signal only per frame and\n // after the loop).\n return runFrameLoop(\n httpSink(res),\n chunks,\n sseCodec({\n done: true,\n usageChunk: opts.usageChunk,\n openRouterProcessing: opts.openRouterProcessing,\n }),\n {\n latency: opts.latency,\n streamingProfile: opts.streamingProfile,\n recordedTimings: opts.recordedTimings,\n replaySpeed: opts.replaySpeed,\n },\n { signal: chunks.length > 0 ? opts.signal : undefined, onChunkSent: opts.onChunkSent },\n );\n}\n\nconst SSE_HEADERS: Record<string, string> = {\n \"Content-Type\": \"text/event-stream\",\n \"Cache-Control\": \"no-cache\",\n Connection: \"keep-alive\",\n};\n\n// Internal (not part of the public API): data-only SSE (`data: <json>\\n\\n`).\n// `done: true` writes the optional usage chunk then `data: [DONE]` after the\n// last frame (OpenAI chat); `done: false` writes no trailer.\n// `openRouterProcessing` writes the OpenRouter keepalive comment first. Line\n// comments, not JSDoc: see calculateTotalDelay.\nexport function sseCodec<F = SSEChunk>(opts: {\n done: boolean;\n usageChunk?: SSEChunk;\n openRouterProcessing?: boolean;\n}): FrameCodec<F> {\n return {\n headers: SSE_HEADERS,\n ...(opts.openRouterProcessing ? { prelude: () => OPENROUTER_PROCESSING_COMMENT } : {}),\n encode: (frame) => `data: ${JSON.stringify(frame)}\\n\\n`,\n trailer: () => {\n if (!opts.done) return [];\n const parts: string[] = [];\n if (opts.usageChunk) parts.push(`data: ${JSON.stringify(opts.usageChunk)}\\n\\n`);\n parts.push(\"data: [DONE]\\n\\n\");\n return parts;\n },\n };\n}\n\n// Internal: typed SSE (`event: <type>\\ndata: <json>\\n\\n`), used by Anthropic.\n// `terminalSentinel` writes `data: [DONE]` after the last frame, only when the\n// stream was not aborted.\nexport function typedSseCodec<F extends { type: string }>(opts?: {\n terminalSentinel?: boolean;\n}): FrameCodec<F> {\n return {\n headers: SSE_HEADERS,\n encode: (frame) => `event: ${frame.type}\\ndata: ${JSON.stringify(frame)}\\n\\n`,\n trailer: () => (opts?.terminalSentinel ? [\"data: [DONE]\\n\\n\"] : []),\n };\n}\n\n/**\n * Default rate-limit response headers matching OpenAI's format.\n * Values are static — aimock doesn't track actual request counts.\n */\nconst RATE_LIMIT_HEADERS: Record<string, string> = {\n \"x-ratelimit-limit-requests\": \"60\",\n \"x-ratelimit-limit-tokens\": \"150000\",\n \"x-ratelimit-remaining-requests\": \"0\",\n \"x-ratelimit-remaining-tokens\": \"0\",\n \"x-ratelimit-reset-requests\": \"1s\",\n \"x-ratelimit-reset-tokens\": \"6m0s\",\n};\n\nexport interface ErrorResponseOptions {\n /** Override the Retry-After header value (seconds). Default: 1. Only applied on 429. */\n retryAfter?: number;\n}\n\nexport function writeErrorResponse(\n res: http.ServerResponse,\n status: number,\n body: string,\n options?: ErrorResponseOptions,\n): void {\n const headers: Record<string, string> = { \"Content-Type\": \"application/json\" };\n if (status === 429) {\n headers[\"Retry-After\"] = String(options?.retryAfter ?? 1);\n Object.assign(headers, RATE_LIMIT_HEADERS);\n }\n res.writeHead(status, headers);\n res.end(body);\n}\n"],"mappings":"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"}
@@ -29,7 +29,6 @@ interface ErrorResponseOptions {
29
29
  }
30
30
  declare function writeErrorResponse(res: http$1.ServerResponse, status: number, body: string, options?: ErrorResponseOptions): void;
31
31
  //# sourceMappingURL=sse-writer.d.ts.map
32
-
33
32
  //#endregion
34
33
  export { ErrorResponseOptions, StreamOptions, calculateDelay, delay, writeErrorResponse, writeSSEStream };
35
34
  //# sourceMappingURL=sse-writer.d.cts.map
@@ -1 +1 @@
1
- {"version":3,"file":"sse-writer.d.cts","names":[],"sources":["../src/sse-writer.ts"],"sourcesContent":[],"mappings":";;;;iBAGgB,KAAA,sBAA2B,cAAc;UAexC,aAAA;EAfD,OAAA,CAAK,EAAA,MAAA;EAAA,gBAAA,CAAA,EAiBA,gBAjBA;iBAAsB,CAAA,EAkBvB,eAlBuB;aAAc,CAAA,EAAA,MAAA;EAAO,MAAA,CAAA,EAoBrD,WApBqD;EAe/C,WAAA,CAAA,EAAA,GAAa,GAAA,IAAA;EAAA;YAET,CAAA,EAMN,QANM;;;;;AAoBrB;;;sBAIoB,CAAA,EAAA,OAAA;;AA0EE,iBA9EN,cAAA,CA8EoB,UAAA,EAAA,MAAA,EAAA,OAAA,CAAA,EA5ExB,gBA4EwB,EAAA,eAAA,CAAA,EAAA,MAAA,EAAA,eAAA,CAAA,EA1EhB,eA0EgB,EAAA,WAAA,CAAA,EAAA,MAAA,CAAA,EAAA,MAAA;AAC7B,iBADe,cAAA,CACV,GAAA,EAAL,MAAA,CAAK,cAAA,EAAA,MAAA,EACF,QADE,EAAA,EAAA,gBAAA,CAAA,EAAA,MAAA,GAEkB,aAFlB,CAAA,EAGT,OAHS,CAAA,OAAA,CAAA;AACF,UA4DO,oBAAA,CA5DP;;YAEP,CAAA,EAAA,MAAA;;AA0Dc,iBAKD,kBAAA,CALqB,GAAA,EAM9B,MAAA,CAAK,cANyB,EAAA,MAAA,EAAA,MAAA,EAAA,IAAA,EAAA,MAAA,EAAA,OAAA,CAAA,EASzB,oBATyB,CAAA,EAAA,IAAA;AAKrC"}
1
+ {"version":3,"file":"sse-writer.d.cts","names":[],"sources":["../src/sse-writer.ts"],"sourcesContent":[],"mappings":";;;;iBAIgB,KAAA,sBAA2B,cAAc;AAAzC,UAiBC,aAAA,CAjBI;EAAA,OAAA,CAAA,EAAA,MAAA;kBAAsB,CAAA,EAmBtB,gBAnBsB;iBAAc,CAAA,EAoBrC,eApBqC;EAAO,WAAA,CAAA,EAAA,MAAA;EAiB/C,MAAA,CAAA,EAKN,WALmB;EAAA,WAAA,CAAA,EAAA,GAAA,GAAA,IAAA;;YAGV,CAAA,EAKL,QALK;;;;AAmBpB;;;;EAImC,oBAAA,CAAA,EAAA,OAAA;AA0EnC;AAAoC,iBA9EpB,cAAA,CA8EoB,UAAA,EAAA,MAAA,EAAA,OAAA,CAAA,EA5ExB,gBA4EwB,EAAA,eAAA,CAAA,EAAA,MAAA,EAAA,eAAA,CAAA,EA1EhB,eA0EgB,EAAA,WAAA,CAAA,EAAA,MAAA,CAAA,EAAA,MAAA;AAE1B,iBAFY,cAAA,CAEZ,GAAA,EADH,MAAA,CAAK,cACF,EAAA,MAAA,EAAA,QAAA,EAAA,EAAA,gBAAA,CAAA,EAAA,MAAA,GACoB,aADpB,CAAA,EAEP,OAFO,CAAA,OAAA,CAAA;UAsFO,oBAAA;;;;iBAKD,kBAAA,MACT,MAAA,CAAK,wDAGA"}
@@ -29,7 +29,6 @@ interface ErrorResponseOptions {
29
29
  }
30
30
  declare function writeErrorResponse(res: http$1.ServerResponse, status: number, body: string, options?: ErrorResponseOptions): void;
31
31
  //# sourceMappingURL=sse-writer.d.ts.map
32
-
33
32
  //#endregion
34
33
  export { ErrorResponseOptions, StreamOptions, calculateDelay, delay, writeErrorResponse, writeSSEStream };
35
34
  //# sourceMappingURL=sse-writer.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"sse-writer.d.ts","names":[],"sources":["../src/sse-writer.ts"],"sourcesContent":[],"mappings":";;;;iBAGgB,KAAA,sBAA2B,cAAc;UAexC,aAAA;EAfD,OAAA,CAAK,EAAA,MAAA;EAAA,gBAAA,CAAA,EAiBA,gBAjBA;iBAAsB,CAAA,EAkBvB,eAlBuB;aAAc,CAAA,EAAA,MAAA;EAAO,MAAA,CAAA,EAoBrD,WApBqD;EAe/C,WAAA,CAAA,EAAA,GAAa,GAAA,IAAA;EAAA;YAET,CAAA,EAMN,QANM;;;;;AAoBrB;;;sBAIoB,CAAA,EAAA,OAAA;;AA0EE,iBA9EN,cAAA,CA8EoB,UAAA,EAAA,MAAA,EAAA,OAAA,CAAA,EA5ExB,gBA4EwB,EAAA,eAAA,CAAA,EAAA,MAAA,EAAA,eAAA,CAAA,EA1EhB,eA0EgB,EAAA,WAAA,CAAA,EAAA,MAAA,CAAA,EAAA,MAAA;AAC7B,iBADe,cAAA,CACV,GAAA,EAAL,MAAA,CAAK,cAAA,EAAA,MAAA,EACF,QADE,EAAA,EAAA,gBAAA,CAAA,EAAA,MAAA,GAEkB,aAFlB,CAAA,EAGT,OAHS,CAAA,OAAA,CAAA;AACF,UA4DO,oBAAA,CA5DP;;YAEP,CAAA,EAAA,MAAA;;AA0Dc,iBAKD,kBAAA,CALqB,GAAA,EAM9B,MAAA,CAAK,cANyB,EAAA,MAAA,EAAA,MAAA,EAAA,IAAA,EAAA,MAAA,EAAA,OAAA,CAAA,EASzB,oBATyB,CAAA,EAAA,IAAA;AAKrC"}
1
+ {"version":3,"file":"sse-writer.d.ts","names":[],"sources":["../src/sse-writer.ts"],"sourcesContent":[],"mappings":";;;;iBAIgB,KAAA,sBAA2B,cAAc;AAAzC,UAiBC,aAAA,CAjBI;EAAA,OAAA,CAAA,EAAA,MAAA;kBAAsB,CAAA,EAmBtB,gBAnBsB;iBAAc,CAAA,EAoBrC,eApBqC;EAAO,WAAA,CAAA,EAAA,MAAA;EAiB/C,MAAA,CAAA,EAKN,WALmB;EAAA,WAAA,CAAA,EAAA,GAAA,GAAA,IAAA;;YAGV,CAAA,EAKL,QALK;;;;AAmBpB;;;;EAImC,oBAAA,CAAA,EAAA,OAAA;AA0EnC;AAAoC,iBA9EpB,cAAA,CA8EoB,UAAA,EAAA,MAAA,EAAA,OAAA,CAAA,EA5ExB,gBA4EwB,EAAA,eAAA,CAAA,EAAA,MAAA,EAAA,eAAA,CAAA,EA1EhB,eA0EgB,EAAA,WAAA,CAAA,EAAA,MAAA,CAAA,EAAA,MAAA;AAE1B,iBAFY,cAAA,CAEZ,GAAA,EADH,MAAA,CAAK,cACF,EAAA,MAAA,EAAA,QAAA,EAAA,EAAA,gBAAA,CAAA,EAAA,MAAA,GACoB,aADpB,CAAA,EAEP,OAFO,CAAA,OAAA,CAAA;UAsFO,oBAAA;;;;iBAKD,kBAAA,MACT,MAAA,CAAK,wDAGA"}
@@ -1,12 +1,18 @@
1
+ import { httpSink, runFrameLoop } from "./response/frame-loop.js";
2
+
1
3
  //#region src/sse-writer.ts
2
4
  function delay(ms, signal) {
3
5
  if (ms <= 0 || signal?.aborted) return Promise.resolve();
4
6
  return new Promise((resolve) => {
5
- const timer = setTimeout(resolve, ms);
6
- signal?.addEventListener("abort", () => {
7
+ const onAbort = () => {
7
8
  clearTimeout(timer);
8
9
  resolve();
9
- }, { once: true });
10
+ };
11
+ const timer = setTimeout(() => {
12
+ signal?.removeEventListener("abort", onAbort);
13
+ resolve();
14
+ }, ms);
15
+ signal?.addEventListener("abort", onAbort, { once: true });
10
16
  });
11
17
  }
12
18
  /** OpenRouter's SSE keepalive comment line (colon-prefixed, per SSE syntax). */
@@ -47,33 +53,45 @@ function calculateTotalDelay(contentLength, chunkSize, profile, recordedTimings,
47
53
  }
48
54
  async function writeSSEStream(res, chunks, optionsOrLatency) {
49
55
  const opts = typeof optionsOrLatency === "number" ? { latency: optionsOrLatency } : optionsOrLatency ?? {};
50
- const latency = opts.latency ?? 0;
51
- const profile = opts.streamingProfile;
52
- const { recordedTimings, replaySpeed } = opts;
53
- const signal = opts.signal;
54
- const onChunkSent = opts.onChunkSent;
55
- if (res.writableEnded) return true;
56
- res.setHeader("Content-Type", "text/event-stream");
57
- res.setHeader("Cache-Control", "no-cache");
58
- res.setHeader("Connection", "keep-alive");
59
- if (opts.openRouterProcessing && !res.writableEnded) res.write(OPENROUTER_PROCESSING_COMMENT);
60
- let chunkIndex = 0;
61
- for (const chunk of chunks) {
62
- const chunkDelay = calculateDelay(chunkIndex, profile, latency, recordedTimings, replaySpeed);
63
- if (chunkDelay > 0) await delay(chunkDelay, signal);
64
- if (signal?.aborted) return false;
65
- if (res.writableEnded) return true;
66
- res.write(`data: ${JSON.stringify(chunk)}\n\n`);
67
- onChunkSent?.();
68
- if (signal?.aborted) return false;
69
- chunkIndex++;
70
- }
71
- if (!res.writableEnded) {
72
- if (opts.usageChunk) res.write(`data: ${JSON.stringify(opts.usageChunk)}\n\n`);
73
- res.write("data: [DONE]\n\n");
74
- res.end();
75
- }
76
- return true;
56
+ return runFrameLoop(httpSink(res), chunks, sseCodec({
57
+ done: true,
58
+ usageChunk: opts.usageChunk,
59
+ openRouterProcessing: opts.openRouterProcessing
60
+ }), {
61
+ latency: opts.latency,
62
+ streamingProfile: opts.streamingProfile,
63
+ recordedTimings: opts.recordedTimings,
64
+ replaySpeed: opts.replaySpeed
65
+ }, {
66
+ signal: chunks.length > 0 ? opts.signal : void 0,
67
+ onChunkSent: opts.onChunkSent
68
+ });
69
+ }
70
+ const SSE_HEADERS = {
71
+ "Content-Type": "text/event-stream",
72
+ "Cache-Control": "no-cache",
73
+ Connection: "keep-alive"
74
+ };
75
+ function sseCodec(opts) {
76
+ return {
77
+ headers: SSE_HEADERS,
78
+ ...opts.openRouterProcessing ? { prelude: () => OPENROUTER_PROCESSING_COMMENT } : {},
79
+ encode: (frame) => `data: ${JSON.stringify(frame)}\n\n`,
80
+ trailer: () => {
81
+ if (!opts.done) return [];
82
+ const parts = [];
83
+ if (opts.usageChunk) parts.push(`data: ${JSON.stringify(opts.usageChunk)}\n\n`);
84
+ parts.push("data: [DONE]\n\n");
85
+ return parts;
86
+ }
87
+ };
88
+ }
89
+ function typedSseCodec(opts) {
90
+ return {
91
+ headers: SSE_HEADERS,
92
+ encode: (frame) => `event: ${frame.type}\ndata: ${JSON.stringify(frame)}\n\n`,
93
+ trailer: () => opts?.terminalSentinel ? ["data: [DONE]\n\n"] : []
94
+ };
77
95
  }
78
96
  /**
79
97
  * Default rate-limit response headers matching OpenAI's format.
@@ -98,5 +116,5 @@ function writeErrorResponse(res, status, body, options) {
98
116
  }
99
117
 
100
118
  //#endregion
101
- export { calculateDelay, calculateTotalDelay, delay, writeErrorResponse, writeSSEStream };
119
+ export { calculateDelay, calculateTotalDelay, delay, sseCodec, typedSseCodec, writeErrorResponse, writeSSEStream };
102
120
  //# sourceMappingURL=sse-writer.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"sse-writer.js","names":[],"sources":["../src/sse-writer.ts"],"sourcesContent":["import type * as http from \"node:http\";\nimport type { SSEChunk, StreamingProfile, RecordedTimings } from \"./types.js\";\n\nexport function delay(ms: number, signal?: AbortSignal): Promise<void> {\n if (ms <= 0 || signal?.aborted) return Promise.resolve();\n return new Promise((resolve) => {\n const timer = setTimeout(resolve, ms);\n signal?.addEventListener(\n \"abort\",\n () => {\n clearTimeout(timer);\n resolve();\n },\n { once: true },\n );\n });\n}\n\nexport interface StreamOptions {\n latency?: number;\n streamingProfile?: StreamingProfile;\n recordedTimings?: RecordedTimings;\n replaySpeed?: number;\n signal?: AbortSignal;\n onChunkSent?: () => void;\n /** When set, emitted as the final chunk before [DONE] (OpenAI stream_options.include_usage). */\n usageChunk?: SSEChunk;\n /**\n * When true, a single OpenRouter `: OPENROUTER PROCESSING` SSE comment\n * (keepalive) line is emitted FIRST — before the first data chunk — matching\n * real OpenRouter bytes (or-capture/chat-stream.sse). A faithful client skips\n * `:`-prefixed lines; opt-in, default off (so strict OpenAI parsers aren't\n * surprised). Used only by the OpenRouter chat surface.\n */\n openRouterProcessing?: boolean;\n}\n\n/** OpenRouter's SSE keepalive comment line (colon-prefixed, per SSE syntax). */\nconst OPENROUTER_PROCESSING_COMMENT = \": OPENROUTER PROCESSING\\n\\n\";\n\nexport function calculateDelay(\n chunkIndex: number,\n profile?: StreamingProfile,\n fallbackLatency?: number,\n recordedTimings?: RecordedTimings,\n replaySpeed?: number,\n): number {\n const speed = replaySpeed ?? 1.0;\n let delayMs: number;\n\n if (profile) {\n // StreamingProfile has highest precedence\n let fromProfile = true;\n if (chunkIndex === 0 && profile.ttft !== undefined) {\n delayMs = profile.ttft;\n } else if (profile.tps !== undefined && profile.tps > 0) {\n delayMs = 1000 / profile.tps;\n } else {\n delayMs = fallbackLatency ?? 0;\n fromProfile = false;\n }\n // Jitter only applies when the delay came from ttft/tps, not fallback\n if (fromProfile && profile.jitter && profile.jitter > 0) {\n delayMs *= 1 + (Math.random() * 2 - 1) * profile.jitter;\n if (delayMs < 0) delayMs = 0;\n }\n } else if (recordedTimings) {\n // Recorded timings (second precedence)\n if (chunkIndex === 0) {\n delayMs = recordedTimings.ttftMs;\n } else {\n const idx = chunkIndex - 1;\n if (idx < recordedTimings.interChunkDelaysMs.length) {\n delayMs = recordedTimings.interChunkDelaysMs[idx];\n } else {\n // Excess chunks: derive average from recorded inter-chunk delays\n const totalInterChunk = recordedTimings.interChunkDelaysMs.reduce((a, b) => a + b, 0);\n delayMs =\n recordedTimings.interChunkDelaysMs.length > 0\n ? totalInterChunk / recordedTimings.interChunkDelaysMs.length\n : 0;\n }\n }\n } else {\n delayMs = fallbackLatency ?? 0;\n }\n\n delayMs = Math.max(0, delayMs);\n return speed > 0 ? delayMs / speed : delayMs;\n}\n\n// Internal (not part of the public API): the total wait before a non-streaming\n// (`stream: false`) JSON response is written. Sums calculateDelay over the\n// content chunk count that `contentLength` at `chunkSize` would produce, so a\n// fixture with an explicit `streamingProfile` or `recordedTimings` pays roughly\n// the same ttft/tps cost as its streamed content chunks. It is an\n// approximation, not an exact match: a streamed reply also pays delays for its\n// role and finish chunks. There is deliberately no fallback-latency parameter:\n// bare `latency` / `--latency` is an inter-SSE-chunk gap and never delays a\n// non-streaming response. With neither a profile nor recorded timings, this\n// returns 0.\n// Kept as a line comment, not JSDoc: the bundled .d.ts drops this non-public\n// declaration but keeps a JSDoc block, which would then attach to writeSSEStream.\nexport function calculateTotalDelay(\n contentLength: number,\n chunkSize: number,\n profile?: StreamingProfile,\n recordedTimings?: RecordedTimings,\n replaySpeed?: number,\n): number {\n const chunkCount = Math.max(1, Math.ceil(contentLength / chunkSize));\n let total = 0;\n for (let chunkIndex = 0; chunkIndex < chunkCount; chunkIndex++) {\n total += calculateDelay(chunkIndex, profile, undefined, recordedTimings, replaySpeed);\n }\n return total;\n}\n\nexport async function writeSSEStream(\n res: http.ServerResponse,\n chunks: SSEChunk[],\n optionsOrLatency?: number | StreamOptions,\n): Promise<boolean> {\n const opts: StreamOptions =\n typeof optionsOrLatency === \"number\" ? { latency: optionsOrLatency } : (optionsOrLatency ?? {});\n const latency = opts.latency ?? 0;\n const profile = opts.streamingProfile;\n const { recordedTimings, replaySpeed } = opts;\n const signal = opts.signal;\n const onChunkSent = opts.onChunkSent;\n\n if (res.writableEnded) return true;\n res.setHeader(\"Content-Type\", \"text/event-stream\");\n res.setHeader(\"Cache-Control\", \"no-cache\");\n res.setHeader(\"Connection\", \"keep-alive\");\n\n // OpenRouter emits exactly ONE `: OPENROUTER PROCESSING` keepalive comment\n // first — before any data frame (opt-in; see or-capture/chat-stream.sse). A\n // faithful client discards `:`-prefixed lines before JSON-parsing a `data:`.\n if (opts.openRouterProcessing && !res.writableEnded) {\n res.write(OPENROUTER_PROCESSING_COMMENT);\n }\n\n let chunkIndex = 0;\n for (const chunk of chunks) {\n const chunkDelay = calculateDelay(chunkIndex, profile, latency, recordedTimings, replaySpeed);\n if (chunkDelay > 0) {\n await delay(chunkDelay, signal);\n }\n if (signal?.aborted) return false;\n if (res.writableEnded) return true;\n res.write(`data: ${JSON.stringify(chunk)}\\n\\n`);\n onChunkSent?.();\n if (signal?.aborted) return false;\n chunkIndex++;\n }\n\n if (!res.writableEnded) {\n if (opts.usageChunk) {\n res.write(`data: ${JSON.stringify(opts.usageChunk)}\\n\\n`);\n }\n res.write(\"data: [DONE]\\n\\n\");\n res.end();\n }\n return true;\n}\n\n/**\n * Default rate-limit response headers matching OpenAI's format.\n * Values are static — aimock doesn't track actual request counts.\n */\nconst RATE_LIMIT_HEADERS: Record<string, string> = {\n \"x-ratelimit-limit-requests\": \"60\",\n \"x-ratelimit-limit-tokens\": \"150000\",\n \"x-ratelimit-remaining-requests\": \"0\",\n \"x-ratelimit-remaining-tokens\": \"0\",\n \"x-ratelimit-reset-requests\": \"1s\",\n \"x-ratelimit-reset-tokens\": \"6m0s\",\n};\n\nexport interface ErrorResponseOptions {\n /** Override the Retry-After header value (seconds). Default: 1. Only applied on 429. */\n retryAfter?: number;\n}\n\nexport function writeErrorResponse(\n res: http.ServerResponse,\n status: number,\n body: string,\n options?: ErrorResponseOptions,\n): void {\n const headers: Record<string, string> = { \"Content-Type\": \"application/json\" };\n if (status === 429) {\n headers[\"Retry-After\"] = String(options?.retryAfter ?? 1);\n Object.assign(headers, RATE_LIMIT_HEADERS);\n }\n res.writeHead(status, headers);\n res.end(body);\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"sse-writer.js","names":[],"sources":["../src/sse-writer.ts"],"sourcesContent":["import type * as http from \"node:http\";\nimport type { SSEChunk, StreamingProfile, RecordedTimings } from \"./types.js\";\nimport { httpSink, runFrameLoop, type FrameCodec } from \"./response/frame-loop.js\";\n\nexport function delay(ms: number, signal?: AbortSignal): Promise<void> {\n if (ms <= 0 || signal?.aborted) return Promise.resolve();\n return new Promise((resolve) => {\n const onAbort = (): void => {\n clearTimeout(timer);\n resolve();\n };\n // Remove the abort listener when the timer fires, so a long stream sharing\n // one lifecycle signal does not keep one listener per delayed frame.\n const timer = setTimeout(() => {\n signal?.removeEventListener(\"abort\", onAbort);\n resolve();\n }, ms);\n signal?.addEventListener(\"abort\", onAbort, { once: true });\n });\n}\n\nexport interface StreamOptions {\n latency?: number;\n streamingProfile?: StreamingProfile;\n recordedTimings?: RecordedTimings;\n replaySpeed?: number;\n signal?: AbortSignal;\n onChunkSent?: () => void;\n /** When set, emitted as the final chunk before [DONE] (OpenAI stream_options.include_usage). */\n usageChunk?: SSEChunk;\n /**\n * When true, a single OpenRouter `: OPENROUTER PROCESSING` SSE comment\n * (keepalive) line is emitted FIRST — before the first data chunk — matching\n * real OpenRouter bytes (or-capture/chat-stream.sse). A faithful client skips\n * `:`-prefixed lines; opt-in, default off (so strict OpenAI parsers aren't\n * surprised). Used only by the OpenRouter chat surface.\n */\n openRouterProcessing?: boolean;\n}\n\n/** OpenRouter's SSE keepalive comment line (colon-prefixed, per SSE syntax). */\nconst OPENROUTER_PROCESSING_COMMENT = \": OPENROUTER PROCESSING\\n\\n\";\n\nexport function calculateDelay(\n chunkIndex: number,\n profile?: StreamingProfile,\n fallbackLatency?: number,\n recordedTimings?: RecordedTimings,\n replaySpeed?: number,\n): number {\n const speed = replaySpeed ?? 1.0;\n let delayMs: number;\n\n if (profile) {\n // StreamingProfile has highest precedence\n let fromProfile = true;\n if (chunkIndex === 0 && profile.ttft !== undefined) {\n delayMs = profile.ttft;\n } else if (profile.tps !== undefined && profile.tps > 0) {\n delayMs = 1000 / profile.tps;\n } else {\n delayMs = fallbackLatency ?? 0;\n fromProfile = false;\n }\n // Jitter only applies when the delay came from ttft/tps, not fallback\n if (fromProfile && profile.jitter && profile.jitter > 0) {\n delayMs *= 1 + (Math.random() * 2 - 1) * profile.jitter;\n if (delayMs < 0) delayMs = 0;\n }\n } else if (recordedTimings) {\n // Recorded timings (second precedence)\n if (chunkIndex === 0) {\n delayMs = recordedTimings.ttftMs;\n } else {\n const idx = chunkIndex - 1;\n if (idx < recordedTimings.interChunkDelaysMs.length) {\n delayMs = recordedTimings.interChunkDelaysMs[idx];\n } else {\n // Excess chunks: derive average from recorded inter-chunk delays\n const totalInterChunk = recordedTimings.interChunkDelaysMs.reduce((a, b) => a + b, 0);\n delayMs =\n recordedTimings.interChunkDelaysMs.length > 0\n ? totalInterChunk / recordedTimings.interChunkDelaysMs.length\n : 0;\n }\n }\n } else {\n delayMs = fallbackLatency ?? 0;\n }\n\n delayMs = Math.max(0, delayMs);\n return speed > 0 ? delayMs / speed : delayMs;\n}\n\n// Internal (not part of the public API): the total wait before a non-streaming\n// (`stream: false`) JSON response is written. Sums calculateDelay over the\n// content chunk count that `contentLength` at `chunkSize` would produce, so a\n// fixture with an explicit `streamingProfile` or `recordedTimings` pays roughly\n// the same ttft/tps cost as its streamed content chunks. It is an\n// approximation, not an exact match: a streamed reply also pays delays for its\n// role and finish chunks. There is deliberately no fallback-latency parameter:\n// bare `latency` / `--latency` is an inter-SSE-chunk gap and never delays a\n// non-streaming response. With neither a profile nor recorded timings, this\n// returns 0.\n// Kept as a line comment, not JSDoc: the bundled .d.ts drops this non-public\n// declaration but keeps a JSDoc block, which would then attach to writeSSEStream.\nexport function calculateTotalDelay(\n contentLength: number,\n chunkSize: number,\n profile?: StreamingProfile,\n recordedTimings?: RecordedTimings,\n replaySpeed?: number,\n): number {\n const chunkCount = Math.max(1, Math.ceil(contentLength / chunkSize));\n let total = 0;\n for (let chunkIndex = 0; chunkIndex < chunkCount; chunkIndex++) {\n total += calculateDelay(chunkIndex, profile, undefined, recordedTimings, replaySpeed);\n }\n return total;\n}\n\nexport async function writeSSEStream(\n res: http.ServerResponse,\n chunks: SSEChunk[],\n optionsOrLatency?: number | StreamOptions,\n): Promise<boolean> {\n const opts: StreamOptions =\n typeof optionsOrLatency === \"number\" ? { latency: optionsOrLatency } : (optionsOrLatency ?? {});\n // No lifecycle: an external caller gets close-awareness only through its own\n // `signal` (a gone client still returns true).\n // Base contract (public API): with no frames, an aborted signal does not\n // stop the writer; it still finishes the stream (any trailer, then end())\n // and returns true. The frame loop returns false there, so pass it no\n // signal when there are no frames (it reads the signal only per frame and\n // after the loop).\n return runFrameLoop(\n httpSink(res),\n chunks,\n sseCodec({\n done: true,\n usageChunk: opts.usageChunk,\n openRouterProcessing: opts.openRouterProcessing,\n }),\n {\n latency: opts.latency,\n streamingProfile: opts.streamingProfile,\n recordedTimings: opts.recordedTimings,\n replaySpeed: opts.replaySpeed,\n },\n { signal: chunks.length > 0 ? opts.signal : undefined, onChunkSent: opts.onChunkSent },\n );\n}\n\nconst SSE_HEADERS: Record<string, string> = {\n \"Content-Type\": \"text/event-stream\",\n \"Cache-Control\": \"no-cache\",\n Connection: \"keep-alive\",\n};\n\n// Internal (not part of the public API): data-only SSE (`data: <json>\\n\\n`).\n// `done: true` writes the optional usage chunk then `data: [DONE]` after the\n// last frame (OpenAI chat); `done: false` writes no trailer.\n// `openRouterProcessing` writes the OpenRouter keepalive comment first. Line\n// comments, not JSDoc: see calculateTotalDelay.\nexport function sseCodec<F = SSEChunk>(opts: {\n done: boolean;\n usageChunk?: SSEChunk;\n openRouterProcessing?: boolean;\n}): FrameCodec<F> {\n return {\n headers: SSE_HEADERS,\n ...(opts.openRouterProcessing ? { prelude: () => OPENROUTER_PROCESSING_COMMENT } : {}),\n encode: (frame) => `data: ${JSON.stringify(frame)}\\n\\n`,\n trailer: () => {\n if (!opts.done) return [];\n const parts: string[] = [];\n if (opts.usageChunk) parts.push(`data: ${JSON.stringify(opts.usageChunk)}\\n\\n`);\n parts.push(\"data: [DONE]\\n\\n\");\n return parts;\n },\n };\n}\n\n// Internal: typed SSE (`event: <type>\\ndata: <json>\\n\\n`), used by Anthropic.\n// `terminalSentinel` writes `data: [DONE]` after the last frame, only when the\n// stream was not aborted.\nexport function typedSseCodec<F extends { type: string }>(opts?: {\n terminalSentinel?: boolean;\n}): FrameCodec<F> {\n return {\n headers: SSE_HEADERS,\n encode: (frame) => `event: ${frame.type}\\ndata: ${JSON.stringify(frame)}\\n\\n`,\n trailer: () => (opts?.terminalSentinel ? [\"data: [DONE]\\n\\n\"] : []),\n };\n}\n\n/**\n * Default rate-limit response headers matching OpenAI's format.\n * Values are static — aimock doesn't track actual request counts.\n */\nconst RATE_LIMIT_HEADERS: Record<string, string> = {\n \"x-ratelimit-limit-requests\": \"60\",\n \"x-ratelimit-limit-tokens\": \"150000\",\n \"x-ratelimit-remaining-requests\": \"0\",\n \"x-ratelimit-remaining-tokens\": \"0\",\n \"x-ratelimit-reset-requests\": \"1s\",\n \"x-ratelimit-reset-tokens\": \"6m0s\",\n};\n\nexport interface ErrorResponseOptions {\n /** Override the Retry-After header value (seconds). Default: 1. Only applied on 429. */\n retryAfter?: number;\n}\n\nexport function writeErrorResponse(\n res: http.ServerResponse,\n status: number,\n body: string,\n options?: ErrorResponseOptions,\n): void {\n const headers: Record<string, string> = { \"Content-Type\": \"application/json\" };\n if (status === 429) {\n headers[\"Retry-After\"] = String(options?.retryAfter ?? 1);\n Object.assign(headers, RATE_LIMIT_HEADERS);\n }\n res.writeHead(status, headers);\n res.end(body);\n}\n"],"mappings":";;;AAIA,SAAgB,MAAM,IAAY,QAAqC;AACrE,KAAI,MAAM,KAAK,QAAQ,QAAS,QAAO,QAAQ,SAAS;AACxD,QAAO,IAAI,SAAS,YAAY;EAC9B,MAAM,gBAAsB;AAC1B,gBAAa,MAAM;AACnB,YAAS;;EAIX,MAAM,QAAQ,iBAAiB;AAC7B,WAAQ,oBAAoB,SAAS,QAAQ;AAC7C,YAAS;KACR,GAAG;AACN,UAAQ,iBAAiB,SAAS,SAAS,EAAE,MAAM,MAAM,CAAC;GAC1D;;;AAuBJ,MAAM,gCAAgC;AAEtC,SAAgB,eACd,YACA,SACA,iBACA,iBACA,aACQ;CACR,MAAM,QAAQ,eAAe;CAC7B,IAAI;AAEJ,KAAI,SAAS;EAEX,IAAI,cAAc;AAClB,MAAI,eAAe,KAAK,QAAQ,SAAS,OACvC,WAAU,QAAQ;WACT,QAAQ,QAAQ,UAAa,QAAQ,MAAM,EACpD,WAAU,MAAO,QAAQ;OACpB;AACL,aAAU,mBAAmB;AAC7B,iBAAc;;AAGhB,MAAI,eAAe,QAAQ,UAAU,QAAQ,SAAS,GAAG;AACvD,cAAW,KAAK,KAAK,QAAQ,GAAG,IAAI,KAAK,QAAQ;AACjD,OAAI,UAAU,EAAG,WAAU;;YAEpB,gBAET,KAAI,eAAe,EACjB,WAAU,gBAAgB;MACrB;EACL,MAAM,MAAM,aAAa;AACzB,MAAI,MAAM,gBAAgB,mBAAmB,OAC3C,WAAU,gBAAgB,mBAAmB;OACxC;GAEL,MAAM,kBAAkB,gBAAgB,mBAAmB,QAAQ,GAAG,MAAM,IAAI,GAAG,EAAE;AACrF,aACE,gBAAgB,mBAAmB,SAAS,IACxC,kBAAkB,gBAAgB,mBAAmB,SACrD;;;KAIV,WAAU,mBAAmB;AAG/B,WAAU,KAAK,IAAI,GAAG,QAAQ;AAC9B,QAAO,QAAQ,IAAI,UAAU,QAAQ;;AAevC,SAAgB,oBACd,eACA,WACA,SACA,iBACA,aACQ;CACR,MAAM,aAAa,KAAK,IAAI,GAAG,KAAK,KAAK,gBAAgB,UAAU,CAAC;CACpE,IAAI,QAAQ;AACZ,MAAK,IAAI,aAAa,GAAG,aAAa,YAAY,aAChD,UAAS,eAAe,YAAY,SAAS,QAAW,iBAAiB,YAAY;AAEvF,QAAO;;AAGT,eAAsB,eACpB,KACA,QACA,kBACkB;CAClB,MAAM,OACJ,OAAO,qBAAqB,WAAW,EAAE,SAAS,kBAAkB,GAAI,oBAAoB,EAAE;AAQhG,QAAO,aACL,SAAS,IAAI,EACb,QACA,SAAS;EACP,MAAM;EACN,YAAY,KAAK;EACjB,sBAAsB,KAAK;EAC5B,CAAC,EACF;EACE,SAAS,KAAK;EACd,kBAAkB,KAAK;EACvB,iBAAiB,KAAK;EACtB,aAAa,KAAK;EACnB,EACD;EAAE,QAAQ,OAAO,SAAS,IAAI,KAAK,SAAS;EAAW,aAAa,KAAK;EAAa,CACvF;;AAGH,MAAM,cAAsC;CAC1C,gBAAgB;CAChB,iBAAiB;CACjB,YAAY;CACb;AAOD,SAAgB,SAAuB,MAIrB;AAChB,QAAO;EACL,SAAS;EACT,GAAI,KAAK,uBAAuB,EAAE,eAAe,+BAA+B,GAAG,EAAE;EACrF,SAAS,UAAU,SAAS,KAAK,UAAU,MAAM,CAAC;EAClD,eAAe;AACb,OAAI,CAAC,KAAK,KAAM,QAAO,EAAE;GACzB,MAAM,QAAkB,EAAE;AAC1B,OAAI,KAAK,WAAY,OAAM,KAAK,SAAS,KAAK,UAAU,KAAK,WAAW,CAAC,MAAM;AAC/E,SAAM,KAAK,mBAAmB;AAC9B,UAAO;;EAEV;;AAMH,SAAgB,cAA0C,MAExC;AAChB,QAAO;EACL,SAAS;EACT,SAAS,UAAU,UAAU,MAAM,KAAK,UAAU,KAAK,UAAU,MAAM,CAAC;EACxE,eAAgB,MAAM,mBAAmB,CAAC,mBAAmB,GAAG,EAAE;EACnE;;;;;;AAOH,MAAM,qBAA6C;CACjD,8BAA8B;CAC9B,4BAA4B;CAC5B,kCAAkC;CAClC,gCAAgC;CAChC,8BAA8B;CAC9B,4BAA4B;CAC7B;AAOD,SAAgB,mBACd,KACA,QACA,MACA,SACM;CACN,MAAM,UAAkC,EAAE,gBAAgB,oBAAoB;AAC9E,KAAI,WAAW,KAAK;AAClB,UAAQ,iBAAiB,OAAO,SAAS,cAAc,EAAE;AACzD,SAAO,OAAO,SAAS,mBAAmB;;AAE5C,KAAI,UAAU,QAAQ,QAAQ;AAC9B,KAAI,IAAI,KAAK"}
@@ -5,7 +5,8 @@ const require_sse_writer = require('./sse-writer.cjs');
5
5
  const require_chaos = require('./chaos.cjs');
6
6
  const require_router = require('./router.cjs');
7
7
  const require_recorder = require('./recorder.cjs');
8
- const require_interruption = require('./interruption.cjs');
8
+ const require_deliver = require('./response/deliver.cjs');
9
+ const require_proxy = require('./response/proxy.cjs');
9
10
 
10
11
  //#region src/transcription.ts
11
12
  /**
@@ -110,7 +111,7 @@ async function handleTranscription(req, res, raw, fixtures, journal, defaults, s
110
111
  return;
111
112
  }
112
113
  if (defaults.record) {
113
- const outcome = await require_recorder.proxyAndRecord(req, res, syntheticReq, "openai", req.url ?? defaultPath, fixtures, defaults, raw);
114
+ const { outcome, clientHungUp } = await require_proxy.withHangUpDetection(req, res, () => require_recorder.proxyAndRecord(req, res, syntheticReq, "openai", req.url ?? defaultPath, fixtures, defaults, raw));
114
115
  if (outcome === "handled_by_hook") return;
115
116
  if (outcome !== "not_configured") {
116
117
  journal.add({
@@ -121,7 +122,8 @@ async function handleTranscription(req, res, raw, fixtures, journal, defaults, s
121
122
  response: {
122
123
  status: res.statusCode ?? 200,
123
124
  fixture: null,
124
- source: "proxy"
125
+ source: "proxy",
126
+ ...require_proxy.proxyInterruption(res, clientHungUp)
125
127
  }
126
128
  });
127
129
  return;
@@ -196,38 +198,22 @@ async function handleTranscription(req, res, raw, fixtures, journal, defaults, s
196
198
  ...t.languages !== void 0 ? { languages: t.languages } : {},
197
199
  ...t.usage !== void 0 ? { usage: t.usage } : {}
198
200
  };
199
- const latency = fixture.latency ?? defaults.latency;
200
201
  const chunkSize = Math.max(1, fixture.chunkSize ?? defaults.chunkSize);
201
- const replaySpeed = fixture.replaySpeed ?? defaults.replaySpeed;
202
- res.writeHead(200, {
203
- "Content-Type": "text/event-stream",
204
- "Cache-Control": "no-cache",
205
- Connection: "keep-alive"
202
+ const frames = [];
203
+ for (let index = 0; index < t.text.length; index += chunkSize) frames.push({
204
+ type: "transcript.text.delta",
205
+ delta: t.text.slice(index, index + chunkSize)
206
+ });
207
+ await require_deliver.deliverStream(res, journalEntry, fixture, {
208
+ ...require_sse_writer.sseCodec({ done: false }),
209
+ writeHeadImmediately: true,
210
+ trailer: () => [`data: ${JSON.stringify(done)}\n\n`, "data: [DONE]\n\n"]
211
+ }, frames, {
212
+ latency: fixture.latency ?? defaults.latency,
213
+ streamingProfile: fixture.streamingProfile,
214
+ recordedTimings: fixture.recordedTimings,
215
+ replaySpeed: fixture.replaySpeed ?? defaults.replaySpeed
206
216
  });
207
- let chunkIndex = 0;
208
- const interruption = require_interruption.createInterruptionSignal(fixture);
209
- for (let index = 0; index < t.text.length; index += chunkSize) {
210
- const chunkDelay = require_sse_writer.calculateDelay(chunkIndex, fixture.streamingProfile, latency, fixture.recordedTimings, replaySpeed);
211
- if (chunkDelay > 0) await require_sse_writer.delay(chunkDelay, interruption?.signal);
212
- if (interruption?.signal.aborted) break;
213
- res.write(`data: ${JSON.stringify({
214
- type: "transcript.text.delta",
215
- delta: t.text.slice(index, index + chunkSize)
216
- })}\n\n`);
217
- interruption?.tick();
218
- chunkIndex++;
219
- }
220
- if (interruption?.signal.aborted) {
221
- journalEntry.response.interrupted = true;
222
- journalEntry.response.interruptReason = interruption.reason();
223
- interruption.cleanup();
224
- res.destroy();
225
- return;
226
- }
227
- res.write(`data: ${JSON.stringify(done)}\n\n`);
228
- res.write("data: [DONE]\n\n");
229
- res.end();
230
- interruption?.cleanup();
231
217
  return;
232
218
  }
233
219
  if (responseFormat === "verbose_json" || t.words != null || t.segments != null) {
@@ -1 +1 @@
1
- {"version":3,"file":"transcription.cjs","names":["getContext","getTestId","matchFixtureDiagnostic","applyChaosAsync","flattenHeaders","resolveRequestDecisions","strictNoMatchMessage","strictNoMatchLogLine","strictOverrideField","renderError","proxyAndRecord","resolveResponse","isErrorResponse","isTranscriptionResponse","createInterruptionSignal","calculateDelay","delay"],"sources":["../src/transcription.ts"],"sourcesContent":["import { renderError } from \"./response/errors.js\";\nimport { resolveRequestDecisions, strictOverrideField } from \"./response/policy.js\";\nimport type * as http from \"node:http\";\nimport type { ChatCompletionRequest, Fixture, HandlerDefaults } from \"./types.js\";\nimport {\n isTranscriptionResponse,\n isErrorResponse,\n flattenHeaders,\n getTestId,\n resolveResponse,\n getContext,\n strictNoMatchMessage,\n strictNoMatchLogLine,\n} from \"./helpers.js\";\nimport { matchFixtureDiagnostic } from \"./router.js\";\nimport { calculateDelay, delay, writeErrorResponse } from \"./sse-writer.js\";\nimport type { Journal } from \"./journal.js\";\nimport { applyChaosAsync } from \"./chaos.js\";\nimport { proxyAndRecord } from \"./recorder.js\";\nimport { createInterruptionSignal } from \"./interruption.js\";\n\n/**\n * Extract the multipart boundary string from a Content-Type header.\n *\n * RFC 2046 §5.1.1 defines the boundary parameter as either a bare token\n * (`boundary=abc123`) or a quoted-string (`boundary=\"abc123\"`), and its\n * `bchars` production INCLUDES a space — which is exactly the case the quoted\n * form exists for, and the one a capture that stops at whitespace can never\n * read. So the quotes are consumed BY the match rather than stripped after it:\n * a post-strip runs on an already-truncated value and leaves `boundary=\"a b c\"`\n * as the useless `\"a`, and the delimiter then never matches, so every form\n * field silently falls back to its default (`whisper-1`, `gpt-image-1`, …).\n *\n * Only DQUOTE quotes. An apostrophe is a legal RFC 2045 token character and a\n * legal RFC 2046 `bchar`, so `boundary='abc'` is a BARE token whose value\n * literally includes the quotes, and stripping them would break a working\n * request.\n *\n * The `(?:^|[;\\s])` prefix keeps a decoy parameter such as `myboundary=` from\n * matching, and `\\s*=\\s*` tolerates the LWSP some clients put around the `=`.\n */\nexport function extractBoundary(contentType: string | undefined): string | undefined {\n if (!contentType) return undefined;\n const match = contentType.match(/(?:^|[;\\s])boundary\\s*=\\s*(?:\"([^\"]*)\"|([^\\s;]+))/i);\n if (!match) return undefined;\n // Group 1 is the quoted form (possibly the empty string), group 2 the bare\n // token; an empty boundary is meaningless, so it reads as absent.\n const boundary = match[1] !== undefined ? match[1] : match[2];\n return boundary || undefined;\n}\n\n/**\n * Extract a text field from multipart form data using boundary-based parsing.\n * Splits the body by the multipart boundary so each part is isolated, then\n * checks each part's Content-Disposition header for the target field name.\n * This avoids false matches from binary audio data that might contain\n * header-like byte sequences.\n */\nexport function extractFormField(\n raw: string,\n fieldName: string,\n boundary: string | undefined,\n): string | undefined {\n if (!boundary) {\n // Fallback: no boundary available, use simple regex (best-effort)\n console.warn(\"extractFormField: no multipart boundary found, using best-effort regex fallback\");\n const escaped = fieldName.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n const pattern = new RegExp(\n `Content-Disposition:\\\\s*form-data;(?:[^\\\\r\\\\n]*;)?\\\\s*name=\"${escaped}\"[^\\\\r\\\\n]*\\\\r\\\\n\\\\r\\\\n([^\\\\r\\\\n]*)`,\n \"i\",\n );\n const match = raw.match(pattern);\n return match?.[1];\n }\n\n // Split by boundary delimiter — each chunk is one part\n const delimiter = `--${boundary}`;\n const parts = raw.split(delimiter);\n\n for (const part of parts) {\n // Skip the preamble (before first boundary) and epilogue (after closing boundary)\n if (!part || part.trimStart().startsWith(\"--\")) continue;\n\n // Split part into headers and body at the first blank line (\\r\\n\\r\\n)\n const headerEnd = part.indexOf(\"\\r\\n\\r\\n\");\n if (headerEnd === -1) continue;\n\n const headers = part.slice(0, headerEnd);\n const body = part.slice(headerEnd + 4);\n\n // Check if this part's Content-Disposition names the target field\n const cdMatch = headers.match(\n /Content-Disposition:\\s*form-data;(?:[^\\r\\n]*;)?\\s*name=\"([^\"]+)\"/i,\n );\n if (cdMatch && cdMatch[1] === fieldName) {\n // Return the body value, trimming trailing \\r\\n from the part boundary\n return body.replace(/\\r\\n$/, \"\");\n }\n }\n return undefined;\n}\n\nexport async function handleTranscription(\n req: http.IncomingMessage,\n res: http.ServerResponse,\n raw: string,\n fixtures: Fixture[],\n journal: Journal,\n defaults: HandlerDefaults,\n setCorsHeaders: (res: http.ServerResponse) => void,\n endpointType: \"transcription\" | \"translation\" = \"transcription\",\n): Promise<void> {\n setCorsHeaders(res);\n const defaultPath =\n endpointType === \"translation\" ? \"/v1/audio/translations\" : \"/v1/audio/transcriptions\";\n const path = req.url ?? defaultPath;\n const method = req.method ?? \"POST\";\n\n const contentType = Array.isArray(req.headers[\"content-type\"])\n ? req.headers[\"content-type\"][0]\n : req.headers[\"content-type\"];\n const boundary = extractBoundary(contentType);\n\n const model = extractFormField(raw, \"model\", boundary) ?? \"whisper-1\";\n const responseFormat = extractFormField(raw, \"response_format\", boundary) ?? \"json\";\n const stream = extractFormField(raw, \"stream\", boundary) === \"true\";\n\n const syntheticReq: ChatCompletionRequest = {\n model,\n messages: [],\n _endpointType: endpointType,\n _context: getContext(req),\n };\n\n const testId = getTestId(req);\n const { fixture, skippedBySequenceOrTurn } = matchFixtureDiagnostic(\n fixtures,\n syntheticReq,\n journal.getFixtureMatchCountsForTest(testId),\n defaults.requestTransform,\n );\n\n if (fixture) {\n journal.incrementFixtureMatchCount(fixture, fixtures, testId);\n defaults.logger.debug(`Fixture matched: ${JSON.stringify(fixture.match).slice(0, 120)}`);\n } else {\n defaults.logger.debug(`No fixture matched for request`);\n }\n\n if (\n await applyChaosAsync(\n res,\n fixture,\n defaults.chaos,\n req.headers,\n req.url,\n journal,\n { method, path, headers: flattenHeaders(req.headers), body: syntheticReq },\n fixture ? \"fixture\" : \"proxy\",\n defaults.registry,\n defaults.logger,\n )\n )\n return;\n\n if (!fixture) {\n const effectiveStrict = resolveRequestDecisions(defaults.strict, req.headers).fixtureMiss;\n if (effectiveStrict) {\n const strictMessage = strictNoMatchMessage(skippedBySequenceOrTurn);\n defaults.logger.error(strictNoMatchLogLine(method, path, skippedBySequenceOrTurn));\n journal.add({\n method,\n path,\n headers: flattenHeaders(req.headers),\n body: syntheticReq,\n response: {\n status: 503,\n fixture: null,\n ...strictOverrideField(defaults.strict, req.headers),\n },\n });\n writeErrorResponse(\n res,\n 503,\n renderError(\"openai\", \"strict-miss\", {\n message: strictMessage,\n type: \"invalid_request_error\",\n code: \"no_fixture_match\",\n }).body,\n );\n return;\n }\n if (defaults.record) {\n const outcome = await proxyAndRecord(\n req,\n res,\n syntheticReq,\n \"openai\",\n req.url ?? defaultPath,\n fixtures,\n defaults,\n raw,\n );\n if (outcome === \"handled_by_hook\") return;\n if (outcome !== \"not_configured\") {\n journal.add({\n method,\n path,\n headers: flattenHeaders(req.headers),\n body: syntheticReq,\n response: { status: res.statusCode ?? 200, fixture: null, source: \"proxy\" },\n });\n return;\n }\n }\n\n journal.add({\n method,\n path,\n headers: flattenHeaders(req.headers),\n body: syntheticReq,\n response: {\n status: 404,\n fixture: null,\n ...strictOverrideField(defaults.strict, req.headers),\n },\n });\n writeErrorResponse(\n res,\n 404,\n renderError(\"openai\", \"fixture-miss\", {\n message: \"No fixture matched\",\n type: \"invalid_request_error\",\n code: \"no_fixture_match\",\n }).body,\n );\n return;\n }\n\n const response = await resolveResponse(fixture, syntheticReq);\n\n if (isErrorResponse(response)) {\n const status = response.status ?? 500;\n journal.add({\n method,\n path,\n headers: flattenHeaders(req.headers),\n body: syntheticReq,\n response: { status, fixture },\n });\n writeErrorResponse(res, status, renderError(\"openai\", \"fixture\", response.error).body, {\n retryAfter: response.retryAfter,\n });\n return;\n }\n\n if (!isTranscriptionResponse(response)) {\n journal.add({\n method,\n path,\n headers: flattenHeaders(req.headers),\n body: syntheticReq,\n response: { status: 500, fixture },\n });\n writeErrorResponse(\n res,\n 500,\n renderError(\"openai\", \"unknown-response\", {\n message: \"Fixture response is not a transcription type\",\n type: \"server_error\",\n }).body,\n );\n return;\n }\n\n const journalEntry = journal.add({\n method,\n path,\n headers: flattenHeaders(req.headers),\n body: syntheticReq,\n response: { status: 200, fixture },\n });\n\n const t = response.transcription;\n\n // Only the transcription endpoint streams modern models. Whisper-1 ignores\n // `stream=true`, and translations continue to return their JSON response.\n if (endpointType === \"transcription\" && stream && model !== \"whisper-1\") {\n const done = {\n type: \"transcript.text.done\",\n text: t.text,\n ...(t.languages !== undefined ? { languages: t.languages } : {}),\n ...(t.usage !== undefined ? { usage: t.usage } : {}),\n };\n const latency = fixture.latency ?? defaults.latency;\n const chunkSize = Math.max(1, fixture.chunkSize ?? defaults.chunkSize);\n const replaySpeed = fixture.replaySpeed ?? defaults.replaySpeed;\n res.writeHead(200, {\n \"Content-Type\": \"text/event-stream\",\n \"Cache-Control\": \"no-cache\",\n Connection: \"keep-alive\",\n });\n let chunkIndex = 0;\n const interruption = createInterruptionSignal(fixture);\n for (let index = 0; index < t.text.length; index += chunkSize) {\n const chunkDelay = calculateDelay(\n chunkIndex,\n fixture.streamingProfile,\n latency,\n fixture.recordedTimings,\n replaySpeed,\n );\n if (chunkDelay > 0) await delay(chunkDelay, interruption?.signal);\n if (interruption?.signal.aborted) break;\n res.write(\n `data: ${JSON.stringify({ type: \"transcript.text.delta\", delta: t.text.slice(index, index + chunkSize) })}\\n\\n`,\n );\n interruption?.tick();\n chunkIndex++;\n }\n if (interruption?.signal.aborted) {\n journalEntry.response.interrupted = true;\n journalEntry.response.interruptReason = interruption.reason();\n interruption.cleanup();\n res.destroy();\n return;\n }\n res.write(`data: ${JSON.stringify(done)}\\n\\n`);\n // The live `gpt-transcribe&stream=true` stream ends with the `[DONE]`\n // sentinel after `transcript.text.done`. Omitting it leaves clients that\n // loop until the sentinel waiting on a stream that never terminates.\n res.write(\"data: [DONE]\\n\\n\");\n res.end();\n interruption?.cleanup();\n return;\n }\n\n const useVerbose = responseFormat === \"verbose_json\" || t.words != null || t.segments != null;\n\n if (useVerbose) {\n const verboseBody: Record<string, unknown> = {\n task: endpointType === \"translation\" ? \"translate\" : \"transcribe\",\n language: t.language ?? \"english\",\n duration: t.duration ?? 0,\n text: t.text,\n };\n if (t.words && t.words.length > 0) {\n verboseBody.words = t.words;\n }\n if (t.segments && t.segments.length > 0) {\n verboseBody.segments = t.segments;\n }\n res.writeHead(200, { \"Content-Type\": \"application/json\" });\n res.end(JSON.stringify(verboseBody));\n } else {\n res.writeHead(200, { \"Content-Type\": \"application/json\" });\n res.end(\n JSON.stringify({\n text: t.text,\n ...(t.languages !== undefined ? { languages: t.languages } : {}),\n ...(t.usage !== undefined ? { usage: t.usage } : {}),\n }),\n );\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"transcription.cjs","names":["getContext","getTestId","matchFixtureDiagnostic","applyChaosAsync","flattenHeaders","resolveRequestDecisions","strictNoMatchMessage","strictNoMatchLogLine","strictOverrideField","renderError","withHangUpDetection","proxyAndRecord","proxyInterruption","resolveResponse","isErrorResponse","isTranscriptionResponse","deliverStream","sseCodec"],"sources":["../src/transcription.ts"],"sourcesContent":["import { renderError } from \"./response/errors.js\";\nimport { resolveRequestDecisions, strictOverrideField } from \"./response/policy.js\";\nimport type * as http from \"node:http\";\nimport type { ChatCompletionRequest, Fixture, HandlerDefaults } from \"./types.js\";\nimport {\n isTranscriptionResponse,\n isErrorResponse,\n flattenHeaders,\n getTestId,\n resolveResponse,\n getContext,\n strictNoMatchMessage,\n strictNoMatchLogLine,\n} from \"./helpers.js\";\nimport { matchFixtureDiagnostic } from \"./router.js\";\nimport { sseCodec, writeErrorResponse } from \"./sse-writer.js\";\nimport type { Journal } from \"./journal.js\";\nimport { applyChaosAsync } from \"./chaos.js\";\nimport { proxyAndRecord } from \"./recorder.js\";\nimport { proxyInterruption, withHangUpDetection } from \"./response/proxy.js\";\nimport { deliverStream } from \"./response/deliver.js\";\nimport type { FrameCodec } from \"./response/frame-loop.js\";\n\n/** One `transcript.text.delta` frame of a streamed transcription. */\ninterface TranscriptDelta {\n type: \"transcript.text.delta\";\n delta: string;\n}\n\n/**\n * Extract the multipart boundary string from a Content-Type header.\n *\n * RFC 2046 §5.1.1 defines the boundary parameter as either a bare token\n * (`boundary=abc123`) or a quoted-string (`boundary=\"abc123\"`), and its\n * `bchars` production INCLUDES a space — which is exactly the case the quoted\n * form exists for, and the one a capture that stops at whitespace can never\n * read. So the quotes are consumed BY the match rather than stripped after it:\n * a post-strip runs on an already-truncated value and leaves `boundary=\"a b c\"`\n * as the useless `\"a`, and the delimiter then never matches, so every form\n * field silently falls back to its default (`whisper-1`, `gpt-image-1`, …).\n *\n * Only DQUOTE quotes. An apostrophe is a legal RFC 2045 token character and a\n * legal RFC 2046 `bchar`, so `boundary='abc'` is a BARE token whose value\n * literally includes the quotes, and stripping them would break a working\n * request.\n *\n * The `(?:^|[;\\s])` prefix keeps a decoy parameter such as `myboundary=` from\n * matching, and `\\s*=\\s*` tolerates the LWSP some clients put around the `=`.\n */\nexport function extractBoundary(contentType: string | undefined): string | undefined {\n if (!contentType) return undefined;\n const match = contentType.match(/(?:^|[;\\s])boundary\\s*=\\s*(?:\"([^\"]*)\"|([^\\s;]+))/i);\n if (!match) return undefined;\n // Group 1 is the quoted form (possibly the empty string), group 2 the bare\n // token; an empty boundary is meaningless, so it reads as absent.\n const boundary = match[1] !== undefined ? match[1] : match[2];\n return boundary || undefined;\n}\n\n/**\n * Extract a text field from multipart form data using boundary-based parsing.\n * Splits the body by the multipart boundary so each part is isolated, then\n * checks each part's Content-Disposition header for the target field name.\n * This avoids false matches from binary audio data that might contain\n * header-like byte sequences.\n */\nexport function extractFormField(\n raw: string,\n fieldName: string,\n boundary: string | undefined,\n): string | undefined {\n if (!boundary) {\n // Fallback: no boundary available, use simple regex (best-effort)\n console.warn(\"extractFormField: no multipart boundary found, using best-effort regex fallback\");\n const escaped = fieldName.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n const pattern = new RegExp(\n `Content-Disposition:\\\\s*form-data;(?:[^\\\\r\\\\n]*;)?\\\\s*name=\"${escaped}\"[^\\\\r\\\\n]*\\\\r\\\\n\\\\r\\\\n([^\\\\r\\\\n]*)`,\n \"i\",\n );\n const match = raw.match(pattern);\n return match?.[1];\n }\n\n // Split by boundary delimiter — each chunk is one part\n const delimiter = `--${boundary}`;\n const parts = raw.split(delimiter);\n\n for (const part of parts) {\n // Skip the preamble (before first boundary) and epilogue (after closing boundary)\n if (!part || part.trimStart().startsWith(\"--\")) continue;\n\n // Split part into headers and body at the first blank line (\\r\\n\\r\\n)\n const headerEnd = part.indexOf(\"\\r\\n\\r\\n\");\n if (headerEnd === -1) continue;\n\n const headers = part.slice(0, headerEnd);\n const body = part.slice(headerEnd + 4);\n\n // Check if this part's Content-Disposition names the target field\n const cdMatch = headers.match(\n /Content-Disposition:\\s*form-data;(?:[^\\r\\n]*;)?\\s*name=\"([^\"]+)\"/i,\n );\n if (cdMatch && cdMatch[1] === fieldName) {\n // Return the body value, trimming trailing \\r\\n from the part boundary\n return body.replace(/\\r\\n$/, \"\");\n }\n }\n return undefined;\n}\n\nexport async function handleTranscription(\n req: http.IncomingMessage,\n res: http.ServerResponse,\n raw: string,\n fixtures: Fixture[],\n journal: Journal,\n defaults: HandlerDefaults,\n setCorsHeaders: (res: http.ServerResponse) => void,\n endpointType: \"transcription\" | \"translation\" = \"transcription\",\n): Promise<void> {\n setCorsHeaders(res);\n const defaultPath =\n endpointType === \"translation\" ? \"/v1/audio/translations\" : \"/v1/audio/transcriptions\";\n const path = req.url ?? defaultPath;\n const method = req.method ?? \"POST\";\n\n const contentType = Array.isArray(req.headers[\"content-type\"])\n ? req.headers[\"content-type\"][0]\n : req.headers[\"content-type\"];\n const boundary = extractBoundary(contentType);\n\n const model = extractFormField(raw, \"model\", boundary) ?? \"whisper-1\";\n const responseFormat = extractFormField(raw, \"response_format\", boundary) ?? \"json\";\n const stream = extractFormField(raw, \"stream\", boundary) === \"true\";\n\n const syntheticReq: ChatCompletionRequest = {\n model,\n messages: [],\n _endpointType: endpointType,\n _context: getContext(req),\n };\n\n const testId = getTestId(req);\n const { fixture, skippedBySequenceOrTurn } = matchFixtureDiagnostic(\n fixtures,\n syntheticReq,\n journal.getFixtureMatchCountsForTest(testId),\n defaults.requestTransform,\n );\n\n if (fixture) {\n journal.incrementFixtureMatchCount(fixture, fixtures, testId);\n defaults.logger.debug(`Fixture matched: ${JSON.stringify(fixture.match).slice(0, 120)}`);\n } else {\n defaults.logger.debug(`No fixture matched for request`);\n }\n\n if (\n await applyChaosAsync(\n res,\n fixture,\n defaults.chaos,\n req.headers,\n req.url,\n journal,\n { method, path, headers: flattenHeaders(req.headers), body: syntheticReq },\n fixture ? \"fixture\" : \"proxy\",\n defaults.registry,\n defaults.logger,\n )\n )\n return;\n\n if (!fixture) {\n const effectiveStrict = resolveRequestDecisions(defaults.strict, req.headers).fixtureMiss;\n if (effectiveStrict) {\n const strictMessage = strictNoMatchMessage(skippedBySequenceOrTurn);\n defaults.logger.error(strictNoMatchLogLine(method, path, skippedBySequenceOrTurn));\n journal.add({\n method,\n path,\n headers: flattenHeaders(req.headers),\n body: syntheticReq,\n response: {\n status: 503,\n fixture: null,\n ...strictOverrideField(defaults.strict, req.headers),\n },\n });\n writeErrorResponse(\n res,\n 503,\n renderError(\"openai\", \"strict-miss\", {\n message: strictMessage,\n type: \"invalid_request_error\",\n code: \"no_fixture_match\",\n }).body,\n );\n return;\n }\n if (defaults.record) {\n const { outcome, clientHungUp } = await withHangUpDetection(req, res, () =>\n proxyAndRecord(\n req,\n res,\n syntheticReq,\n \"openai\",\n req.url ?? defaultPath,\n fixtures,\n defaults,\n raw,\n ),\n );\n if (outcome === \"handled_by_hook\") return;\n if (outcome !== \"not_configured\") {\n journal.add({\n method,\n path,\n headers: flattenHeaders(req.headers),\n body: syntheticReq,\n response: {\n status: res.statusCode ?? 200,\n fixture: null,\n source: \"proxy\",\n ...proxyInterruption(res, clientHungUp),\n },\n });\n return;\n }\n }\n\n journal.add({\n method,\n path,\n headers: flattenHeaders(req.headers),\n body: syntheticReq,\n response: {\n status: 404,\n fixture: null,\n ...strictOverrideField(defaults.strict, req.headers),\n },\n });\n writeErrorResponse(\n res,\n 404,\n renderError(\"openai\", \"fixture-miss\", {\n message: \"No fixture matched\",\n type: \"invalid_request_error\",\n code: \"no_fixture_match\",\n }).body,\n );\n return;\n }\n\n const response = await resolveResponse(fixture, syntheticReq);\n\n if (isErrorResponse(response)) {\n const status = response.status ?? 500;\n journal.add({\n method,\n path,\n headers: flattenHeaders(req.headers),\n body: syntheticReq,\n response: { status, fixture },\n });\n writeErrorResponse(res, status, renderError(\"openai\", \"fixture\", response.error).body, {\n retryAfter: response.retryAfter,\n });\n return;\n }\n\n if (!isTranscriptionResponse(response)) {\n journal.add({\n method,\n path,\n headers: flattenHeaders(req.headers),\n body: syntheticReq,\n response: { status: 500, fixture },\n });\n writeErrorResponse(\n res,\n 500,\n renderError(\"openai\", \"unknown-response\", {\n message: \"Fixture response is not a transcription type\",\n type: \"server_error\",\n }).body,\n );\n return;\n }\n\n const journalEntry = journal.add({\n method,\n path,\n headers: flattenHeaders(req.headers),\n body: syntheticReq,\n response: { status: 200, fixture },\n });\n\n const t = response.transcription;\n\n // Only the transcription endpoint streams modern models. Whisper-1 ignores\n // `stream=true`, and translations continue to return their JSON response.\n if (endpointType === \"transcription\" && stream && model !== \"whisper-1\") {\n const done = {\n type: \"transcript.text.done\",\n text: t.text,\n ...(t.languages !== undefined ? { languages: t.languages } : {}),\n ...(t.usage !== undefined ? { usage: t.usage } : {}),\n };\n const chunkSize = Math.max(1, fixture.chunkSize ?? defaults.chunkSize);\n const frames: TranscriptDelta[] = [];\n for (let index = 0; index < t.text.length; index += chunkSize) {\n frames.push({ type: \"transcript.text.delta\", delta: t.text.slice(index, index + chunkSize) });\n }\n // The live `gpt-transcribe&stream=true` stream ends with the `[DONE]`\n // sentinel after `transcript.text.done`. Omitting it leaves clients that\n // loop until the sentinel waiting on a stream that never terminates.\n const codec: FrameCodec<TranscriptDelta> = {\n ...sseCodec<TranscriptDelta>({ done: false }),\n writeHeadImmediately: true,\n trailer: () => [`data: ${JSON.stringify(done)}\\n\\n`, \"data: [DONE]\\n\\n\"],\n };\n await deliverStream(res, journalEntry, fixture, codec, frames, {\n latency: fixture.latency ?? defaults.latency,\n streamingProfile: fixture.streamingProfile,\n recordedTimings: fixture.recordedTimings,\n replaySpeed: fixture.replaySpeed ?? defaults.replaySpeed,\n });\n return;\n }\n\n const useVerbose = responseFormat === \"verbose_json\" || t.words != null || t.segments != null;\n\n if (useVerbose) {\n const verboseBody: Record<string, unknown> = {\n task: endpointType === \"translation\" ? \"translate\" : \"transcribe\",\n language: t.language ?? \"english\",\n duration: t.duration ?? 0,\n text: t.text,\n };\n if (t.words && t.words.length > 0) {\n verboseBody.words = t.words;\n }\n if (t.segments && t.segments.length > 0) {\n verboseBody.segments = t.segments;\n }\n res.writeHead(200, { \"Content-Type\": \"application/json\" });\n res.end(JSON.stringify(verboseBody));\n } else {\n res.writeHead(200, { \"Content-Type\": \"application/json\" });\n res.end(\n JSON.stringify({\n text: t.text,\n ...(t.languages !== undefined ? { languages: t.languages } : {}),\n ...(t.usage !== undefined ? { usage: t.usage } : {}),\n }),\n );\n }\n}\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"transcription.d.cts","names":[],"sources":["../src/transcription.ts"],"sourcesContent":[],"mappings":";;;;;;iBAsGsB,mBAAA,MACf,MAAA,CAAK,sBACL,MAAA,CAAK,uCAEA,oBACD,mBACC,uCACY,MAAA,CAAK,0EAE1B"}
1
+ {"version":3,"file":"transcription.d.cts","names":[],"sources":["../src/transcription.ts"],"sourcesContent":[],"mappings":";;;;;;iBA8GsB,mBAAA,MACf,MAAA,CAAK,sBACL,MAAA,CAAK,uCAEA,oBACD,mBACC,uCACY,MAAA,CAAK,0EAE1B"}
@@ -1 +1 @@
1
- {"version":3,"file":"transcription.d.ts","names":[],"sources":["../src/transcription.ts"],"sourcesContent":[],"mappings":";;;;;;iBAsGsB,mBAAA,MACf,MAAA,CAAK,sBACL,MAAA,CAAK,uCAEA,oBACD,mBACC,uCACY,MAAA,CAAK,0EAE1B"}
1
+ {"version":3,"file":"transcription.d.ts","names":[],"sources":["../src/transcription.ts"],"sourcesContent":[],"mappings":";;;;;;iBA8GsB,mBAAA,MACf,MAAA,CAAK,sBACL,MAAA,CAAK,uCAEA,oBACD,mBACC,uCACY,MAAA,CAAK,0EAE1B"}
@@ -1,11 +1,12 @@
1
1
  import { renderError } from "./response/errors.js";
2
2
  import { resolveRequestDecisions, strictOverrideField } from "./response/policy.js";
3
3
  import { flattenHeaders, getContext, getTestId, isErrorResponse, isTranscriptionResponse, resolveResponse, strictNoMatchLogLine, strictNoMatchMessage } from "./helpers.js";
4
- import { calculateDelay, delay, writeErrorResponse } from "./sse-writer.js";
4
+ import { sseCodec, writeErrorResponse } from "./sse-writer.js";
5
5
  import { applyChaosAsync } from "./chaos.js";
6
6
  import { matchFixtureDiagnostic } from "./router.js";
7
7
  import { proxyAndRecord } from "./recorder.js";
8
- import { createInterruptionSignal } from "./interruption.js";
8
+ import { deliverStream } from "./response/deliver.js";
9
+ import { proxyInterruption, withHangUpDetection } from "./response/proxy.js";
9
10
 
10
11
  //#region src/transcription.ts
11
12
  /**
@@ -110,7 +111,7 @@ async function handleTranscription(req, res, raw, fixtures, journal, defaults, s
110
111
  return;
111
112
  }
112
113
  if (defaults.record) {
113
- const outcome = await proxyAndRecord(req, res, syntheticReq, "openai", req.url ?? defaultPath, fixtures, defaults, raw);
114
+ const { outcome, clientHungUp } = await withHangUpDetection(req, res, () => proxyAndRecord(req, res, syntheticReq, "openai", req.url ?? defaultPath, fixtures, defaults, raw));
114
115
  if (outcome === "handled_by_hook") return;
115
116
  if (outcome !== "not_configured") {
116
117
  journal.add({
@@ -121,7 +122,8 @@ async function handleTranscription(req, res, raw, fixtures, journal, defaults, s
121
122
  response: {
122
123
  status: res.statusCode ?? 200,
123
124
  fixture: null,
124
- source: "proxy"
125
+ source: "proxy",
126
+ ...proxyInterruption(res, clientHungUp)
125
127
  }
126
128
  });
127
129
  return;
@@ -196,38 +198,22 @@ async function handleTranscription(req, res, raw, fixtures, journal, defaults, s
196
198
  ...t.languages !== void 0 ? { languages: t.languages } : {},
197
199
  ...t.usage !== void 0 ? { usage: t.usage } : {}
198
200
  };
199
- const latency = fixture.latency ?? defaults.latency;
200
201
  const chunkSize = Math.max(1, fixture.chunkSize ?? defaults.chunkSize);
201
- const replaySpeed = fixture.replaySpeed ?? defaults.replaySpeed;
202
- res.writeHead(200, {
203
- "Content-Type": "text/event-stream",
204
- "Cache-Control": "no-cache",
205
- Connection: "keep-alive"
202
+ const frames = [];
203
+ for (let index = 0; index < t.text.length; index += chunkSize) frames.push({
204
+ type: "transcript.text.delta",
205
+ delta: t.text.slice(index, index + chunkSize)
206
+ });
207
+ await deliverStream(res, journalEntry, fixture, {
208
+ ...sseCodec({ done: false }),
209
+ writeHeadImmediately: true,
210
+ trailer: () => [`data: ${JSON.stringify(done)}\n\n`, "data: [DONE]\n\n"]
211
+ }, frames, {
212
+ latency: fixture.latency ?? defaults.latency,
213
+ streamingProfile: fixture.streamingProfile,
214
+ recordedTimings: fixture.recordedTimings,
215
+ replaySpeed: fixture.replaySpeed ?? defaults.replaySpeed
206
216
  });
207
- let chunkIndex = 0;
208
- const interruption = createInterruptionSignal(fixture);
209
- for (let index = 0; index < t.text.length; index += chunkSize) {
210
- const chunkDelay = calculateDelay(chunkIndex, fixture.streamingProfile, latency, fixture.recordedTimings, replaySpeed);
211
- if (chunkDelay > 0) await delay(chunkDelay, interruption?.signal);
212
- if (interruption?.signal.aborted) break;
213
- res.write(`data: ${JSON.stringify({
214
- type: "transcript.text.delta",
215
- delta: t.text.slice(index, index + chunkSize)
216
- })}\n\n`);
217
- interruption?.tick();
218
- chunkIndex++;
219
- }
220
- if (interruption?.signal.aborted) {
221
- journalEntry.response.interrupted = true;
222
- journalEntry.response.interruptReason = interruption.reason();
223
- interruption.cleanup();
224
- res.destroy();
225
- return;
226
- }
227
- res.write(`data: ${JSON.stringify(done)}\n\n`);
228
- res.write("data: [DONE]\n\n");
229
- res.end();
230
- interruption?.cleanup();
231
217
  return;
232
218
  }
233
219
  if (responseFormat === "verbose_json" || t.words != null || t.segments != null) {