Skip to content

Commit 0d943bb

Browse files
committed
Merge Responses API compatibility
2 parents 7b21bcf + 59a6ec7 commit 0d943bb

8 files changed

Lines changed: 876 additions & 21 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,8 @@ The gateway continues running when the panel is hidden. Quitting ReRouted stops
159159
| `GET /` | Same unauthenticated local health response as `/health` |
160160
| `GET /health` | Local gateway health and listening port |
161161
| `GET /v1/models` | Enabled direct models and named routes |
162-
| `POST /v1/chat/completions` | Streaming or non-streaming routed completions |
162+
| `POST /v1/chat/completions` | Streaming or non-streaming routed chat completions |
163+
| `POST /v1/responses` | Streaming or non-streaming routed Responses API requests |
163164

164165
Requests require a generated bearer key except for `/` and `/health`. OpenAI-style image inputs inside chat-completion messages are supported when the selected upstream model accepts them. The separate `/v1/images` generation API, embeddings, audio, and the rest of the OpenAI platform API are outside ReRouted's scope.
165166

docs/architecture.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,8 @@ The renderer is vanilla HTML, CSS, and JavaScript. It renders onboarding and the
4949
| `GET /` | None | Same process health response as `/health` |
5050
| `GET /health` | None | App name and current listening port |
5151
| `GET /v1/models` | Bearer key | Enabled provider models plus named route IDs |
52-
| `POST /v1/chat/completions` | Bearer key | Streaming or non-streaming routed completion |
52+
| `POST /v1/chat/completions` | Bearer key | Streaming or non-streaming routed chat completion |
53+
| `POST /v1/responses` | Bearer key | Responses requests adapted through the chat-completions router |
5354

5455
The default bind is `127.0.0.1:4949`. Settings can switch the host to `0.0.0.0` for LAN or Tailscale access. CORS is currently `*`, so the bearer key is the gateway's access boundary when network binding is enabled.
5556

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "rerouted",
3-
"version": "0.4.12",
3+
"version": "0.4.13",
44
"description": "A macOS menu-bar router for accounts, models, and automatic fallback.",
55
"author": "gitcommit90",
66
"license": "MIT",

src/lib/gateway.js

Lines changed: 45 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,19 @@
33
const http = require("node:http");
44
const { DEFAULT_PORT } = require("./constants");
55
const logger = require("./logger");
6+
const {
7+
toChatCompletionsBody,
8+
fromChatCompletion,
9+
pipeChatCompletionsSseToResponses,
10+
toResponsesError,
11+
} = require("./responses-api");
612

713
const MAX_JSON_BODY_BYTES = 32 * 1024 * 1024;
814

915
/**
1016
* OpenAI-compatible HTTP gateway.
1117
* Auth: Authorization: Bearer <apiKey>
12-
* Routes: GET /v1/models, POST /v1/chat/completions, GET /health
18+
* Routes: GET /v1/models, POST /v1/chat/completions, POST /v1/responses, GET /health
1319
*/
1420
function createGateway({
1521
store,
@@ -160,13 +166,15 @@ function createGateway({
160166
return;
161167
}
162168

163-
if (req.method === "POST" && path === "/v1/chat/completions") {
169+
if (req.method === "POST" && (path === "/v1/chat/completions" || path === "/v1/responses")) {
170+
const responsesRequest = path === "/v1/responses";
171+
const routeName = responsesRequest ? "responses" : "chat/completions";
164172
let body;
165173
try {
166174
body = await readBody(req);
167175
} catch (error) {
168176
if (error?.code === "REQUEST_BODY_TOO_LARGE") {
169-
logger.warn("chat/completions: request body too large");
177+
logger.warn(`${routeName}: request body too large`);
170178
res.writeHead(413, { "Content-Type": "application/json" });
171179
res.end(
172180
JSON.stringify({
@@ -179,7 +187,7 @@ function createGateway({
179187
);
180188
return;
181189
}
182-
logger.warn("chat/completions: invalid JSON body");
190+
logger.warn(`${routeName}: invalid JSON body`);
183191
res.writeHead(400, { "Content-Type": "application/json" });
184192
res.end(
185193
JSON.stringify({
@@ -189,7 +197,7 @@ function createGateway({
189197
return;
190198
}
191199
if (!body.model) {
192-
logger.warn("chat/completions: missing model");
200+
logger.warn(`${routeName}: missing model`);
193201
res.writeHead(400, { "Content-Type": "application/json" });
194202
res.end(
195203
JSON.stringify({
@@ -199,7 +207,16 @@ function createGateway({
199207
return;
200208
}
201209

202-
logger.info("chat/completions request", {
210+
let routerBody;
211+
try {
212+
routerBody = responsesRequest ? toChatCompletionsBody(body) : body;
213+
} catch (error) {
214+
logger.warn(`${routeName}: invalid request`, { error: error.message });
215+
res.writeHead(error.status || 400, { "Content-Type": "application/json" });
216+
res.end(JSON.stringify(toResponsesError(error)));
217+
return;
218+
}
219+
logger.info(`${routeName} request`, {
203220
model: body.model,
204221
stream: !!body.stream,
205222
});
@@ -217,23 +234,23 @@ function createGateway({
217234
res.once("close", onClientAbort);
218235
try {
219236
const result = await router.chatCompletions({
220-
body,
237+
body: routerBody,
221238
signal: clientAbort.signal,
222239
onProviderSelected: (provider) => requestActivity?.route(activityId, provider),
223240
});
224241
if (!result.ok) {
225242
activityStatus = result.status || 502;
226243
activityOutcome = result.status === 499 ? "canceled" : "error";
227-
logger.error("chat/completions failed", {
244+
logger.error(`${routeName} failed`, {
228245
model: body.model,
229246
status: result.status,
230247
error: result.error?.error?.message || result.error,
231248
});
232249
res.writeHead(result.status || 502, { "Content-Type": "application/json" });
233-
res.end(JSON.stringify(result.error));
250+
res.end(JSON.stringify(responsesRequest ? toResponsesError(result.error) : result.error));
234251
return;
235252
}
236-
logger.info("chat/completions ok", {
253+
logger.info(`${routeName} ok`, {
237254
model: body.model,
238255
stream: !!result.stream,
239256
providerId: result.providerId,
@@ -247,24 +264,36 @@ function createGateway({
247264
Connection: "keep-alive",
248265
});
249266
try {
250-
await result.streamPipe(res);
267+
if (responsesRequest) {
268+
await pipeChatCompletionsSseToResponses(result.streamPipe, res, body.model, body);
269+
} else {
270+
await result.streamPipe(res);
271+
}
251272
activityStatus = 200;
252273
activityOutcome = "success";
253274
} catch (e) {
254275
activityStatus = clientAbort.signal.aborted ? 499 : 502;
255276
activityOutcome = clientAbort.signal.aborted ? "canceled" : "error";
256277
if (!res.writableEnded) {
257-
res.write(
258-
`data: ${JSON.stringify({ error: { message: e.message } })}\n\n`
259-
);
278+
if (!responsesRequest) {
279+
res.write(
280+
`data: ${JSON.stringify({ error: { message: e.message } })}\n\n`
281+
);
282+
}
260283
}
261284
}
262285
if (!res.writableEnded) res.end();
263286
return;
264287
}
265288

266-
res.writeHead(200, { "Content-Type": "application/json" });
267-
res.end(JSON.stringify(result.openAiJson));
289+
res.writeHead(200, { "Content-Type": "application/json" });
290+
res.end(
291+
JSON.stringify(
292+
responsesRequest
293+
? fromChatCompletion(result.openAiJson, body.model, body)
294+
: result.openAiJson
295+
)
296+
);
268297
activityStatus = 200;
269298
activityOutcome = "success";
270299
return;

0 commit comments

Comments
 (0)