fix(translator): support Responses custom tool choice (#13128)

* fix(translator): support Responses custom tool choice

* fix(translator): preserve custom tools across response paths

* docs(changelog): add fragment for Responses custom tool choice fix

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: Pham Tien Duc <phamtienduceng@gmail.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: ducphamtien-fonos <ducphamtien-fonos@users.noreply.github.com>
This commit is contained in:
ducphamtien-fonos
2026-09-18 21:59:24 +07:00
committed by GitHub
parent 5a82da7084
commit 128f06d645
12 changed files with 299 additions and 19 deletions

View File

@@ -0,0 +1 @@
- **fix(translator):** recognize `tool_choice.type: "custom"` in Responses→Chat translation and propagate custom tool names (including namespace-flattened ones) across both the streaming and non-streaming provider legs, so non-streaming Responses clients get `custom_tool_call`/raw `input` instead of `function_call`/JSON arguments ([#13128](https://github.com/diegosouzapw/OmniRoute/pull/13128)) — thanks @ducphamtien-fonos

View File

@@ -5048,6 +5048,7 @@ export async function handleChatCore({
effectiveModel: currentModel,
translatedBody: translatedBody as Record<string, unknown>,
toolNameMap,
customToolNames,
requestToolIdentityMap,
reasoningCacheScope,
reasoningReplayHistory,
@@ -5220,6 +5221,7 @@ export async function handleChatCore({
effectiveModel: currentModel,
translatedBody: translatedBody as Record<string, unknown>,
toolNameMap,
customToolNames,
requestToolIdentityMap,
reasoningCacheScope,
reasoningReplayHistory,

View File

@@ -55,6 +55,7 @@ export function translateNonStreamingClientResponse(
model,
requestBody,
responseToolNameMap,
customToolNames,
requestToolIdentityMap,
reasoningCacheScope,
clientHeaders,
@@ -123,19 +124,46 @@ export function translateNonStreamingClientResponse(
} catch {
// Cache capture is non-critical — never block the response
}
// ── Sanitize response for SDK compatibility ────────────────────────────────
if (clientResponseFormat === FORMATS.OPENAI_RESPONSES) {
translatedResponse = sanitizeResponsesApiResponse(translatedResponse);
// Restore {namespace, name} on function_call items for round-trip closure
const sanitizedOutput = translatedResponse?.output;
if (customToolNames && Array.isArray(sanitizedOutput)) {
for (const item of sanitizedOutput) {
if (item?.type !== "function_call" || !customToolNames.has(item.name)) continue;
let rawInput = item.arguments;
if (typeof item.arguments === "string") {
try {
const parsed = JSON.parse(item.arguments);
if (parsed && typeof parsed.input === "string") rawInput = parsed.input;
} catch {
// Non-JSON arguments are already the best available raw input.
}
} else if (
item.arguments &&
typeof item.arguments === "object" &&
typeof item.arguments.input === "string"
) {
rawInput = item.arguments.input;
}
item.type = "custom_tool_call";
item.input = typeof rawInput === "string" ? rawInput : JSON.stringify(rawInput ?? "");
item.status ??= "completed";
delete item.arguments;
}
}
// Restore {namespace, name} on function_call / custom_tool_call items for round-trip
// closure — only after custom classification above, which uses wire names.
// (#7936). Falls back to splitting the flattened `mcp__`-namespaced wire
// name itself when the per-request identity map has no entry — e.g. a
// follow-up turn in the same session that didn't re-declare its
// `type:"namespace"` tools (#12996).
const responseOutput = translatedResponse?.output;
if (Array.isArray(responseOutput)) {
for (const item of responseOutput) {
if (item?.type !== "function_call") continue;
if (Array.isArray(sanitizedOutput)) {
for (const item of sanitizedOutput) {
if (item?.type !== "function_call" && item?.type !== "custom_tool_call") continue;
// `requestToolIdentityMap` is typed as Map<string, NamespaceIdentity>, but
// extractRequestToolIdentityMap() (chatCore/requestToolIdentity.ts) falls
// back to `_toolNameMap` when no namespace tools were present — and that

View File

@@ -88,6 +88,7 @@ export interface ProviderLegInput {
effectiveModel?: string;
translatedBody?: Record<string, unknown>;
toolNameMap?: Map<string, string> | null;
customToolNames?: ReadonlySet<string>;
requestToolIdentityMap?: Map<string, { namespace?: string; name: string }> | null;
reasoningCacheScope?: string | null;
/** Normalized OpenAI transcript reported by translateRequest for Responses-API
@@ -290,6 +291,7 @@ function finishOk(
input.reasoningReplayHistory ??
null,
responseToolNameMap,
customToolNames: input.customToolNames,
requestToolIdentityMap: input.requestToolIdentityMap ?? null,
reasoningCacheScope: input.reasoningCacheScope ?? null,
clientHeaders: input.clientHeaders ?? null,

View File

@@ -762,7 +762,10 @@ export function openaiResponsesToOpenAIRequest(
) {
const tc = toRecord(result.tool_choice);
const tcType = toString(tc.type);
if (tcType === "function" && tc.name !== undefined && !tc.function) {
// Custom/freeform tools are normalized to Chat function tools with an { input: string }
// schema above. Force the normalized function here while response-side custom-tool metadata
// restores custom_tool_call and raw input for the Responses client.
if ((tcType === "function" || tcType === "custom") && tc.name !== undefined && !tc.function) {
result.tool_choice = { type: "function", function: { name: tc.name } };
} else if (tcType === "local_shell") {
result.tool_choice = { type: "function", function: { name: "shell" } };

View File

@@ -1,3 +1,5 @@
import { flattenNamespaceToolName } from "./namespaceFlatten.ts";
type JsonRecord = Record<string, unknown>;
function toRecord(value: unknown): JsonRecord {
@@ -117,12 +119,16 @@ export function collectResponsesCustomToolNames(
inputItems: unknown[]
): Set<string> {
const names = new Set<string>();
const visit = (tools: unknown[]) => {
const visit = (tools: unknown[], namespaceName = "") => {
for (const toolValue of tools) {
const tool = toRecord(toolValue);
const name = toolName(toolValue);
if (tool.type === "custom" && name) names.add(name);
if (tool.type === "namespace" && Array.isArray(tool.tools)) visit(tool.tools);
if (tool.type === "custom" && name) {
names.add(flattenNamespaceToolName(namespaceName, name));
}
if (tool.type === "namespace" && Array.isArray(tool.tools)) {
visit(tool.tools, name);
}
}
};
visit(collectResponsesTools(rootTools, inputItems));

View File

@@ -191,6 +191,7 @@ export interface NonStreamingClientTranslateInput {
*/
historyMessages?: unknown[] | null;
responseToolNameMap: Map<string, string> | null;
customToolNames?: ReadonlySet<string>;
requestToolIdentityMap: Map<string, { namespace?: string; name: string }> | null;
reasoningCacheScope: string | null;
clientHeaders: Headers | Record<string, unknown> | null;

View File

@@ -270,6 +270,94 @@ test("Responses API format: sanitizeResponsesApiResponse is applied", () => {
assert.equal(output[0]?.name, "get_weather", "#7936 restore original name");
});
test("Responses API format: restores non-stream custom tool calls before namespace identity", () => {
const input = baseInput({
responsePayloadFormat: FORMATS.OPENAI_RESPONSES,
clientResponseFormat: FORMATS.OPENAI_RESPONSES,
sourceFormat: FORMATS.OPENAI_RESPONSES,
responseBody: {
id: "resp_custom",
object: "response",
status: "completed",
output: [
{
id: "fc_call_1",
type: "function_call",
call_id: "call_1",
name: "functions__exec",
arguments: '{"input":"printf \'nonstream-ok\\\\n\'"}',
},
],
usage: { input_tokens: 10, output_tokens: 5, total_tokens: 15 },
},
customToolNames: new Set(["functions__exec"]),
requestToolIdentityMap: new Map([
["functions__exec", { namespace: "functions", name: "exec" }],
]),
});
const result = translateNonStreamingClientResponse(input);
const output = result.response.output as Array<Record<string, unknown>>;
assert.deepEqual(output[0], {
id: "fc_call_1",
type: "custom_tool_call",
call_id: "call_1",
name: "exec",
input: "printf 'nonstream-ok\\n'",
status: "completed",
namespace: "functions",
});
});
test("Responses API format: classifies custom calls synthesized from Kiro chat output", () => {
const input = baseInput({
responsePayloadFormat: "kiro",
clientResponseFormat: FORMATS.OPENAI_RESPONSES,
sourceFormat: FORMATS.OPENAI_RESPONSES,
responseBody: {
id: "chatcmpl_custom",
object: "chat.completion",
choices: [
{
index: 0,
message: {
role: "assistant",
content: null,
tool_calls: [
{
id: "call_kiro_1",
type: "function",
function: {
name: "functions__exec",
arguments: '{"input":"printf \'kiro-nonstream-ok\\\\n\'"}',
},
},
],
},
finish_reason: "tool_calls",
},
],
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
},
customToolNames: new Set(["functions__exec"]),
requestToolIdentityMap: new Map([
["functions__exec", { namespace: "functions", name: "exec" }],
]),
});
const result = translateNonStreamingClientResponse(input);
const output = result.response.output as Array<Record<string, unknown>>;
assert.deepEqual(output[0], {
id: "fc_call_kiro_1",
type: "custom_tool_call",
call_id: "call_kiro_1",
name: "exec",
input: "printf 'kiro-nonstream-ok\\n'",
status: "completed",
namespace: "functions",
});
});
test("#12370: alias-shaped requestToolIdentityMap must not blank out function_call name", () => {
// extractRequestToolIdentityMap() falls back to the `_toolNameMap` side channel
// when no namespace tools are present. For Gemini/Claude pivots that side

View File

@@ -84,6 +84,54 @@ test("200 JSON: returns ok with usage and receipt", async () => {
assert.equal(result.receipt.termination, "completed");
});
test("Responses custom tool metadata survives request-body translation in provider leg", async () => {
const upstreamBody = {
id: "resp_custom",
object: "response",
status: "completed",
output: [
{
id: "fc_call_1",
type: "function_call",
call_id: "call_1",
name: "functions__exec",
arguments: '{"input":"printf \'nonstream-ok\\\\n\'"}',
},
],
usage: { input_tokens: 10, output_tokens: 5, total_tokens: 15 },
};
const result = await runNonStreamingProviderLeg(
baseInput({
// Request conversion has already downgraded the source declaration by this seam.
sourceBody: {
model: "gpt-5.6-sol",
tools: [{ type: "function", function: { name: "functions__exec" } }],
},
sourceFormat: "openai-responses",
targetFormat: "openai-responses",
clientResponseFormat: "openai-responses",
translatedBody: { model: "gpt-5.6-sol" },
customToolNames: new Set(["functions__exec"]),
requestToolIdentityMap: new Map([
["functions__exec", { namespace: "functions", name: "exec" }],
]),
executeProviderRequest: async () => makeExecutorResult(upstreamBody),
})
);
assert.equal(result.kind, "ok");
if (result.kind !== "ok") return;
assert.deepEqual(result.response.output[0], {
id: "fc_call_1",
type: "custom_tool_call",
call_id: "call_1",
name: "exec",
input: "printf 'nonstream-ok\\n'",
status: "completed",
namespace: "functions",
});
});
test("runProviderExecution is called once with policy; first send skips executeProviderRequest", async () => {
let pipelineCalls = 0;
let executorCalls = 0;
@@ -860,11 +908,7 @@ test("dynamic connection: ID changes between initial and retry -> 409 on retry p
assert.equal(result.result.status, 409);
assert.equal(result.result.errorCode, "LEASE_CONNECTION_MISMATCH");
}
assert.equal(
executorCallCount,
1,
"retry executor must not run after the lease already moved"
);
assert.equal(executorCallCount, 1, "retry executor must not run after the lease already moved");
});
/* -- fallback with real parsed response ----------------------------------- */
@@ -1070,7 +1114,11 @@ test("empty-content fallback with invalid SSE body is 502, not 200 empty", async
});
const result = await runNonStreamingProviderLeg(input);
assert.ok(executorCallCount >= 2, "should attempt fallback");
assert.equal(result.kind, "error", "invalid SSE on fallback must not finishOk the empty original");
assert.equal(
result.kind,
"error",
"invalid SSE on fallback must not finishOk the empty original"
);
if (result.kind !== "error") return;
assert.equal(result.result.status, 502);
assert.equal(result.result.errorCode, "invalid_sse_payload");

View File

@@ -246,7 +246,10 @@ test("Responses custom metadata includes additional and namespaced custom tools"
],
},
];
assert.deepEqual([...collectResponsesCustomToolNames([], input)].sort(), ["apply_diff", "exec"]);
assert.deepEqual([...collectResponsesCustomToolNames([], input)].sort(), [
"exec",
"server__apply_diff",
]);
});
test("Responses source format enables custom metadata independently of model apiFormat", () => {

View File

@@ -429,6 +429,68 @@ test("handleResponsesCore rejects invalid Responses API input that cannot be tra
);
});
test("handleResponsesCore restores a top-level custom tool with automatic selection", async () => {
const { result, call } = await invokeResponsesCore({
body: {
model: "gpt-5.6-sol",
input: 'You must call functions__exec with exactly: text("ok")',
tools: [
{
type: "custom",
name: "functions__exec",
description: "Execute freeform code",
},
],
stream: false,
},
responseFactory: () =>
buildToolCallSseResponse("functions__exec", '{"input":"text(\\"ok\\")"}'),
});
assert.equal(call.body.tools[0].type, "function");
assert.equal(call.body.tools[0].function.name, "functions__exec");
const sse = await result.response.text();
assert.match(sse, /"type":"custom_tool_call"/);
assert.match(sse, /"call_id":"call_1"/);
assert.match(sse, /"input":"text\(\\"ok\\"\)"/);
assert.match(sse, /event: response\.completed/);
assert.doesNotMatch(sse, /"type":"function_call","arguments"/);
});
test("handleResponsesCore maps forced custom tool_choice and preserves its lifecycle", async () => {
const { result, call } = await invokeResponsesCore({
body: {
model: "gpt-5.6-sol",
input: 'Call functions__exec with exactly: text("ok")',
tools: [
{
type: "custom",
name: "functions__exec",
description: "Execute freeform code",
},
],
tool_choice: {
type: "custom",
name: "functions__exec",
},
stream: false,
},
responseFactory: () =>
buildToolCallSseResponse("functions__exec", '{"input":"text(\\"ok\\")"}'),
});
assert.deepEqual(call.body.tool_choice, {
type: "function",
function: { name: "functions__exec" },
});
const sse = await result.response.text();
assert.match(sse, /"type":"custom_tool_call"/);
assert.match(sse, /"call_id":"call_1"/);
assert.match(sse, /"input":"text\(\\"ok\\"\)"/);
assert.match(sse, /event: response\.completed/);
assert.doesNotMatch(sse, /"type":"function_call","arguments"/);
});
test("handleResponsesCore restores custom tools declared through additional_tools", async () => {
const { result, call } = await invokeResponsesCore({
body: {
@@ -480,7 +542,7 @@ test("handleResponsesCore preserves top-level tool precedence for custom-name co
});
test("handleResponsesCore restores custom tools nested in namespaces", async () => {
const { result } = await invokeResponsesCore({
const { result, call } = await invokeResponsesCore({
body: {
model: "gpt-4o-mini",
input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "ping" }] }],
@@ -492,11 +554,13 @@ test("handleResponsesCore restores custom tools nested in namespaces", async ()
},
],
},
responseFactory: () => buildToolCallSseResponse("exec", '{"input":"pong"}'),
responseFactory: () => buildToolCallSseResponse("commands__exec", '{"input":"pong"}'),
});
assert.equal(call.body.tools[0].function?.name, "commands__exec");
const sse = await result.response.text();
assert.match(sse, /"type":"custom_tool_call"/);
assert.match(sse, /"input":"pong"/);
assert.doesNotMatch(sse, /"type":"function_call","arguments"/);
});

View File

@@ -54,6 +54,40 @@ test("Responses -> Chat: custom tool is normalized to a { input: string } functi
});
});
test("Responses -> Chat: forced custom tool_choice maps to the normalized function tool", () => {
const result = openaiResponsesToOpenAIRequest(
"gpt-5.6-sol",
{
input: 'Call functions__exec with exactly: text("ok")',
tools: [
{
type: "custom",
name: "functions__exec",
description: "Execute freeform code",
},
],
tool_choice: {
type: "custom",
name: "functions__exec",
},
},
false,
{}
);
assert.deepEqual(result.tool_choice, {
type: "function",
function: { name: "functions__exec" },
});
assert.equal(result.tools[0].function.name, "functions__exec");
assert.deepEqual(result.tools[0].function.parameters, {
type: "object",
properties: { input: { type: "string" } },
required: ["input"],
additionalProperties: false,
});
});
// Request side: custom_tool_call / custom_tool_call_output input items round-trip.
test("Responses -> Chat: custom_tool_call + output items map to tool_calls and tool role (#1007)", () => {
const result = openaiResponsesToOpenAIRequest(