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:
diegosouzapw
2026-09-15 23:27:41 -03:00
parent cde49c9372
commit 9a9f5fa02d
3 changed files with 55 additions and 1 deletions

View 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)

View File

@@ -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;
}

View File

@@ -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: {},