Skip to content

Commit 571b757

Browse files
committed
Merge Codex custom tool support
2 parents 2da0d6c + 54a086f commit 571b757

6 files changed

Lines changed: 182 additions & 40 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.15",
3+
"version": "0.4.16",
44
"description": "A macOS menu-bar router for accounts, models, and automatic fallback.",
55
"author": "gitcommit90",
66
"license": "MIT",

src/lib/providers/chatgpt.js

Lines changed: 58 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -139,9 +139,11 @@ function toResponsesInput(messages, model, reasoningScope) {
139139
}
140140
if (message.role === "tool") {
141141
input.push({
142-
type: "function_call_output",
142+
type: message.extra_content?.openai?.custom_tool_call_output ? "custom_tool_call_output" : "function_call_output",
143143
call_id: message.tool_call_id,
144-
output: textFromOpenAiContent(message.content),
144+
output: message.extra_content?.openai?.custom_tool_call_output && Array.isArray(message.content)
145+
? JSON.parse(JSON.stringify(message.content))
146+
: textFromOpenAiContent(message.content),
145147
});
146148
continue;
147149
}
@@ -167,7 +169,8 @@ function toResponsesInput(messages, model, reasoningScope) {
167169
}
168170
const emittedReasoning = new Set();
169171
for (const call of calls) {
170-
if (!call?.function?.name) continue;
172+
const custom = call?.type === "custom" && call.custom?.name;
173+
if (!custom && !call?.function?.name) continue;
171174
const echoedReasoning = reasoningItemsFromToolCall(call);
172175
const reasoningItems = echoedReasoning.length
173176
? echoedReasoning
@@ -178,7 +181,12 @@ function toResponsesInput(messages, model, reasoningScope) {
178181
emittedReasoning.add(identity);
179182
input.push(reasoning);
180183
}
181-
input.push({
184+
input.push(custom ? {
185+
type: "custom_tool_call",
186+
call_id: call.id,
187+
name: call.custom.name,
188+
input: typeof call.custom.input === "string" ? call.custom.input : String(call.custom.input ?? ""),
189+
} : {
182190
type: "function_call",
183191
call_id: call.id,
184192
name: call.function.name,
@@ -264,11 +272,14 @@ function fromResponsesJson(data, model, { reasoningScope } = {}) {
264272
for (const c of item.content) {
265273
if (c.type === "output_text" || c.type === "text") text += c.text || "";
266274
}
267-
} else if (item.type === "function_call" && item.name) {
275+
} else if ((item.type === "function_call" || item.type === "custom_tool_call") && item.name) {
276+
const custom = item.type === "custom_tool_call";
268277
const call = {
269278
id: item.call_id || item.id,
270-
type: "function",
271-
function: { name: item.name, arguments: item.arguments || "" },
279+
type: custom ? "custom" : "function",
280+
...(custom
281+
? { custom: { name: item.name, input: item.input || "" } }
282+
: { function: { name: item.name, arguments: item.arguments || "" } }),
272283
};
273284
attachReasoningItems(call, reasoningByCall.get(call.id) || []);
274285
toolCalls.push(call);
@@ -434,33 +445,55 @@ async function pipeResponsesSse(
434445
}
435446
if (
436447
(type === "response.output_item.added" || type === "response.output_item.done") &&
437-
item?.type === "function_call" &&
448+
(item?.type === "function_call" || item?.type === "custom_tool_call") &&
438449
item.name
439450
) {
440451
const index = toolIndexFor(data, item);
441452
const current = toolCalls[index];
453+
const custom = item.type === "custom_tool_call";
442454
current.id ||= item.call_id || item.id;
443-
current.function.name ||= item.name;
444-
if (!current.function.arguments && item.arguments) {
445-
current.function.arguments = item.arguments;
455+
if (custom) {
456+
current.type = "custom";
457+
current.custom = { name: item.name, input: item.input || current.custom?.input || "" };
458+
delete current.function;
459+
} else {
460+
current.function.name ||= item.name;
461+
if (!current.function.arguments && item.arguments) current.function.arguments = item.arguments;
446462
}
447463
if (type === "response.output_item.added") {
448464
ensureRole();
449-
writeChunk(
450-
openaiChunk({
451-
id,
452-
model,
453-
tool_calls: [
454-
{
455-
index,
456-
id: current.id,
457-
type: "function",
458-
function: { name: current.function.name, arguments: "" },
459-
},
460-
],
461-
})
462-
);
465+
writeChunk(openaiChunk({
466+
id,
467+
model,
468+
tool_calls: [{
469+
index,
470+
id: current.id,
471+
type: custom ? "custom" : "function",
472+
...(custom
473+
? { custom: { name: current.custom.name, input: "" } }
474+
: { function: { name: current.function.name, arguments: "" } }),
475+
}],
476+
}));
463477
}
478+
} else if (type === "response.custom_tool_call_input.delta") {
479+
const index = toolIndexFor(data, item);
480+
const delta = typeof data.delta === "string" ? data.delta : "";
481+
const current = toolCalls[index];
482+
current.type = "custom";
483+
current.custom ||= { name: "", input: "" };
484+
current.custom.input += delta;
485+
delete current.function;
486+
if (delta) {
487+
ensureRole();
488+
writeChunk(openaiChunk({ id, model, tool_calls: [{ index, type: "custom", custom: { input: delta } }] }));
489+
}
490+
} else if (type === "response.custom_tool_call_input.done") {
491+
const index = toolIndexFor(data, item);
492+
const current = toolCalls[index];
493+
current.type = "custom";
494+
current.custom ||= { name: "", input: "" };
495+
if (!current.custom.input && typeof data.input === "string") current.custom.input = data.input;
496+
delete current.function;
464497
} else if (type === "response.function_call_arguments.delta") {
465498
const index = toolIndexFor(data, item);
466499
const delta = typeof data.delta === "string" ? data.delta : "";

src/lib/responses-api.js

Lines changed: 58 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,22 @@ function validateInputItem(item, param) {
117117
if (!(typeof item.output === "string" || Array.isArray(item.output))) throw invalid("function_call_output requires string or array output", `${param}.output`);
118118
return;
119119
}
120+
if (item.type === "custom_tool_call") {
121+
if (typeof item.call_id !== "string" || !item.call_id) throw invalid("custom_tool_call requires call_id", `${param}.call_id`);
122+
if (typeof item.name !== "string" || !item.name) throw invalid("custom_tool_call requires name", `${param}.name`);
123+
if (typeof item.input !== "string") throw invalid("custom_tool_call input must be a string", `${param}.input`);
124+
return;
125+
}
126+
if (item.type === "custom_tool_call_output") {
127+
if (typeof item.call_id !== "string" || !item.call_id) throw invalid("custom_tool_call_output requires call_id", `${param}.call_id`);
128+
if (!(typeof item.output === "string" || Array.isArray(item.output))) {
129+
throw invalid("custom_tool_call_output requires string or array output", `${param}.output`);
130+
}
131+
if (Array.isArray(item.output)) {
132+
item.output.forEach((part, index) => validateContentPart(part, `${param}.output[${index}]`));
133+
}
134+
return;
135+
}
120136
if (item.type !== undefined && item.type !== "message") throw invalid("Unsupported input item", `${param}.type`);
121137
if (!["user", "assistant", "system", "developer"].includes(item.role)) throw invalid("Invalid message role", `${param}.role`);
122138
if (item.content == null) {
@@ -142,15 +158,18 @@ function inputMessages(input) {
142158
if (reasoning) pendingReasoning.push(reasoning);
143159
continue;
144160
}
145-
if (item.type === "function_call") {
161+
if (item.type === "function_call" || item.type === "custom_tool_call") {
146162
if (!pendingAssistant) {
147163
pendingAssistant = { role: "assistant", content: null, tool_calls: [] };
148164
messages.push(pendingAssistant);
149165
}
166+
const custom = item.type === "custom_tool_call";
150167
const call = {
151168
id: item.call_id,
152-
type: "function",
153-
function: { name: item.name, arguments: item.arguments },
169+
type: custom ? "custom" : "function",
170+
...(custom
171+
? { custom: { name: item.name, input: item.input } }
172+
: { function: { name: item.name, arguments: item.arguments } }),
154173
};
155174
if (pendingReasoning.length) {
156175
call.extra_content = { openai: { reasoning_items: pendingReasoning.map((reasoning) => ({ ...reasoning })) } };
@@ -160,8 +179,15 @@ function inputMessages(input) {
160179
}
161180
pendingAssistant = null;
162181
pendingReasoning = [];
163-
if (item.type === "function_call_output") {
164-
messages.push({ role: "tool", tool_call_id: item.call_id, content: textContent(item.output) });
182+
if (item.type === "function_call_output" || item.type === "custom_tool_call_output") {
183+
messages.push({
184+
role: "tool",
185+
tool_call_id: item.call_id,
186+
content: item.type === "custom_tool_call_output" && Array.isArray(item.output)
187+
? JSON.parse(JSON.stringify(item.output))
188+
: textContent(item.output),
189+
...(item.type === "custom_tool_call_output" ? { extra_content: { openai: { custom_tool_call_output: true } } } : {}),
190+
});
165191
continue;
166192
}
167193
messages.push({
@@ -275,7 +301,10 @@ function outputFromMessage(message, responseId, request) {
275301
}
276302
}
277303
const callId = call.id || `call_${crypto.randomUUID().replaceAll("-", "")}`;
278-
output.push({ id: `fc_${responseId.slice(5)}_${index}`, type: "function_call", status: "completed", call_id: callId, name: call.function?.name, arguments: call.function?.arguments || "" });
304+
const custom = call.type === "custom" && call.custom;
305+
output.push(custom
306+
? { id: `ctc_${responseId.slice(5)}_${index}`, type: "custom_tool_call", status: "completed", call_id: callId, name: call.custom.name, input: call.custom.input || "" }
307+
: { id: `fc_${responseId.slice(5)}_${index}`, type: "function_call", status: "completed", call_id: callId, name: call.function?.name, arguments: call.function?.arguments || "" });
279308
}
280309
return output;
281310
}
@@ -351,17 +380,26 @@ async function pipeChatCompletionsSseToResponses(streamPipe, sink, model, reques
351380
if (!call) {
352381
const outputIndex = slot();
353382
const callId = callDelta.id || `call_${crypto.randomUUID().replaceAll("-", "")}`;
354-
call = { id: `fc_${responseId.slice(5)}_${calls.size}`, call_id: callId, type: "function_call", status: "in_progress", name: callDelta.function?.name || "", arguments: "", argumentDeltas: [], outputIndex, emitted: false };
383+
const custom = callDelta.type === "custom" || !!callDelta.custom;
384+
call = { id: `${custom ? "ctc" : "fc"}_${responseId.slice(5)}_${calls.size}`, call_id: callId, type: custom ? "custom_tool_call" : "function_call", status: "in_progress", name: custom ? callDelta.custom?.name || "" : callDelta.function?.name || "", input: "", arguments: "", argumentDeltas: [], outputIndex, emitted: false };
355385
calls.set(key, call);
356386
entries.push(call);
357387
if (!deferForReasoning) {
358-
const pending = { id: call.id, type: "function_call", status: "in_progress", call_id: call.call_id, name: call.name, arguments: "" };
388+
const pending = call.type === "custom_tool_call"
389+
? { id: call.id, type: call.type, status: "in_progress", call_id: call.call_id, name: call.name, input: "" }
390+
: { id: call.id, type: call.type, status: "in_progress", call_id: call.call_id, name: call.name, arguments: "" };
359391
writeEvent(sink, "response.output_item.added", { output_index: outputIndex, item: pending }, sequence++);
360392
call.emitted = true;
361393
}
362394
}
363395
if (callDelta.id) call.call_id = callDelta.id;
364396
if (callDelta.function?.name) call.name = callDelta.function.name;
397+
if (callDelta.custom?.name) call.name = callDelta.custom.name;
398+
if (callDelta.custom?.input) {
399+
call.input += callDelta.custom.input;
400+
call.argumentDeltas.push(callDelta.custom.input);
401+
if (call.emitted) writeEvent(sink, "response.custom_tool_call_input.delta", { item_id: call.id, output_index: call.outputIndex, delta: callDelta.custom.input }, sequence++);
402+
}
365403
if (callDelta.function?.arguments) {
366404
call.arguments += callDelta.function.arguments;
367405
call.argumentDeltas.push(callDelta.function.arguments);
@@ -406,13 +444,21 @@ async function pipeChatCompletionsSseToResponses(streamPipe, sink, model, reques
406444
writeEvent(sink, "response.output_item.done", { output_index: outputIndex, item }, sequence++);
407445
} else {
408446
if (!entry.emitted) {
409-
const pending = { id: entry.id, type: "function_call", status: "in_progress", call_id: entry.call_id, name: entry.name, arguments: "" };
447+
const custom = entry.type === "custom_tool_call";
448+
const pending = custom
449+
? { id: entry.id, type: entry.type, status: "in_progress", call_id: entry.call_id, name: entry.name, input: "" }
450+
: { id: entry.id, type: entry.type, status: "in_progress", call_id: entry.call_id, name: entry.name, arguments: "" };
410451
writeEvent(sink, "response.output_item.added", { output_index: outputIndex, item: pending }, sequence++);
411-
for (const delta of entry.argumentDeltas) writeEvent(sink, "response.function_call_arguments.delta", { item_id: entry.id, output_index: outputIndex, delta }, sequence++);
452+
for (const delta of entry.argumentDeltas) writeEvent(sink, custom ? "response.custom_tool_call_input.delta" : "response.function_call_arguments.delta", { item_id: entry.id, output_index: outputIndex, delta }, sequence++);
412453
}
413-
const item = { id: entry.id, type: "function_call", status: "completed", call_id: entry.call_id, name: entry.name, arguments: entry.arguments };
454+
const custom = entry.type === "custom_tool_call";
455+
const item = custom
456+
? { id: entry.id, type: entry.type, status: "completed", call_id: entry.call_id, name: entry.name, input: entry.input }
457+
: { id: entry.id, type: entry.type, status: "completed", call_id: entry.call_id, name: entry.name, arguments: entry.arguments };
414458
output.push(item);
415-
writeEvent(sink, "response.function_call_arguments.done", { item_id: item.id, output_index: outputIndex, arguments: item.arguments }, sequence++);
459+
writeEvent(sink, custom ? "response.custom_tool_call_input.done" : "response.function_call_arguments.done", custom
460+
? { item_id: item.id, output_index: outputIndex, input: item.input }
461+
: { item_id: item.id, output_index: outputIndex, arguments: item.arguments }, sequence++);
416462
writeEvent(sink, "response.output_item.done", { output_index: outputIndex, item }, sequence++);
417463
}
418464
}

tests/chatgpt-tools.test.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,40 @@ describe("ChatGPT Responses tool translation", () => {
7070
assert.equal(typeof result.reasoningScope, "string");
7171
});
7272

73+
it("preserves captured additional_tools custom grammar and replays custom output", () => {
74+
const additionalTools = [
75+
{ type: "custom", name: "exec", description: "Runs a shell command and returns its output.", format: { type: "grammar", syntax: "lark", definition: "start: command\ncommand: /(.|\\n)+/" } },
76+
{ type: "namespace", name: "container", description: "Container tools", tools: [{ type: "custom", name: "exec", format: { type: "grammar", syntax: "regex", definition: "(?s).+" } }] },
77+
];
78+
const first = chatgpt.toResponsesBody({ messages: [{ role: "user", content: "List files" }], tools: additionalTools }, "gpt-5.6-sol", false);
79+
assert.deepEqual(first.tools, additionalTools);
80+
const output = [
81+
{ type: "input_text", text: "Script completed..." },
82+
{ type: "input_text", text: "{...TOOL_EXEC_OK...}" },
83+
];
84+
const second = chatgpt.toResponsesBody({ messages: [
85+
{ role: "assistant", content: null, tool_calls: [{ id: "call_capture", type: "custom", custom: { name: "exec", input: "ls" } }] },
86+
{ role: "tool", tool_call_id: "call_capture", content: output, extra_content: { openai: { custom_tool_call_output: true } } },
87+
], tools: additionalTools }, "gpt-5.6-sol", false);
88+
assert.deepEqual(second.input, [
89+
{ type: "custom_tool_call", call_id: "call_capture", name: "exec", input: "ls" },
90+
{ type: "custom_tool_call_output", call_id: "call_capture", output },
91+
]);
92+
assert.deepEqual(second.tools, additionalTools);
93+
});
94+
95+
it("collects custom tool events without converting them to function calls", async () => {
96+
const events = [
97+
{ type: "response.output_item.added", output_index: 1, item: { id: "ctc_1", type: "custom_tool_call", call_id: "call_exec", name: "exec", input: "" } },
98+
{ type: "response.custom_tool_call_input.delta", item_id: "ctc_1", output_index: 1, delta: "pw" },
99+
{ type: "response.custom_tool_call_input.done", item_id: "ctc_1", output_index: 1, input: "pwd" },
100+
{ type: "response.output_item.done", output_index: 1, item: { id: "ctc_1", type: "custom_tool_call", call_id: "call_exec", name: "exec", input: "pwd" } },
101+
{ type: "response.completed" },
102+
].map((event) => `data: ${JSON.stringify(event)}\n\n`);
103+
const result = await chatgpt.pipeResponsesSse(Readable.from(events), null, "gpt-5.6-sol", { collect: true });
104+
assert.deepEqual(result.choices[0].message.tool_calls, [{ id: "call_exec", type: "custom", custom: { name: "exec", input: "pwd" } }]);
105+
});
106+
73107
it("preserves assistant tool calls and tool results in multi-turn input", () => {
74108
const payload = chatgpt.toResponsesBody(
75109
{

0 commit comments

Comments
 (0)