@rerouter/helper 0.0.0-stage → 0.1.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.
- package/package.json +36 -4
- package/src/cli.js +266 -0
- package/src/lib/api.js +35 -0
- package/src/lib/bridge-server.js +217 -0
- package/src/lib/env.js +32 -0
- package/src/lib/files.js +85 -0
- package/src/lib/responses-bridge.js +639 -0
- package/src/lib/targets.js +390 -0
- package/src/responses-bridge.test.js +580 -0
- package/src/targets.test.js +215 -0
- package/README.md +0 -3
|
@@ -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
|
+
}
|