@rerouter/helper 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,639 @@
1
+ import crypto from "node:crypto";
2
+
3
+ /**
4
+ * Pure translation between the OpenAI-compatible Responses API (used by Codex CLI)
5
+ * and the OpenAI-compatible Chat Completions API (used by the Rerouter gateway).
6
+ *
7
+ * This module has NO I/O — the local bridge server lives in bridge-server.js.
8
+ *
9
+ * Supported:
10
+ * - message (user/assistant/system/developer), input_text, input_image, output_text
11
+ * - function tools + function_call / function_call_output
12
+ * - custom tools (freeform input) — wrapped as a function `{input: string}`
13
+ * - reasoning.effort → reasoning_effort
14
+ * - stream: chat.completion.chunk → Responses events (response.created,
15
+ * output_item.added, output_text.delta, function_call_arguments.delta, …, response.completed)
16
+ *
17
+ * Documented limitations:
18
+ * - `reasoning` items (encrypted) are dropped — cannot be forwarded to chat upstream.
19
+ * - `input_file`, item_reference, web_search/preview tools → 400 invalid_request.
20
+ * - store/include/background/service_tier/truncation → ignored.
21
+ */
22
+
23
+ export class ResponsesBridgeError extends Error {
24
+ constructor(status, message, type = "invalid_request_error") {
25
+ super(message);
26
+ this.name = "ResponsesBridgeError";
27
+ this.status = status;
28
+ this.type = type;
29
+ }
30
+ }
31
+
32
+ const REASONING_EFFORTS = new Set(["low", "medium", "high"]);
33
+
34
+ function newId(prefix) {
35
+ return `${prefix}_${crypto.randomUUID().replaceAll("-", "")}`;
36
+ }
37
+
38
+ function toText(content) {
39
+ if (typeof content === "string") return content;
40
+ if (!Array.isArray(content)) return "";
41
+ return content
42
+ .map((part) => (typeof part?.text === "string" ? part.text : ""))
43
+ .filter(Boolean)
44
+ .join("");
45
+ }
46
+
47
+ function mapContentParts(content, role) {
48
+ if (typeof content === "string") return content;
49
+ if (!Array.isArray(content)) return "";
50
+ const out = [];
51
+ for (const part of content) {
52
+ if (!part || typeof part !== "object") continue;
53
+ switch (part.type) {
54
+ case "input_text":
55
+ case "output_text":
56
+ out.push({ type: "text", text: part.text ?? "" });
57
+ break;
58
+ case "refusal":
59
+ out.push({ type: "text", text: part.refusal ?? "" });
60
+ break;
61
+ case "input_image": {
62
+ const url = part.image_url;
63
+ if (typeof url !== "string" || !url) {
64
+ throw new ResponsesBridgeError(400, "input_image without image_url is not supported.");
65
+ }
66
+ out.push({ type: "image_url", image_url: { url } });
67
+ break;
68
+ }
69
+ case "input_file":
70
+ throw new ResponsesBridgeError(400, "input_file is not yet supported by the bridge.");
71
+ default:
72
+ throw new ResponsesBridgeError(400, `Unknown content part: "${part.type}".`);
73
+ }
74
+ }
75
+ // Single text part → plain string (more compatible with strict upstreams).
76
+ if (role !== "user" && out.every((p) => p.type === "text")) {
77
+ return out.map((p) => p.text).join("");
78
+ }
79
+ if (out.length === 1 && out[0].type === "text") return out[0].text;
80
+ return out;
81
+ }
82
+
83
+ function mapToolChoice(choice) {
84
+ if (choice === undefined || choice === null) return undefined;
85
+ if (typeof choice === "string") {
86
+ if (choice === "auto" || choice === "required" || choice === "none") return choice;
87
+ return "auto";
88
+ }
89
+ if (typeof choice === "object" && typeof choice.name === "string") {
90
+ // {type:"function"|"custom"|"allowed_tools", name}
91
+ return { type: "function", function: { name: choice.name } };
92
+ }
93
+ return undefined;
94
+ }
95
+
96
+ /**
97
+ * Responses request → { request, customToolNames } ready to send to
98
+ * POST {baseUrl}/v1/chat/completions. Only fields known to the gateway
99
+ * are forwarded (whitelist).
100
+ */
101
+ export function responsesToChatRequest(body) {
102
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
103
+ throw new ResponsesBridgeError(400, "Body must be a JSON object.");
104
+ }
105
+ const model = typeof body.model === "string" ? body.model.trim() : "";
106
+ if (!model) throw new ResponsesBridgeError(400, "Field `model` is required.");
107
+
108
+ const messages = [];
109
+ if (typeof body.instructions === "string" && body.instructions.trim()) {
110
+ messages.push({ role: "system", content: body.instructions });
111
+ }
112
+
113
+ const customToolNames = new Set();
114
+ let input = body.input;
115
+ if (typeof input === "string") {
116
+ input = [{ type: "message", role: "user", content: [{ type: "input_text", text: input }] }];
117
+ }
118
+ if (!Array.isArray(input)) {
119
+ throw new ResponsesBridgeError(400, "Field `input` must be a string or an array of items.");
120
+ }
121
+
122
+ for (const item of input) {
123
+ if (!item || typeof item !== "object") {
124
+ throw new ResponsesBridgeError(400, "`input` items must be objects.");
125
+ }
126
+ const type = item.type ?? (item.role ? "message" : undefined);
127
+ switch (type) {
128
+ case "message": {
129
+ const roleRaw = typeof item.role === "string" ? item.role : "user";
130
+ const role = roleRaw === "developer" ? "system" : roleRaw;
131
+ if (!["user", "assistant", "system", "tool"].includes(role)) {
132
+ throw new ResponsesBridgeError(400, `Unknown role: "${roleRaw}".`);
133
+ }
134
+ const content = mapContentParts(item.content, role);
135
+ messages.push({ role, content });
136
+ break;
137
+ }
138
+ case "function_call": {
139
+ if (typeof item.name !== "string" || !item.name) {
140
+ throw new ResponsesBridgeError(400, "function_call without `name`.");
141
+ }
142
+ const callId = typeof item.call_id === "string" && item.call_id ? item.call_id : newId("call");
143
+ messages.push({
144
+ role: "assistant",
145
+ content: "",
146
+ tool_calls: [
147
+ {
148
+ id: callId,
149
+ type: "function",
150
+ function: { name: item.name, arguments: typeof item.arguments === "string" ? item.arguments : "{}" },
151
+ },
152
+ ],
153
+ });
154
+ break;
155
+ }
156
+ case "custom_tool_call": {
157
+ if (typeof item.name !== "string" || !item.name) {
158
+ throw new ResponsesBridgeError(400, "custom_tool_call without `name`.");
159
+ }
160
+ customToolNames.add(item.name);
161
+ const callId = typeof item.call_id === "string" && item.call_id ? item.call_id : newId("call");
162
+ const inputText = typeof item.input === "string" ? item.input : "";
163
+ messages.push({
164
+ role: "assistant",
165
+ content: "",
166
+ tool_calls: [
167
+ {
168
+ id: callId,
169
+ type: "function",
170
+ function: { name: item.name, arguments: JSON.stringify({ input: inputText }) },
171
+ },
172
+ ],
173
+ });
174
+ break;
175
+ }
176
+ case "function_call_output": {
177
+ if (typeof item.call_id !== "string" || !item.call_id) {
178
+ throw new ResponsesBridgeError(400, "function_call_output without `call_id`.");
179
+ }
180
+ messages.push({
181
+ role: "tool",
182
+ tool_call_id: item.call_id,
183
+ content: typeof item.output === "string" ? item.output : JSON.stringify(item.output ?? ""),
184
+ });
185
+ break;
186
+ }
187
+ case "reasoning":
188
+ // Encrypted reasoning cannot be forwarded to chat upstream — drop.
189
+ break;
190
+ default:
191
+ throw new ResponsesBridgeError(400, `Unknown input item: "${String(type)}".`);
192
+ }
193
+ }
194
+ if (!messages.length || messages.every((m) => m.role === "system" && !m.content)) {
195
+ throw new ResponsesBridgeError(400, "No messages to send (empty input).");
196
+ }
197
+
198
+ const request = { model, messages };
199
+ if (body.stream === true) request.stream = true;
200
+
201
+ if (Array.isArray(body.tools) && body.tools.length) {
202
+ const tools = [];
203
+ for (const tool of body.tools) {
204
+ if (!tool || typeof tool !== "object") continue;
205
+ if (tool.type === "function") {
206
+ if (typeof tool.name !== "string" || !tool.name) {
207
+ throw new ResponsesBridgeError(400, "Function tool without `name`.");
208
+ }
209
+ const fn = {
210
+ name: tool.name,
211
+ ...(typeof tool.description === "string" ? { description: tool.description } : {}),
212
+ parameters:
213
+ tool.parameters && typeof tool.parameters === "object" ? tool.parameters : { type: "object", properties: {} },
214
+ };
215
+ if (tool.strict === true) fn.strict = true;
216
+ tools.push({ type: "function", function: fn });
217
+ } else if (tool.type === "custom") {
218
+ if (typeof tool.name !== "string" || !tool.name) {
219
+ throw new ResponsesBridgeError(400, "Custom tool without `name`.");
220
+ }
221
+ customToolNames.add(tool.name);
222
+ tools.push({
223
+ type: "function",
224
+ function: {
225
+ name: tool.name,
226
+ ...(typeof tool.description === "string" ? { description: tool.description } : {}),
227
+ parameters: {
228
+ type: "object",
229
+ properties: { input: { type: "string", description: "Freeform input for the tool." } },
230
+ required: ["input"],
231
+ additionalProperties: false,
232
+ },
233
+ },
234
+ });
235
+ }
236
+ // Other tool types (web_search etc.) cannot be honored upstream — skipped.
237
+ }
238
+ if (tools.length) request.tools = tools;
239
+ }
240
+
241
+ const toolChoice = mapToolChoice(body.tool_choice);
242
+ if (toolChoice !== undefined) request.tool_choice = toolChoice;
243
+ if (typeof body.max_output_tokens === "number" && Number.isFinite(body.max_output_tokens)) {
244
+ request.max_tokens = Math.max(1, Math.floor(body.max_output_tokens));
245
+ }
246
+ if (typeof body.temperature === "number") request.temperature = body.temperature;
247
+ if (typeof body.top_p === "number") request.top_p = body.top_p;
248
+ if (typeof body.parallel_tool_calls === "boolean") request.parallel_tool_calls = body.parallel_tool_calls;
249
+ if (body.reasoning && typeof body.reasoning === "object") {
250
+ const effort = body.reasoning.effort;
251
+ if (typeof effort === "string" && REASONING_EFFORTS.has(effort)) request.reasoning_effort = effort;
252
+ }
253
+
254
+ return { request, customToolNames };
255
+ }
256
+
257
+ function mapUsage(usage) {
258
+ if (!usage || typeof usage !== "object") return undefined;
259
+ const input = Number(usage.prompt_tokens ?? 0);
260
+ const output = Number(usage.completion_tokens ?? 0);
261
+ return {
262
+ input_tokens: input,
263
+ input_tokens_details: { cached_tokens: Number(usage.prompt_tokens_details?.cached_tokens ?? 0) },
264
+ output_tokens: output,
265
+ output_tokens_details: { reasoning_tokens: Number(usage.completion_tokens_details?.reasoning_tokens ?? 0) },
266
+ total_tokens: Number(usage.total_tokens ?? input + output),
267
+ };
268
+ }
269
+
270
+ function tryParseJson(text) {
271
+ try {
272
+ return JSON.parse(text);
273
+ } catch {
274
+ return undefined;
275
+ }
276
+ }
277
+
278
+ function toolCallToOutputItem(call, customToolNames) {
279
+ const name = call?.function?.name ?? "";
280
+ const callId = typeof call?.id === "string" && call.id ? call.id : newId("call");
281
+ const args = typeof call?.function?.arguments === "string" ? call.function.arguments : "";
282
+ if (customToolNames.has(name)) {
283
+ const parsed = tryParseJson(args);
284
+ const input = parsed && typeof parsed.input === "string" ? parsed.input : args;
285
+ return { type: "custom_tool_call", id: newId("ctc"), call_id: callId, name, input };
286
+ }
287
+ return {
288
+ type: "function_call",
289
+ id: newId("fc"),
290
+ call_id: callId,
291
+ name,
292
+ arguments: args,
293
+ status: "completed",
294
+ };
295
+ }
296
+
297
+ function baseResponse(id, createdAt, model) {
298
+ return {
299
+ id,
300
+ object: "response",
301
+ created_at: createdAt,
302
+ status: "in_progress",
303
+ background: false,
304
+ error: null,
305
+ incomplete_details: null,
306
+ instructions: null,
307
+ max_output_tokens: null,
308
+ model,
309
+ output: [],
310
+ parallel_tool_calls: true,
311
+ previous_response_id: null,
312
+ reasoning: { effort: null, summary: null },
313
+ store: false,
314
+ temperature: null,
315
+ text: { format: { type: "text" } },
316
+ tool_choice: "auto",
317
+ tools: [],
318
+ top_p: null,
319
+ truncation: "disabled",
320
+ usage: null,
321
+ metadata: {},
322
+ };
323
+ }
324
+
325
+ /** Chat completion JSON (non-stream) → Responses object. */
326
+ export function chatJsonToResponse(chat, { requestedModel, customToolNames = new Set(), responseId, createdAt } = {}) {
327
+ const id = responseId ?? newId("resp");
328
+ const created = createdAt ?? (typeof chat?.created === "number" ? chat.created : Math.floor(Date.now() / 1000));
329
+ const response = baseResponse(id, created, requestedModel ?? chat?.model ?? "unknown");
330
+
331
+ const choice = Array.isArray(chat?.choices) ? chat.choices[0] : undefined;
332
+ const message = choice?.message ?? {};
333
+ const finish = choice?.finish_reason ?? "stop";
334
+
335
+ const text = toText(message.content);
336
+ if (text) {
337
+ response.output.push({
338
+ type: "message",
339
+ id: newId("msg"),
340
+ status: "completed",
341
+ role: "assistant",
342
+ content: [{ type: "output_text", text, annotations: [] }],
343
+ });
344
+ }
345
+ if (Array.isArray(message.tool_calls)) {
346
+ for (const call of message.tool_calls) {
347
+ response.output.push(toolCallToOutputItem(call, customToolNames));
348
+ }
349
+ }
350
+
351
+ response.status = finish === "length" ? "incomplete" : "completed";
352
+ if (finish === "length") response.incomplete_details = { reason: "max_output_tokens" };
353
+ response.usage = mapUsage(chat?.usage) ?? null;
354
+ return response;
355
+ }
356
+
357
+ // ------------------------------------------------------------------ streaming
358
+
359
+ function encodeSse(event, data, seq) {
360
+ return `event: ${event}\ndata: ${JSON.stringify({ ...data, sequence_number: seq })}\n\n`;
361
+ }
362
+
363
+ /**
364
+ * Wrap a chat.completion.chunk stream (SSE byte ReadableStream from the gateway)
365
+ * into a Responses API event stream. `upstream` is a web ReadableStream
366
+ * (response.body from fetch).
367
+ */
368
+ export function wrapResponsesStream(upstream, { requestedModel, customToolNames = new Set(), responseId, createdAt } = {}) {
369
+ const encoder = new TextEncoder();
370
+ const decoder = new TextDecoder();
371
+ const id = responseId ?? newId("resp");
372
+ const created = createdAt ?? Math.floor(Date.now() / 1000);
373
+ const model = requestedModel ?? "unknown";
374
+ const finalResponse = baseResponse(id, created, model);
375
+
376
+ return new ReadableStream({
377
+ async start(controller) {
378
+ let seq = 0;
379
+ const send = (event, data) => controller.enqueue(encoder.encode(encodeSse(event, data, ++seq)));
380
+
381
+ // Open item state
382
+ let textOpen = false;
383
+ let textItemId = null;
384
+ let textBuffer = "";
385
+ let textOutputIndex = -1;
386
+ const toolStates = new Map(); // upstream tool_call.index → state
387
+ let nextOutputIndex = 0;
388
+ let usageFinal;
389
+ let finishReason = null;
390
+
391
+ const openText = () => {
392
+ textOpen = true;
393
+ textItemId = newId("msg");
394
+ textOutputIndex = nextOutputIndex++;
395
+ send("response.output_item.added", {
396
+ type: "response.output_item.added",
397
+ output_index: textOutputIndex,
398
+ item: { type: "message", id: textItemId, status: "in_progress", role: "assistant", content: [] },
399
+ });
400
+ send("response.content_part.added", {
401
+ type: "response.content_part.added",
402
+ item_id: textItemId,
403
+ output_index: textOutputIndex,
404
+ content_index: 0,
405
+ part: { type: "output_text", text: "", annotations: [] },
406
+ });
407
+ };
408
+
409
+ const closeText = () => {
410
+ if (!textOpen) return;
411
+ send("response.output_text.done", {
412
+ type: "response.output_text.done",
413
+ item_id: textItemId,
414
+ output_index: textOutputIndex,
415
+ content_index: 0,
416
+ text: textBuffer,
417
+ });
418
+ const part = { type: "output_text", text: textBuffer, annotations: [] };
419
+ send("response.content_part.done", {
420
+ type: "response.content_part.done",
421
+ item_id: textItemId,
422
+ output_index: textOutputIndex,
423
+ content_index: 0,
424
+ part,
425
+ });
426
+ send("response.output_item.done", {
427
+ type: "response.output_item.done",
428
+ output_index: textOutputIndex,
429
+ item: { type: "message", id: textItemId, status: "completed", role: "assistant", content: [part] },
430
+ });
431
+ finalResponse.output.push({
432
+ type: "message",
433
+ id: textItemId,
434
+ status: "completed",
435
+ role: "assistant",
436
+ content: [part],
437
+ });
438
+ textOpen = false;
439
+ };
440
+
441
+ const openTool = (idx, callId, name) => {
442
+ const custom = customToolNames.has(name);
443
+ const state = {
444
+ outputIndex: nextOutputIndex++,
445
+ itemId: newId(custom ? "ctc" : "fc"),
446
+ callId: callId || newId("call"),
447
+ name,
448
+ custom,
449
+ args: "",
450
+ closed: false,
451
+ };
452
+ toolStates.set(idx, state);
453
+ const item = custom
454
+ ? { type: "custom_tool_call", id: state.itemId, call_id: state.callId, name, input: "" }
455
+ : { type: "function_call", id: state.itemId, call_id: state.callId, name, arguments: "", status: "in_progress" };
456
+ send("response.output_item.added", { type: "response.output_item.added", output_index: state.outputIndex, item });
457
+ return state;
458
+ };
459
+
460
+ const closeTool = (state) => {
461
+ if (state.closed) return;
462
+ state.closed = true;
463
+ if (state.custom) {
464
+ const parsed = tryParseJson(state.args);
465
+ const input = parsed && typeof parsed.input === "string" ? parsed.input : state.args;
466
+ send("response.custom_tool_call_input.done", {
467
+ type: "response.custom_tool_call_input.done",
468
+ item_id: state.itemId,
469
+ output_index: state.outputIndex,
470
+ input,
471
+ });
472
+ send("response.output_item.done", {
473
+ type: "response.output_item.done",
474
+ output_index: state.outputIndex,
475
+ item: { type: "custom_tool_call", id: state.itemId, call_id: state.callId, name: state.name, input },
476
+ });
477
+ finalResponse.output.push({
478
+ type: "custom_tool_call",
479
+ id: state.itemId,
480
+ call_id: state.callId,
481
+ name: state.name,
482
+ input,
483
+ });
484
+ } else {
485
+ send("response.function_call_arguments.done", {
486
+ type: "response.function_call_arguments.done",
487
+ item_id: state.itemId,
488
+ output_index: state.outputIndex,
489
+ arguments: state.args,
490
+ });
491
+ send("response.output_item.done", {
492
+ type: "response.output_item.done",
493
+ output_index: state.outputIndex,
494
+ item: {
495
+ type: "function_call",
496
+ id: state.itemId,
497
+ call_id: state.callId,
498
+ name: state.name,
499
+ arguments: state.args,
500
+ status: "completed",
501
+ },
502
+ });
503
+ finalResponse.output.push({
504
+ type: "function_call",
505
+ id: state.itemId,
506
+ call_id: state.callId,
507
+ name: state.name,
508
+ arguments: state.args,
509
+ status: "completed",
510
+ });
511
+ }
512
+ };
513
+
514
+ const finalize = () => {
515
+ closeText();
516
+ for (const state of toolStates.values()) closeTool(state);
517
+ finalResponse.status = finishReason === "length" ? "incomplete" : "completed";
518
+ if (finishReason === "length") finalResponse.incomplete_details = { reason: "max_output_tokens" };
519
+ const mapped = mapUsage(usageFinal);
520
+ if (mapped) finalResponse.usage = mapped;
521
+ send("response.completed", { type: "response.completed", response: finalResponse });
522
+ };
523
+
524
+ const handleChunk = (chunk) => {
525
+ if (!chunk || typeof chunk !== "object") return;
526
+ if (chunk.error) {
527
+ const message = typeof chunk.error === "string" ? chunk.error : (chunk.error.message ?? "upstream error");
528
+ send("response.failed", {
529
+ type: "response.failed",
530
+ response: { ...finalResponse, status: "failed", error: { code: "upstream_error", message } },
531
+ });
532
+ finishReason = "__failed__";
533
+ return;
534
+ }
535
+ if (chunk.usage) usageFinal = chunk.usage;
536
+ const choice = Array.isArray(chunk.choices) ? chunk.choices[0] : undefined;
537
+ if (!choice) return; // chunk usage-only
538
+ const delta = choice.delta ?? {};
539
+
540
+ if (typeof delta.content === "string" && delta.content) {
541
+ if (!textOpen) openText();
542
+ textBuffer += delta.content;
543
+ send("response.output_text.delta", {
544
+ type: "response.output_text.delta",
545
+ item_id: textItemId,
546
+ output_index: textOutputIndex,
547
+ content_index: 0,
548
+ delta: delta.content,
549
+ });
550
+ }
551
+ // reasoning_content is not translated (no valid signature).
552
+
553
+ if (Array.isArray(delta.tool_calls)) {
554
+ if (textOpen) closeText(); // tool call after text → separate item
555
+ for (const tc of delta.tool_calls) {
556
+ const idx = typeof tc.index === "number" ? tc.index : 0;
557
+ let state = toolStates.get(idx);
558
+ if (!state) state = openTool(idx, tc.id, tc.function?.name ?? "");
559
+ if (typeof tc.id === "string" && tc.id) state.callId = tc.id;
560
+ if (typeof tc.function?.name === "string" && tc.function.name) state.name = tc.function.name;
561
+ const argDelta = typeof tc.function?.arguments === "string" ? tc.function.arguments : "";
562
+ if (argDelta) {
563
+ state.args += argDelta;
564
+ send(state.custom ? "response.custom_tool_call_input.delta" : "response.function_call_arguments.delta", {
565
+ type: state.custom ? "response.custom_tool_call_input.delta" : "response.function_call_arguments.delta",
566
+ item_id: state.itemId,
567
+ output_index: state.outputIndex,
568
+ delta: argDelta,
569
+ });
570
+ }
571
+ }
572
+ }
573
+ if (typeof choice.finish_reason === "string" && choice.finish_reason) {
574
+ finishReason = choice.finish_reason;
575
+ }
576
+ };
577
+
578
+ // ---- send opening events
579
+ send("response.created", { type: "response.created", response: baseResponse(id, created, model) });
580
+ send("response.in_progress", { type: "response.in_progress", response: baseResponse(id, created, model) });
581
+
582
+ const reader = upstream.getReader();
583
+ let buffer = "";
584
+ let failed = false;
585
+ try {
586
+ for (;;) {
587
+ const { done, value } = await reader.read();
588
+ if (done) break;
589
+ buffer += decoder.decode(value, { stream: true });
590
+ let sep;
591
+ while ((sep = buffer.indexOf("\n\n")) >= 0) {
592
+ const rawEvent = buffer.slice(0, sep);
593
+ buffer = buffer.slice(sep + 2);
594
+ const dataLines = [];
595
+ for (const line of rawEvent.split("\n")) {
596
+ if (line.startsWith("data:")) dataLines.push(line.slice(5).trimStart());
597
+ }
598
+ if (!dataLines.length) continue;
599
+ const payload = dataLines.join("\n");
600
+ if (payload === "[DONE]") continue;
601
+ let chunk;
602
+ try {
603
+ chunk = JSON.parse(payload);
604
+ } catch {
605
+ continue; // ignore unknown frame
606
+ }
607
+ handleChunk(chunk);
608
+ if (finishReason === "__failed__") {
609
+ failed = true;
610
+ break;
611
+ }
612
+ }
613
+ if (failed) break;
614
+ }
615
+ } catch (err) {
616
+ send("response.failed", {
617
+ type: "response.failed",
618
+ response: {
619
+ ...finalResponse,
620
+ status: "failed",
621
+ error: { code: "stream_error", message: err?.message ?? "stream error" },
622
+ },
623
+ });
624
+ failed = true;
625
+ } finally {
626
+ try {
627
+ await reader.cancel();
628
+ } catch {
629
+ /* ignore */
630
+ }
631
+ }
632
+
633
+ if (!failed) {
634
+ finalize();
635
+ }
636
+ controller.close();
637
+ },
638
+ });
639
+ }