mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-19 21:32:20 +03:00
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:
committed by
GitHub
parent
5a82da7084
commit
128f06d645
1
changelog.d/fixes/13128-responses-custom-tool-choice.md
Normal file
1
changelog.d/fixes/13128-responses-custom-tool-choice.md
Normal 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
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" } };
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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"/);
|
||||
});
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user