Skip to content

Commit 4875fb8

Browse files
committed
Merge Codex Responses content fix
2 parents 0d943bb + 21cd36c commit 4875fb8

5 files changed

Lines changed: 124 additions & 6 deletions

File tree

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.13",
3+
"version": "0.4.14",
44
"description": "A macOS menu-bar router for accounts, models, and automatic fallback.",
55
"author": "gitcommit90",
66
"license": "MIT",

src/lib/responses-api.js

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,13 @@ function validateContentPart(part, param) {
6565
}
6666
}
6767

68+
function normalizeContentParts(value, param) {
69+
if (Array.isArray(value)) return value;
70+
if (!isObject(value)) return value;
71+
validateContentPart(value, param);
72+
return [value];
73+
}
74+
6875
function encryptedReasoningItem(item) {
6976
if (!isObject(item) || item.type !== "reasoning" || typeof item.encrypted_content !== "string") return null;
7077
const copy = { type: "reasoning" };
@@ -84,9 +91,14 @@ function reasoningItemsFromCall(call) {
8491
function validateInputItem(item, param) {
8592
if (!isObject(item)) throw invalid("Input items must be objects", param);
8693
if (item.type === "reasoning") {
87-
if (!encryptedReasoningItem(item)) throw invalid("reasoning requires encrypted_content", `${param}.encrypted_content`);
94+
if (item.encrypted_content != null && typeof item.encrypted_content !== "string") {
95+
throw invalid("reasoning encrypted_content must be a string or null", `${param}.encrypted_content`);
96+
}
8897
return;
8998
}
99+
if (item.type === "item_reference") {
100+
throw invalid("item_reference is not supported by this stateless gateway; send the referenced item in input", param);
101+
}
90102
if (item.type === "function_call") {
91103
if (typeof item.call_id !== "string" || !item.call_id) throw invalid("function_call requires call_id", `${param}.call_id`);
92104
if (typeof item.name !== "string" || !item.name) throw invalid("function_call requires name", `${param}.name`);
@@ -100,7 +112,14 @@ function validateInputItem(item, param) {
100112
}
101113
if (item.type !== "message" && !item.role) throw invalid("Unsupported input item", `${param}.type`);
102114
if (!["user", "assistant", "system", "developer"].includes(item.role)) throw invalid("Invalid message role", `${param}.role`);
103-
if (!(typeof item.content === "string" || Array.isArray(item.content))) throw invalid("Message content must be a string or array", `${param}.content`);
115+
if (item.content == null) {
116+
if (item.role !== "assistant") throw invalid("Message content must be a string, content part, or array", `${param}.content`);
117+
return;
118+
}
119+
item.content = normalizeContentParts(item.content, `${param}.content`);
120+
if (!(typeof item.content === "string" || Array.isArray(item.content))) {
121+
throw invalid("Message content must be a string, content part, or array", `${param}.content`);
122+
}
104123
if (Array.isArray(item.content)) item.content.forEach((part, index) => validateContentPart(part, `${param}.content[${index}]`));
105124
}
106125

@@ -111,7 +130,8 @@ function inputMessages(input) {
111130
let pendingReasoning = [];
112131
for (const item of input) {
113132
if (item.type === "reasoning") {
114-
pendingReasoning.push(encryptedReasoningItem(item));
133+
const reasoning = encryptedReasoningItem(item);
134+
if (reasoning) pendingReasoning.push(reasoning);
115135
continue;
116136
}
117137
if (item.type === "function_call") {

tests/gateway.test.js

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,48 @@ describe("gateway Responses API", () => {
394394
}
395395
});
396396

397+
it("routes the live Codex singleton content payload", async () => {
398+
const store = createStore(tmpConfig());
399+
const apiKey = store.load().apiKey;
400+
let routedBody;
401+
const gateway = createGateway({
402+
store,
403+
router: {
404+
async chatCompletions({ body }) {
405+
routedBody = body;
406+
return {
407+
ok: true,
408+
stream: false,
409+
openAiJson: {
410+
id: "chatcmpl_singleton",
411+
choices: [{ message: { role: "assistant", content: "ok" }, finish_reason: "stop" }],
412+
},
413+
};
414+
},
415+
},
416+
});
417+
const server = http.createServer((req, res) => gateway.handle(req, res));
418+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
419+
420+
try {
421+
const response = await fetch(`http://127.0.0.1:${server.address().port}/v1/responses`, {
422+
method: "POST",
423+
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
424+
body: JSON.stringify({
425+
model: "route",
426+
input: [{ type: "message", role: "user", content: { type: "input_text", text: "hello" } }],
427+
}),
428+
});
429+
assert.equal(response.status, 200);
430+
assert.deepEqual(routedBody.messages, [
431+
{ role: "user", content: [{ type: "text", text: "hello" }] },
432+
]);
433+
assert.equal((await response.json()).output[0].content[0].text, "ok");
434+
} finally {
435+
await new Promise((resolve) => server.close(resolve));
436+
}
437+
});
438+
397439
it("returns Responses SSE and preserves router errors", async () => {
398440
const store = createStore(tmpConfig());
399441
const apiKey = store.load().apiKey;

tests/responses-api.test.js

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,58 @@ describe("Responses API adapter", () => {
183183
assert.equal(body.max_output_tokens, 4096);
184184
});
185185

186+
it("accepts the live Codex singleton content payload", () => {
187+
const request = {
188+
model: "coding-route",
189+
input: [
190+
{ type: "message", role: "user", content: { type: "input_text", text: "hello" } },
191+
],
192+
};
193+
const body = toChatCompletionsBody(request);
194+
assert.deepEqual(request.input[0].content, [{ type: "input_text", text: "hello" }]);
195+
assert.deepEqual(body.messages, [{ role: "user", content: [{ type: "text", text: "hello" }] }]);
196+
});
197+
198+
it("accepts supported singleton parts, empty assistant content, and nullable reasoning", () => {
199+
const body = toChatCompletionsBody({
200+
model: "route",
201+
input: [
202+
{ type: "message", role: "user", content: { type: "input_image", image_url: "data:image/png;base64,QUJD" } },
203+
{ type: "message", role: "assistant", content: null },
204+
{ type: "message", role: "assistant" },
205+
{ type: "reasoning", id: "rs_unavailable", encrypted_content: null, summary: [] },
206+
{ type: "function_call", call_id: "call_1", name: "shell", arguments: "{}" },
207+
],
208+
});
209+
assert.equal(body.messages[0].content[0].type, "image_url");
210+
assert.deepEqual(body.messages.slice(1, 3), [
211+
{ role: "assistant", content: null },
212+
{ role: "assistant", content: undefined },
213+
]);
214+
assert.equal(body.messages[3].tool_calls[0].extra_content, undefined);
215+
});
216+
217+
it("rejects arbitrary singleton objects and invalid adjacent shapes", () => {
218+
for (const [content, param] of [
219+
[{ foo: "bar" }, "input[0].content"],
220+
[{ type: "input_text", text: 1 }, "input[0].content.text"],
221+
[{ type: "unknown", text: "x" }, "input[0].content.type"],
222+
]) {
223+
assert.throws(
224+
() => toChatCompletionsBody({ model: "route", input: [{ type: "message", role: "user", content }] }),
225+
(error) => error.status === 400 && error.error.param === param
226+
);
227+
}
228+
assert.throws(
229+
() => toChatCompletionsBody({ model: "route", input: [{ type: "message", role: "user", content: null }] }),
230+
(error) => error.status === 400 && error.error.param === "input[0].content"
231+
);
232+
assert.throws(
233+
() => toChatCompletionsBody({ model: "route", input: [{ type: "reasoning", encrypted_content: {} }] }),
234+
(error) => error.status === 400 && error.error.param === "input[0].encrypted_content"
235+
);
236+
});
237+
186238
it("rejects malformed input and stateless continuation IDs", () => {
187239
assert.throws(
188240
() => toChatCompletionsBody({ model: "route", input: [{ type: "function_call", name: "x", arguments: "{}" }] }),
@@ -192,6 +244,10 @@ describe("Responses API adapter", () => {
192244
() => toChatCompletionsBody({ model: "route", input: "hi", previous_response_id: "resp_prior" }),
193245
(error) => error.status === 400 && error.error.param === "previous_response_id"
194246
);
247+
assert.throws(
248+
() => toChatCompletionsBody({ model: "route", input: [{ type: "item_reference", id: "rs_prior" }] }),
249+
(error) => error.status === 400 && error.error.param === "input[0]" && /stateless/.test(error.message)
250+
);
195251
});
196252

197253
it("preserves the requested route and marks length-limited responses incomplete", () => {

0 commit comments

Comments
 (0)