Skip to content

Commit 2da0d6c

Browse files
committed
Merge Codex additional tools support
2 parents 4875fb8 + 6d67cf3 commit 2da0d6c

5 files changed

Lines changed: 108 additions & 13 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.14",
3+
"version": "0.4.15",
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: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -38,14 +38,7 @@ function validateResponsesRequest(body) {
3838
if (body.max_output_tokens != null && (!Number.isInteger(body.max_output_tokens) || body.max_output_tokens < 1)) {
3939
throw invalid("max_output_tokens must be a positive integer", "max_output_tokens");
4040
}
41-
if (Array.isArray(body.tools)) {
42-
body.tools.forEach((tool, index) => {
43-
if (!isObject(tool) || typeof tool.type !== "string") throw invalid("Each tool must be an object with a type", `tools[${index}]`);
44-
if (tool.type === "function" && (typeof tool.name !== "string" || !tool.name)) {
45-
throw invalid("Function tools require a name", `tools[${index}].name`);
46-
}
47-
});
48-
}
41+
if (Array.isArray(body.tools)) validateTools(body.tools, "tools");
4942
if (Array.isArray(body.input)) {
5043
body.input.forEach((item, index) => validateInputItem(item, `input[${index}]`));
5144
}
@@ -88,8 +81,22 @@ function reasoningItemsFromCall(call) {
8881
return Array.isArray(items) ? items.map(encryptedReasoningItem).filter(Boolean) : [];
8982
}
9083

84+
function validateTools(tools, param) {
85+
tools.forEach((tool, index) => {
86+
if (!isObject(tool) || typeof tool.type !== "string") throw invalid("Each tool must be an object with a type", `${param}[${index}]`);
87+
if (tool.type === "function" && (typeof tool.name !== "string" || !tool.name)) {
88+
throw invalid("Function tools require a name", `${param}[${index}].name`);
89+
}
90+
});
91+
}
92+
9193
function validateInputItem(item, param) {
9294
if (!isObject(item)) throw invalid("Input items must be objects", param);
95+
if (item.type === "additional_tools") {
96+
if (!Array.isArray(item.tools)) throw invalid("additional_tools tools must be an array", `${param}.tools`);
97+
validateTools(item.tools, `${param}.tools`);
98+
return;
99+
}
93100
if (item.type === "reasoning") {
94101
if (item.encrypted_content != null && typeof item.encrypted_content !== "string") {
95102
throw invalid("reasoning encrypted_content must be a string or null", `${param}.encrypted_content`);
@@ -110,7 +117,7 @@ function validateInputItem(item, param) {
110117
if (!(typeof item.output === "string" || Array.isArray(item.output))) throw invalid("function_call_output requires string or array output", `${param}.output`);
111118
return;
112119
}
113-
if (item.type !== "message" && !item.role) throw invalid("Unsupported input item", `${param}.type`);
120+
if (item.type !== undefined && item.type !== "message") throw invalid("Unsupported input item", `${param}.type`);
114121
if (!["user", "assistant", "system", "developer"].includes(item.role)) throw invalid("Invalid message role", `${param}.role`);
115122
if (item.content == null) {
116123
if (item.role !== "assistant") throw invalid("Message content must be a string, content part, or array", `${param}.content`);
@@ -129,6 +136,7 @@ function inputMessages(input) {
129136
let pendingAssistant = null;
130137
let pendingReasoning = [];
131138
for (const item of input) {
139+
if (item.type === "additional_tools") continue;
132140
if (item.type === "reasoning") {
133141
const reasoning = encryptedReasoningItem(item);
134142
if (reasoning) pendingReasoning.push(reasoning);
@@ -191,7 +199,11 @@ function toChatCompletionsBody(body) {
191199
const messages = inputMessages(body.input);
192200
if (body.instructions != null) messages.unshift({ role: "system", content: body.instructions });
193201
const out = { model: body.model, messages, stream: !!body.stream };
194-
const tools = toChatTools(body.tools);
202+
const declaredTools = [
203+
...(body.tools || []),
204+
...(Array.isArray(body.input) ? body.input.filter((item) => item.type === "additional_tools").flatMap((item) => item.tools) : []),
205+
];
206+
const tools = toChatTools(declaredTools);
195207
if (tools) out.tools = tools;
196208
if (body.tool_choice !== undefined) out.tool_choice = toChatToolChoice(body.tool_choice);
197209
if (body.reasoning !== undefined) out.reasoning = { ...body.reasoning };

tests/gateway.test.js

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

397+
it("routes Codex additional tools without a bogus message", 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_additional_tools",
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: [{
427+
type: "additional_tools",
428+
role: "developer",
429+
tools: [
430+
{ type: "custom", name: "shell", format: { type: "grammar", syntax: "lark", definition: "start: /.+/" } },
431+
{ type: "function", name: "read_file", parameters: { type: "object" } },
432+
],
433+
}],
434+
tools: [{ type: "namespace", name: "workspace", tools: [] }],
435+
}),
436+
});
437+
assert.equal(response.status, 200);
438+
assert.deepEqual(routedBody.messages, []);
439+
assert.deepEqual(routedBody.tools.map((tool) => tool.type), ["namespace", "custom", "function"]);
440+
assert.equal(routedBody.tools[1].format.definition, "start: /.+/");
441+
assert.equal(routedBody.tools[2].function.name, "read_file");
442+
} finally {
443+
await new Promise((resolve) => server.close(resolve));
444+
}
445+
});
446+
397447
it("routes the live Codex singleton content payload", async () => {
398448
const store = createStore(tmpConfig());
399449
const apiKey = store.load().apiKey;

tests/responses-api.test.js

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

186+
it("merges Codex additional tools without creating a message", () => {
187+
const grammar = { type: "grammar", syntax: "lark", definition: "start: /.+/" };
188+
const body = toChatCompletionsBody({
189+
model: "coding-route",
190+
input: [
191+
{
192+
type: "additional_tools",
193+
role: "developer",
194+
tools: [
195+
{ type: "custom", name: "shell", description: "Run a command", format: grammar },
196+
{ type: "function", name: "read_file", parameters: { type: "object", properties: { path: { type: "string" } } } },
197+
],
198+
},
199+
{ type: "message", role: "user", content: "Inspect it" },
200+
],
201+
tools: [{ type: "namespace", name: "workspace", tools: [] }],
202+
});
203+
204+
assert.deepEqual(body.messages, [{ role: "user", content: "Inspect it" }]);
205+
assert.deepEqual(body.tools, [
206+
{ type: "namespace", name: "workspace", tools: [] },
207+
{ type: "custom", name: "shell", description: "Run a command", format: grammar },
208+
{ type: "function", function: { name: "read_file", parameters: { type: "object", properties: { path: { type: "string" } } } } },
209+
]);
210+
});
211+
212+
it("does not let arbitrary role-bearing typed items bypass validation", () => {
213+
assert.throws(
214+
() => toChatCompletionsBody({ model: "route", input: [{ type: "unknown", role: "developer" }] }),
215+
(error) => error.status === 400 && error.error.param === "input[0].type"
216+
);
217+
});
218+
186219
it("accepts the live Codex singleton content payload", () => {
187220
const request = {
188221
model: "coding-route",

0 commit comments

Comments
 (0)