@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,580 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import { strict as assert } from "node:assert";
|
|
3
|
+
import {
|
|
4
|
+
ResponsesBridgeError,
|
|
5
|
+
chatJsonToResponse,
|
|
6
|
+
responsesToChatRequest,
|
|
7
|
+
wrapResponsesStream,
|
|
8
|
+
} from "./lib/responses-bridge.js";
|
|
9
|
+
import { startBridge } from "./lib/bridge-server.js";
|
|
10
|
+
|
|
11
|
+
// ------------------------------------------------------- helpers
|
|
12
|
+
function sseStream(frames) {
|
|
13
|
+
const encoder = new TextEncoder();
|
|
14
|
+
const payload = frames.map((f) => (f === "[DONE]" ? "data: [DONE]\n\n" : `data: ${JSON.stringify(f)}\n\n`)).join("");
|
|
15
|
+
return new ReadableStream({
|
|
16
|
+
start(controller) {
|
|
17
|
+
controller.enqueue(encoder.encode(payload));
|
|
18
|
+
controller.close();
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function readSseEvents(stream) {
|
|
24
|
+
const reader = stream.getReader();
|
|
25
|
+
const decoder = new TextDecoder();
|
|
26
|
+
let buffer = "";
|
|
27
|
+
const events = [];
|
|
28
|
+
for (;;) {
|
|
29
|
+
const { done, value } = await reader.read();
|
|
30
|
+
if (done) break;
|
|
31
|
+
buffer += decoder.decode(value, { stream: true });
|
|
32
|
+
let sep;
|
|
33
|
+
while ((sep = buffer.indexOf("\n\n")) >= 0) {
|
|
34
|
+
const raw = buffer.slice(0, sep);
|
|
35
|
+
buffer = buffer.slice(sep + 2);
|
|
36
|
+
const eventLine = raw.split("\n").find((l) => l.startsWith("event:"));
|
|
37
|
+
const dataLine = raw
|
|
38
|
+
.split("\n")
|
|
39
|
+
.filter((l) => l.startsWith("data:"))
|
|
40
|
+
.map((l) => l.slice(5).trimStart())
|
|
41
|
+
.join("\n");
|
|
42
|
+
events.push({ event: eventLine?.slice(6).trim(), data: dataLine ? JSON.parse(dataLine) : null });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return events;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ------------------------------------------------------- responsesToChatRequest
|
|
49
|
+
describe("responsesToChatRequest", () => {
|
|
50
|
+
it("translates a simple string input", () => {
|
|
51
|
+
const { request } = responsesToChatRequest({ model: "acme/pro-1", input: "hello" });
|
|
52
|
+
assert.equal(request.model, "acme/pro-1");
|
|
53
|
+
assert.deepEqual(request.messages, [{ role: "user", content: "hello" }]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("instructions → system message first", () => {
|
|
57
|
+
const { request } = responsesToChatRequest({
|
|
58
|
+
model: "m",
|
|
59
|
+
instructions: "You are an assistant.",
|
|
60
|
+
input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }],
|
|
61
|
+
});
|
|
62
|
+
assert.equal(request.messages[0].role, "system");
|
|
63
|
+
assert.equal(request.messages[0].content, "You are an assistant.");
|
|
64
|
+
assert.equal(request.messages[1].role, "user");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("role developer → system", () => {
|
|
68
|
+
const { request } = responsesToChatRequest({
|
|
69
|
+
model: "m",
|
|
70
|
+
input: [{ type: "message", role: "developer", content: [{ type: "input_text", text: "rules" }] }],
|
|
71
|
+
});
|
|
72
|
+
assert.equal(request.messages[0].role, "system");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("input_image → image_url, input_file → 400", () => {
|
|
76
|
+
const { request } = responsesToChatRequest({
|
|
77
|
+
model: "m",
|
|
78
|
+
input: [
|
|
79
|
+
{
|
|
80
|
+
type: "message",
|
|
81
|
+
role: "user",
|
|
82
|
+
content: [
|
|
83
|
+
{ type: "input_text", text: "look" },
|
|
84
|
+
{ type: "input_image", image_url: "https://x/img.png" },
|
|
85
|
+
],
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
});
|
|
89
|
+
const content = request.messages[0].content;
|
|
90
|
+
assert.equal(content[0].type, "text");
|
|
91
|
+
assert.deepEqual(content[1], { type: "image_url", image_url: { url: "https://x/img.png" } });
|
|
92
|
+
|
|
93
|
+
assert.throws(
|
|
94
|
+
() =>
|
|
95
|
+
responsesToChatRequest({
|
|
96
|
+
model: "m",
|
|
97
|
+
input: [{ type: "message", role: "user", content: [{ type: "input_file", file_id: "f1" }] }],
|
|
98
|
+
}),
|
|
99
|
+
(err) => err instanceof ResponsesBridgeError && err.status === 400,
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("function_call → assistant tool_calls; function_call_output → role tool", () => {
|
|
104
|
+
const { request } = responsesToChatRequest({
|
|
105
|
+
model: "m",
|
|
106
|
+
input: [
|
|
107
|
+
{ type: "message", role: "user", content: [{ type: "input_text", text: "call" }] },
|
|
108
|
+
{ type: "function_call", call_id: "call_1", name: "get_weather", arguments: '{"city":"JKT"}' },
|
|
109
|
+
{ type: "function_call_output", call_id: "call_1", output: "sunny" },
|
|
110
|
+
],
|
|
111
|
+
});
|
|
112
|
+
const assistant = request.messages[1];
|
|
113
|
+
assert.equal(assistant.role, "assistant");
|
|
114
|
+
assert.deepEqual(assistant.tool_calls, [
|
|
115
|
+
{ id: "call_1", type: "function", function: { name: "get_weather", arguments: '{"city":"JKT"}' } },
|
|
116
|
+
]);
|
|
117
|
+
const tool = request.messages[2];
|
|
118
|
+
assert.equal(tool.role, "tool");
|
|
119
|
+
assert.equal(tool.tool_call_id, "call_1");
|
|
120
|
+
assert.equal(tool.content, "sunny");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("custom tool wrapped as function {input:string} and tracked", () => {
|
|
124
|
+
const { request, customToolNames } = responsesToChatRequest({
|
|
125
|
+
model: "m",
|
|
126
|
+
input: "x",
|
|
127
|
+
tools: [
|
|
128
|
+
{ type: "function", name: "f1", parameters: { type: "object", properties: { a: { type: "string" } } } },
|
|
129
|
+
{ type: "custom", name: "c1", description: "freeform" },
|
|
130
|
+
],
|
|
131
|
+
});
|
|
132
|
+
assert.equal(request.tools.length, 2);
|
|
133
|
+
assert.equal(request.tools[0].function.name, "f1");
|
|
134
|
+
const c1 = request.tools[1].function;
|
|
135
|
+
assert.equal(c1.name, "c1");
|
|
136
|
+
assert.deepEqual(c1.parameters.required, ["input"]);
|
|
137
|
+
assert.equal(c1.parameters.properties.input.type, "string");
|
|
138
|
+
assert.ok(customToolNames.has("c1"));
|
|
139
|
+
assert.ok(!customToolNames.has("f1"));
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("custom_tool_call input → arguments JSON {input}", () => {
|
|
143
|
+
const { request, customToolNames } = responsesToChatRequest({
|
|
144
|
+
model: "m",
|
|
145
|
+
input: [{ type: "custom_tool_call", call_id: "k1", name: "paint", input: "cat painting" }],
|
|
146
|
+
});
|
|
147
|
+
assert.ok(customToolNames.has("paint"));
|
|
148
|
+
assert.equal(request.messages[0].tool_calls[0].function.arguments, '{"input":"cat painting"}');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("reasoning item dropped, reasoning.effort → reasoning_effort", () => {
|
|
152
|
+
const { request } = responsesToChatRequest({
|
|
153
|
+
model: "m",
|
|
154
|
+
input: [
|
|
155
|
+
{ type: "reasoning", summary: [] },
|
|
156
|
+
{ type: "message", role: "user", content: [{ type: "input_text", text: "x" }] },
|
|
157
|
+
],
|
|
158
|
+
reasoning: { effort: "high" },
|
|
159
|
+
});
|
|
160
|
+
assert.equal(request.messages.length, 1);
|
|
161
|
+
assert.equal(request.reasoning_effort, "high");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("max_output_tokens → max_tokens, stream, tool_choice object", () => {
|
|
165
|
+
const { request } = responsesToChatRequest({
|
|
166
|
+
model: "m",
|
|
167
|
+
input: "x",
|
|
168
|
+
stream: true,
|
|
169
|
+
max_output_tokens: 512.9,
|
|
170
|
+
tool_choice: { type: "function", name: "f1" },
|
|
171
|
+
});
|
|
172
|
+
assert.equal(request.max_tokens, 512);
|
|
173
|
+
assert.equal(request.stream, true);
|
|
174
|
+
assert.deepEqual(request.tool_choice, { type: "function", function: { name: "f1" } });
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("rejects empty input and missing model", () => {
|
|
178
|
+
assert.throws(() => responsesToChatRequest({ input: "x" }), /model/);
|
|
179
|
+
assert.throws(() => responsesToChatRequest({ model: "m", input: [] }), /empty/);
|
|
180
|
+
assert.throws(() => responsesToChatRequest("not an object"), /object/);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// ------------------------------------------------------- chatJsonToResponse
|
|
185
|
+
describe("chatJsonToResponse", () => {
|
|
186
|
+
const baseChat = {
|
|
187
|
+
id: "chatcmpl-1",
|
|
188
|
+
created: 1700000000,
|
|
189
|
+
model: "acme/pro-1",
|
|
190
|
+
choices: [{ message: { role: "assistant", content: "answer" }, finish_reason: "stop" }],
|
|
191
|
+
usage: {
|
|
192
|
+
prompt_tokens: 10,
|
|
193
|
+
completion_tokens: 5,
|
|
194
|
+
total_tokens: 15,
|
|
195
|
+
prompt_tokens_details: { cached_tokens: 2 },
|
|
196
|
+
completion_tokens_details: { reasoning_tokens: 1 },
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
it("text → output message + usage mapping", () => {
|
|
201
|
+
const resp = chatJsonToResponse(baseChat, { requestedModel: "acme/pro-1" });
|
|
202
|
+
assert.match(resp.id, /^resp_/);
|
|
203
|
+
assert.equal(resp.object, "response");
|
|
204
|
+
assert.equal(resp.status, "completed");
|
|
205
|
+
assert.equal(resp.model, "acme/pro-1");
|
|
206
|
+
assert.equal(resp.output.length, 1);
|
|
207
|
+
assert.equal(resp.output[0].type, "message");
|
|
208
|
+
assert.equal(resp.output[0].content[0].type, "output_text");
|
|
209
|
+
assert.equal(resp.output[0].content[0].text, "answer");
|
|
210
|
+
assert.deepEqual(resp.usage, {
|
|
211
|
+
input_tokens: 10,
|
|
212
|
+
input_tokens_details: { cached_tokens: 2 },
|
|
213
|
+
output_tokens: 5,
|
|
214
|
+
output_tokens_details: { reasoning_tokens: 1 },
|
|
215
|
+
total_tokens: 15,
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("finish length → status incomplete + incomplete_details", () => {
|
|
220
|
+
const resp = chatJsonToResponse(
|
|
221
|
+
{ ...baseChat, choices: [{ message: { content: "x" }, finish_reason: "length" }] },
|
|
222
|
+
{ requestedModel: "m" },
|
|
223
|
+
);
|
|
224
|
+
assert.equal(resp.status, "incomplete");
|
|
225
|
+
assert.deepEqual(resp.incomplete_details, { reason: "max_output_tokens" });
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("tool_calls → function_call / custom_tool_call output items", () => {
|
|
229
|
+
const chat = {
|
|
230
|
+
...baseChat,
|
|
231
|
+
choices: [
|
|
232
|
+
{
|
|
233
|
+
message: {
|
|
234
|
+
content: null,
|
|
235
|
+
tool_calls: [
|
|
236
|
+
{ id: "call_a", type: "function", function: { name: "f1", arguments: '{"a":1}' } },
|
|
237
|
+
{ id: "call_b", type: "function", function: { name: "c1", arguments: '{"input":"freeform"}' } },
|
|
238
|
+
],
|
|
239
|
+
},
|
|
240
|
+
finish_reason: "tool_calls",
|
|
241
|
+
},
|
|
242
|
+
],
|
|
243
|
+
};
|
|
244
|
+
const resp = chatJsonToResponse(chat, {
|
|
245
|
+
requestedModel: "m",
|
|
246
|
+
customToolNames: new Set(["c1"]),
|
|
247
|
+
});
|
|
248
|
+
assert.equal(resp.output.length, 2);
|
|
249
|
+
const fn = resp.output[0];
|
|
250
|
+
assert.equal(fn.type, "function_call");
|
|
251
|
+
assert.equal(fn.call_id, "call_a");
|
|
252
|
+
assert.equal(fn.arguments, '{"a":1}');
|
|
253
|
+
const custom = resp.output[1];
|
|
254
|
+
assert.equal(custom.type, "custom_tool_call");
|
|
255
|
+
assert.equal(custom.call_id, "call_b");
|
|
256
|
+
assert.equal(custom.input, "freeform");
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// ------------------------------------------------------- wrapResponsesStream
|
|
261
|
+
describe("wrapResponsesStream", () => {
|
|
262
|
+
it("wraps a text stream into complete Responses events", async () => {
|
|
263
|
+
const upstream = sseStream([
|
|
264
|
+
{ choices: [{ delta: { role: "assistant" }, finish_reason: null }] },
|
|
265
|
+
{ choices: [{ delta: { content: "Hello " }, finish_reason: null }] },
|
|
266
|
+
{ choices: [{ delta: { content: "world" }, finish_reason: null }] },
|
|
267
|
+
{ choices: [{ delta: {}, finish_reason: "stop" }], usage: { prompt_tokens: 3, completion_tokens: 2, total_tokens: 5 } },
|
|
268
|
+
"[DONE]",
|
|
269
|
+
]);
|
|
270
|
+
const events = await readSseEvents(wrapResponsesStream(upstream, { requestedModel: "acme/pro-1" }));
|
|
271
|
+
const names = events.map((e) => e.event);
|
|
272
|
+
|
|
273
|
+
assert.equal(names[0], "response.created");
|
|
274
|
+
assert.equal(names[1], "response.in_progress");
|
|
275
|
+
assert.ok(names.includes("response.output_item.added"));
|
|
276
|
+
assert.ok(names.includes("response.content_part.added"));
|
|
277
|
+
assert.equal(names.filter((n) => n === "response.output_text.delta").length, 2);
|
|
278
|
+
assert.ok(names.includes("response.output_text.done"));
|
|
279
|
+
assert.ok(names.includes("response.content_part.done"));
|
|
280
|
+
assert.ok(names.includes("response.output_item.done"));
|
|
281
|
+
assert.equal(names.at(-1), "response.completed");
|
|
282
|
+
|
|
283
|
+
// sequence_number increases monotonically
|
|
284
|
+
const seqs = events.map((e) => e.data.sequence_number);
|
|
285
|
+
assert.deepEqual(seqs, [...seqs].sort((a, b) => a - b));
|
|
286
|
+
|
|
287
|
+
const deltas = events.filter((e) => e.event === "response.output_text.delta").map((e) => e.data.delta);
|
|
288
|
+
assert.equal(deltas.join(""), "Hello world");
|
|
289
|
+
|
|
290
|
+
const done = events.find((e) => e.event === "response.output_text.done");
|
|
291
|
+
assert.equal(done.data.text, "Hello world");
|
|
292
|
+
|
|
293
|
+
const completed = events.at(-1).data.response;
|
|
294
|
+
assert.equal(completed.status, "completed");
|
|
295
|
+
assert.equal(completed.model, "acme/pro-1");
|
|
296
|
+
assert.equal(completed.output[0].type, "message");
|
|
297
|
+
assert.equal(completed.output[0].content[0].text, "Hello world");
|
|
298
|
+
assert.equal(completed.usage.total_tokens, 5);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("wraps tool_calls stream → function_call events", async () => {
|
|
302
|
+
const upstream = sseStream([
|
|
303
|
+
{
|
|
304
|
+
choices: [
|
|
305
|
+
{
|
|
306
|
+
delta: { tool_calls: [{ index: 0, id: "call_1", function: { name: "get_weather", arguments: "" } }] },
|
|
307
|
+
finish_reason: null,
|
|
308
|
+
},
|
|
309
|
+
],
|
|
310
|
+
},
|
|
311
|
+
{ choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: '{"city":' } }] }, finish_reason: null }] },
|
|
312
|
+
{ choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: '"JKT"}' } }] }, finish_reason: null }] },
|
|
313
|
+
{ choices: [{ delta: {}, finish_reason: "tool_calls" }] },
|
|
314
|
+
"[DONE]",
|
|
315
|
+
]);
|
|
316
|
+
const events = await readSseEvents(wrapResponsesStream(upstream, { requestedModel: "m" }));
|
|
317
|
+
const names = events.map((e) => e.event);
|
|
318
|
+
assert.ok(names.includes("response.function_call_arguments.delta"));
|
|
319
|
+
assert.ok(names.includes("response.function_call_arguments.done"));
|
|
320
|
+
|
|
321
|
+
const completed = events.at(-1).data.response;
|
|
322
|
+
const fnItem = completed.output.find((o) => o.type === "function_call");
|
|
323
|
+
assert.equal(fnItem.call_id, "call_1");
|
|
324
|
+
assert.equal(fnItem.name, "get_weather");
|
|
325
|
+
assert.equal(fnItem.arguments, '{"city":"JKT"}');
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("custom tool stream → custom_tool_call events with parsed input", async () => {
|
|
329
|
+
const upstream = sseStream([
|
|
330
|
+
{
|
|
331
|
+
choices: [
|
|
332
|
+
{ delta: { tool_calls: [{ index: 0, id: "c9", function: { name: "paint", arguments: '{"input":"cat"}' } }] }, finish_reason: null },
|
|
333
|
+
],
|
|
334
|
+
},
|
|
335
|
+
{ choices: [{ delta: {}, finish_reason: "tool_calls" }] },
|
|
336
|
+
"[DONE]",
|
|
337
|
+
]);
|
|
338
|
+
const events = await readSseEvents(
|
|
339
|
+
wrapResponsesStream(upstream, { requestedModel: "m", customToolNames: new Set(["paint"]) }),
|
|
340
|
+
);
|
|
341
|
+
const names = events.map((e) => e.event);
|
|
342
|
+
assert.ok(names.includes("response.custom_tool_call_input.delta"));
|
|
343
|
+
assert.ok(names.includes("response.custom_tool_call_input.done"));
|
|
344
|
+
const completed = events.at(-1).data.response;
|
|
345
|
+
const item = completed.output.find((o) => o.type === "custom_tool_call");
|
|
346
|
+
assert.equal(item.input, "cat");
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it("upstream error chunk → response.failed without completed", async () => {
|
|
350
|
+
const upstream = sseStream([
|
|
351
|
+
{ choices: [{ delta: { content: "partial" }, finish_reason: null }] },
|
|
352
|
+
{ error: { message: "upstream exploded" } },
|
|
353
|
+
]);
|
|
354
|
+
const events = await readSseEvents(wrapResponsesStream(upstream, { requestedModel: "m" }));
|
|
355
|
+
const names = events.map((e) => e.event);
|
|
356
|
+
assert.ok(names.includes("response.failed"));
|
|
357
|
+
assert.ok(!names.includes("response.completed"));
|
|
358
|
+
const failed = events.find((e) => e.event === "response.failed");
|
|
359
|
+
assert.equal(failed.data.response.status, "failed");
|
|
360
|
+
assert.match(failed.data.response.error.message, /upstream exploded/);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it("finish length → response.completed status incomplete", async () => {
|
|
364
|
+
const upstream = sseStream([
|
|
365
|
+
{ choices: [{ delta: { content: "x" }, finish_reason: null }] },
|
|
366
|
+
{ choices: [{ delta: {}, finish_reason: "length" }] },
|
|
367
|
+
"[DONE]",
|
|
368
|
+
]);
|
|
369
|
+
const events = await readSseEvents(wrapResponsesStream(upstream, { requestedModel: "m" }));
|
|
370
|
+
const completed = events.at(-1).data.response;
|
|
371
|
+
assert.equal(completed.status, "incomplete");
|
|
372
|
+
assert.deepEqual(completed.incomplete_details, { reason: "max_output_tokens" });
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
// ------------------------------------------------------- startBridge (server)
|
|
377
|
+
describe("startBridge", () => {
|
|
378
|
+
function fakeFetchChat(chatJson) {
|
|
379
|
+
return async (url, init) => {
|
|
380
|
+
assert.ok(url.endsWith("/v1/chat/completions"));
|
|
381
|
+
return new Response(JSON.stringify(chatJson), {
|
|
382
|
+
status: 200,
|
|
383
|
+
headers: { "content-type": "application/json" },
|
|
384
|
+
});
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async function withBridge(fetchImpl, fn) {
|
|
389
|
+
const bridge = await startBridge({ port: 0, baseUrl: "https://gw.test", apiKey: "sk-rr-test", fetchImpl });
|
|
390
|
+
try {
|
|
391
|
+
await fn(bridge);
|
|
392
|
+
} finally {
|
|
393
|
+
await bridge.close();
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
it("GET /health answers ok without auth", async () => {
|
|
398
|
+
await withBridge(fetch, async (bridge) => {
|
|
399
|
+
const res = await fetch(`${bridge.url.replace(/\/v1$/, "")}/health`);
|
|
400
|
+
assert.equal(res.status, 200);
|
|
401
|
+
const body = await res.json();
|
|
402
|
+
assert.equal(body.ok, true);
|
|
403
|
+
assert.equal(body.upstream, "https://gw.test");
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it("POST /v1/responses non-stream translates both ways + forwards auth", async () => {
|
|
408
|
+
let seenAuth;
|
|
409
|
+
let seenBody;
|
|
410
|
+
const fetchImpl = async (url, init) => {
|
|
411
|
+
seenAuth = init.headers.authorization;
|
|
412
|
+
seenBody = JSON.parse(init.body);
|
|
413
|
+
return new Response(
|
|
414
|
+
JSON.stringify({
|
|
415
|
+
created: 1700000000,
|
|
416
|
+
model: "acme/pro-1",
|
|
417
|
+
choices: [{ message: { role: "assistant", content: "hello" }, finish_reason: "stop" }],
|
|
418
|
+
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
|
|
419
|
+
}),
|
|
420
|
+
{ status: 200, headers: { "content-type": "application/json" } },
|
|
421
|
+
);
|
|
422
|
+
};
|
|
423
|
+
await withBridge(fetchImpl, async (bridge) => {
|
|
424
|
+
const res = await fetch(`${bridge.url}/responses`, {
|
|
425
|
+
method: "POST",
|
|
426
|
+
headers: { "content-type": "application/json", authorization: "Bearer sk-rr-client" },
|
|
427
|
+
body: JSON.stringify({ model: "acme/pro-1", input: "hi" }),
|
|
428
|
+
});
|
|
429
|
+
assert.equal(res.status, 200);
|
|
430
|
+
const body = await res.json();
|
|
431
|
+
assert.equal(body.object, "response");
|
|
432
|
+
assert.equal(body.output[0].content[0].text, "hello");
|
|
433
|
+
assert.equal(seenAuth, "Bearer sk-rr-client", "client auth forwarded as-is");
|
|
434
|
+
assert.deepEqual(seenBody.messages, [{ role: "user", content: "hi" }]);
|
|
435
|
+
});
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it("falls back to local apiKey when client sends no Authorization", async () => {
|
|
439
|
+
let seenAuth;
|
|
440
|
+
const fetchImpl = async (url, init) => {
|
|
441
|
+
seenAuth = init.headers.authorization;
|
|
442
|
+
return new Response(
|
|
443
|
+
JSON.stringify({ choices: [{ message: { content: "x" }, finish_reason: "stop" }] }),
|
|
444
|
+
{ status: 200, headers: { "content-type": "application/json" } },
|
|
445
|
+
);
|
|
446
|
+
};
|
|
447
|
+
await withBridge(fetchImpl, async (bridge) => {
|
|
448
|
+
const res = await fetch(`${bridge.url}/responses`, {
|
|
449
|
+
method: "POST",
|
|
450
|
+
headers: { "content-type": "application/json" },
|
|
451
|
+
body: JSON.stringify({ model: "m", input: "x" }),
|
|
452
|
+
});
|
|
453
|
+
assert.equal(res.status, 200);
|
|
454
|
+
assert.equal(seenAuth, "Bearer sk-rr-test");
|
|
455
|
+
});
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it("no auth at all → 401", async () => {
|
|
459
|
+
const bridge = await startBridge({ port: 0, baseUrl: "https://gw.test", fetchImpl: fakeFetchChat({}) });
|
|
460
|
+
try {
|
|
461
|
+
const res = await fetch(`${bridge.url}/responses`, {
|
|
462
|
+
method: "POST",
|
|
463
|
+
headers: { "content-type": "application/json" },
|
|
464
|
+
body: JSON.stringify({ model: "m", input: "x" }),
|
|
465
|
+
});
|
|
466
|
+
assert.equal(res.status, 401);
|
|
467
|
+
const body = await res.json();
|
|
468
|
+
assert.equal(body.error.type, "authentication_error");
|
|
469
|
+
} finally {
|
|
470
|
+
await bridge.close();
|
|
471
|
+
}
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it("upstream error forwarded with status + gateway message", async () => {
|
|
475
|
+
const fetchImpl = async () =>
|
|
476
|
+
new Response(JSON.stringify({ error: { message: "insufficient balance" } }), {
|
|
477
|
+
status: 402,
|
|
478
|
+
headers: { "content-type": "application/json" },
|
|
479
|
+
});
|
|
480
|
+
await withBridge(fetchImpl, async (bridge) => {
|
|
481
|
+
const res = await fetch(`${bridge.url}/responses`, {
|
|
482
|
+
method: "POST",
|
|
483
|
+
headers: { "content-type": "application/json", authorization: "Bearer k" },
|
|
484
|
+
body: JSON.stringify({ model: "m", input: "x" }),
|
|
485
|
+
});
|
|
486
|
+
assert.equal(res.status, 402);
|
|
487
|
+
const body = await res.json();
|
|
488
|
+
assert.match(body.error.message, /insufficient balance/);
|
|
489
|
+
assert.equal(body.error.code, "upstream_error");
|
|
490
|
+
});
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
it("gateway unreachable → 502 upstream_unreachable", async () => {
|
|
494
|
+
const fetchImpl = async () => {
|
|
495
|
+
throw new Error("ECONNREFUSED");
|
|
496
|
+
};
|
|
497
|
+
await withBridge(fetchImpl, async (bridge) => {
|
|
498
|
+
const res = await fetch(`${bridge.url}/responses`, {
|
|
499
|
+
method: "POST",
|
|
500
|
+
headers: { "content-type": "application/json", authorization: "Bearer k" },
|
|
501
|
+
body: JSON.stringify({ model: "m", input: "x" }),
|
|
502
|
+
});
|
|
503
|
+
assert.equal(res.status, 502);
|
|
504
|
+
const body = await res.json();
|
|
505
|
+
assert.equal(body.error.code, "upstream_unreachable");
|
|
506
|
+
});
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it("stream: SSE from the gateway wrapped into Responses events", async () => {
|
|
510
|
+
const sse = [
|
|
511
|
+
'data: {"choices":[{"delta":{"content":"hi "},"finish_reason":null}]}',
|
|
512
|
+
'data: {"choices":[{"delta":{"content":"you"},"finish_reason":null}]}',
|
|
513
|
+
'data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3}}',
|
|
514
|
+
"data: [DONE]",
|
|
515
|
+
].join("\n\n") + "\n\n";
|
|
516
|
+
const fetchImpl = async () =>
|
|
517
|
+
new Response(sse, { status: 200, headers: { "content-type": "text/event-stream" } });
|
|
518
|
+
await withBridge(fetchImpl, async (bridge) => {
|
|
519
|
+
const res = await fetch(`${bridge.url}/responses`, {
|
|
520
|
+
method: "POST",
|
|
521
|
+
headers: { "content-type": "application/json", authorization: "Bearer k" },
|
|
522
|
+
body: JSON.stringify({ model: "m", input: "x", stream: true }),
|
|
523
|
+
});
|
|
524
|
+
assert.equal(res.status, 200);
|
|
525
|
+
assert.match(res.headers.get("content-type"), /text\/event-stream/);
|
|
526
|
+
const text = await res.text();
|
|
527
|
+
assert.match(text, /event: response\.created/);
|
|
528
|
+
assert.match(text, /event: response\.output_text\.delta/);
|
|
529
|
+
assert.match(text, /event: response\.completed/);
|
|
530
|
+
const completedLine = text
|
|
531
|
+
.split("\n")
|
|
532
|
+
.filter((l) => l.startsWith("data:"))
|
|
533
|
+
.map((l) => JSON.parse(l.slice(5).trim()))
|
|
534
|
+
.find((d) => d.type === "response.completed");
|
|
535
|
+
assert.equal(completedLine.response.status, "completed");
|
|
536
|
+
assert.equal(completedLine.response.usage.total_tokens, 3);
|
|
537
|
+
});
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
it("GET /v1/models passthrough to the gateway", async () => {
|
|
541
|
+
let seenAuth;
|
|
542
|
+
const fetchImpl = async (url, init) => {
|
|
543
|
+
seenAuth = init.headers.authorization;
|
|
544
|
+
assert.ok(url.endsWith("/v1/models"));
|
|
545
|
+
return new Response(JSON.stringify({ object: "list", data: [{ id: "m1" }] }), {
|
|
546
|
+
status: 200,
|
|
547
|
+
headers: { "content-type": "application/json" },
|
|
548
|
+
});
|
|
549
|
+
};
|
|
550
|
+
await withBridge(fetchImpl, async (bridge) => {
|
|
551
|
+
const res = await fetch(`${bridge.url}/models`, {
|
|
552
|
+
headers: { authorization: "Bearer sk-rr-client" },
|
|
553
|
+
});
|
|
554
|
+
assert.equal(res.status, 200);
|
|
555
|
+
const body = await res.json();
|
|
556
|
+
assert.equal(body.data[0].id, "m1");
|
|
557
|
+
assert.equal(seenAuth, "Bearer sk-rr-client");
|
|
558
|
+
});
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
it("invalid request (not JSON / unknown input) → 400", async () => {
|
|
562
|
+
await withBridge(fakeFetchChat({}), async (bridge) => {
|
|
563
|
+
const bad = await fetch(`${bridge.url}/responses`, {
|
|
564
|
+
method: "POST",
|
|
565
|
+
headers: { "content-type": "application/json", authorization: "Bearer k" },
|
|
566
|
+
body: "not json",
|
|
567
|
+
});
|
|
568
|
+
assert.equal(bad.status, 400);
|
|
569
|
+
|
|
570
|
+
const unknown = await fetch(`${bridge.url}/responses`, {
|
|
571
|
+
method: "POST",
|
|
572
|
+
headers: { "content-type": "application/json", authorization: "Bearer k" },
|
|
573
|
+
body: JSON.stringify({ model: "m", input: [{ type: "alien_item" }] }),
|
|
574
|
+
});
|
|
575
|
+
assert.equal(unknown.status, 400);
|
|
576
|
+
const body = await unknown.json();
|
|
577
|
+
assert.match(body.error.message, /Unknown/);
|
|
578
|
+
});
|
|
579
|
+
});
|
|
580
|
+
});
|