diff --git a/changelog.d/fixes/12370-responses-function-call-name.md b/changelog.d/fixes/12370-responses-function-call-name.md new file mode 100644 index 0000000000..231a2a78af --- /dev/null +++ b/changelog.d/fixes/12370-responses-function-call-name.md @@ -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) diff --git a/open-sse/handlers/chatCore/nonStreamingClientTranslate.ts b/open-sse/handlers/chatCore/nonStreamingClientTranslate.ts index 79821278f0..d328934140 100644 --- a/open-sse/handlers/chatCore/nonStreamingClientTranslate.ts +++ b/open-sse/handlers/chatCore/nonStreamingClientTranslate.ts @@ -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, but + // extractRequestToolIdentityMap() (chatCore/requestToolIdentity.ts) falls + // back to `_toolNameMap` when no namespace tools were present — and that + // side channel is a plain Map 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; } diff --git a/tests/unit/non-streaming-client-translate.test.ts b/tests/unit/non-streaming-client-translate.test.ts index fb382929fa..d96b931773 100644 --- a/tests/unit/non-streaming-client-translate.test.ts +++ b/tests/unit/non-streaming-client-translate.test.ts @@ -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 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>; + 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: {},