mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-21 14:22:14 +03:00
fix(api): restore function_call name on non-streaming /v1/responses (#12370)
A plain (non-namespace) tool call's #7936 identity restore in nonStreamingClientTranslate.ts unconditionally applied whatever requestToolIdentityMap.get(item.name) returned as a {namespace, name} object. That map falls back to the _toolNameMap alias table (a plain Map<string, string>, e.g. "shell" -> "shell") published by the openai->gemini/claude pivot whenever no namespace tools are present. Applying that string as an identity object read undefined for both .namespace and .name, blanking item.name to undefined -- a key JSON.stringify then drops entirely, leaving Codex with a function_call it cannot dispatch and causing it to re-narrate its tool intent in a loop instead of executing it. Guard the restore so it only fires when the map entry actually has the {namespace, name} shape. Live VPS check (192.168.0.15, real gemini API key, Codex-shaped payload: tool_choice "auto", one shell function tool, gemini/gemini-3-flash-preview) reproduced the bug 3/3 on stream:false /v1/responses; stream:false /v1/chat/completions and stream:true /v1/responses were both already correct.
This commit is contained in:
1
changelog.d/fixes/12370-responses-function-call-name.md
Normal file
1
changelog.d/fixes/12370-responses-function-call-name.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(api): restore the `name` field on non-streaming `/v1/responses` `function_call` output items — a plain (non-namespace) tool call's identity restore was blindly applying the `_toolNameMap` alias-table fallback as a `{namespace, name}` object, silently blanking `name` to `undefined` (dropped entirely by JSON.stringify) and leaving Codex unable to dispatch the call, so it re-narrated its intent in a loop instead (#12370)
|
||||
@@ -130,7 +130,17 @@ export function translateNonStreamingClientResponse(
|
||||
for (const item of responseOutput) {
|
||||
if (item?.type !== "function_call") continue;
|
||||
const identity = requestToolIdentityMap.get(item.name);
|
||||
if (identity) {
|
||||
// `requestToolIdentityMap` is typed as Map<string, NamespaceIdentity>, but
|
||||
// extractRequestToolIdentityMap() (chatCore/requestToolIdentity.ts) falls
|
||||
// back to `_toolNameMap` when no namespace tools were present — and that
|
||||
// side channel is a plain Map<string, string> alias table published by the
|
||||
// openai->gemini/claude pivot (#9780), not {namespace, name} identities.
|
||||
// Applying that fallback here unconditionally overwrote a perfectly valid
|
||||
// `item.name` (e.g. "shell") with `("shell").name === undefined`, which
|
||||
// JSON.stringify then drops the key entirely (#12370) — Codex receives a
|
||||
// function_call with no name and cannot dispatch it. Only apply the
|
||||
// restore when `identity` actually has the {namespace, name} shape.
|
||||
if (identity && typeof identity === "object" && typeof identity.name === "string") {
|
||||
item.namespace = identity.namespace;
|
||||
item.name = identity.name;
|
||||
}
|
||||
|
||||
@@ -270,6 +270,49 @@ test("Responses API format: sanitizeResponsesApiResponse is applied", () => {
|
||||
assert.equal(output[0]?.name, "get_weather", "#7936 restore original name");
|
||||
});
|
||||
|
||||
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
|
||||
// channel is a plain Map<string, string> alias table (wire name -> original
|
||||
// name), NOT the {namespace, name} identity shape the #7936 restore loop
|
||||
// expects. A plain function tool like Codex's "shell" round-trips through
|
||||
// this alias map as an identity mapping ("shell" -> "shell"): reproduces the
|
||||
// exact live-VPS shape (tool_choice: auto, one `shell` function tool,
|
||||
// gemini-3-flash-preview) where the non-streaming /v1/responses item lost
|
||||
// its `name` key entirely.
|
||||
const input = baseInput({
|
||||
responsePayloadFormat: FORMATS.GEMINI,
|
||||
clientResponseFormat: FORMATS.OPENAI_RESPONSES,
|
||||
sourceFormat: FORMATS.OPENAI_RESPONSES,
|
||||
provider: "gemini",
|
||||
model: "gemini-3-flash-preview",
|
||||
responseBody: {
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
role: "model",
|
||||
parts: [{ functionCall: { name: "shell", args: { command: ["ls", "memory-bank/"] } } }],
|
||||
},
|
||||
finishReason: "STOP",
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
// Alias-shaped map (string -> string), as published by the openai->gemini
|
||||
// pivot — not a NamespaceIdentity map.
|
||||
requestToolIdentityMap: new Map([["shell", "shell"]]) as unknown as Map<
|
||||
string,
|
||||
{ namespace?: string; name: string }
|
||||
>,
|
||||
});
|
||||
const result = translateNonStreamingClientResponse(input);
|
||||
const output = result.response.output as Array<Record<string, unknown>>;
|
||||
const functionCall = output.find((item) => item.type === "function_call");
|
||||
assert.ok(functionCall, "expected a function_call output item");
|
||||
assert.equal(functionCall?.name, "shell", "name must survive the alias-map fallback");
|
||||
assert.equal("name" in (functionCall as object), true, "name key must be present, not stripped");
|
||||
});
|
||||
|
||||
test("empty content response: passthrough without crash", () => {
|
||||
const input = baseInput({
|
||||
responseBody: {},
|
||||
|
||||
Reference in New Issue
Block a user