Skip to content

fix(api): restore function_call name on non-streaming /v1/responses - #13824

Merged
diegosouzapw merged 1 commit into
release/v3.8.51from
fix/12370-responses-fncall-name
Sep 16, 2026
Merged

diegosouzapw merged 1 commit into
release/v3.8.51from
fix/12370-responses-fncall-name

Conversation

@diegosouzapw

Copy link
Copy Markdown
Owner

Closes #12370

Root cause

open-sse/handlers/chatCore/nonStreamingClientTranslate.ts:132-138 — the #7936 identity-restore loop for non-streaming /v1/responses function_call output items:

const identity = requestToolIdentityMap.get(item.name);
if (identity) {
  item.namespace = identity.namespace;
  item.name = identity.name;
}

requestToolIdentityMap is typed Map<string, {namespace, name}>, but extractRequestToolIdentityMap() (open-sse/handlers/chatCore/requestToolIdentity.ts) falls back to the _toolNameMap side channel whenever no namespace tools were declared on the request. For a Gemini/Claude pivot that side channel is a plain Map<string, string> alias table ("shell" -> "shell"), not a {namespace, name} identity — a fact the code comment there already documents (#9780), but the consumer in nonStreamingClientTranslate.ts never checked the shape.

For Codex's plain (non-namespace) shell function tool, identity comes back as the string "shell". ("shell").namespace and ("shell").name are both undefined, so the loop overwrote a perfectly valid item.name ("shell") with undefined — a key JSON.stringify drops entirely. Codex then receives a function_call with no name, can't dispatch it, and re-narrates its intent instead — the reported loop.

Fix: only apply the restore when identity actually has the {namespace, name} shape (typeof identity === "object" && typeof identity.name === "string").

Live VPS evidence (192.168.0.15, real gemini API key, Codex-shaped payload: tool_choice: "auto", one shell function tool, gemini/gemini-3-flash-preview)

POST /v1/chat/completions  (stream:false)  → CORRECT
  tool_calls[0] = {"id":"call_257465","type":"function",
                   "function":{"name":"shell","arguments":"{\"command\":[\"ls memory-bank/\"]}"}}

POST /v1/responses  (stream:false)  → BUG — no `name` (reproduced 3/3)
  output[0] = {"id":"fc_call_8223","type":"function_call","call_id":"call_8223",
               "arguments":"{\"command\":[\"ls\",\"memory-bank/\"]}"}

POST /v1/responses  (stream:true)   → CORRECT
  response.output_item.added item = {"type":"function_call","arguments":"",
                                     "call_id":"call_165396","name":"shell","status":"in_progress"}

TDD — RED → GREEN

New test tests/unit/non-streaming-client-translate.test.ts → "#12370: alias-shaped requestToolIdentityMap must not blank out function_call name", feeding translateNonStreamingClientResponse() a Gemini functionCall response plus an alias-shaped requestToolIdentityMap (Map(["shell","shell"])), matching the live-VPS shape.

RED (guard removed):

✖ #12370: alias-shaped requestToolIdentityMap must not blank out function_call name (19.021283ms)
  AssertionError [ERR_ASSERTION]: name must survive the alias-map fallback
  + actual - expected
  + undefined
  - 'shell'

GREEN (fix applied) — full file, 11/11 pass:

✔ #12370: alias-shaped requestToolIdentityMap must not blank out function_call name (12.826964ms)
...
ℹ tests 11
ℹ pass 11
ℹ fail 0

Also confirmed the pre-existing #7936 regression test (genuine {namespace, name} restore, e.g. mcp__server__tool) is unaffected — still green, unmodified.

Gates run

  • node scripts/check/check-file-size.mjs — no new violation on touched files
  • node scripts/check/check-complexity.mjs — OK (2826 violations vs. baseline 3218)
  • node scripts/check/check-cognitive-complexity.mjs — OK (1276 violations vs. baseline 1437)
  • npm run typecheck:core — exit 0
  • npm run check:open-sse-typecheck — OK, 0 pre-existing errors
  • npx eslint --suppressions-location config/quality/eslint-suppressions.json <changed files> — exit 0
  • node --import tsx/esm --test tests/unit/non-streaming-client-translate.test.ts — 11/11 pass
  • Related identity-map regression suites also re-run green and unmodified: tests/unit/chatcore-response-toolname-map-recovery.test.ts, tests/unit/9780-namespace-identity-pivot.test.ts, tests/unit/translator-resp-openai-responses-namespace-identity.test.ts
  • node scripts/check/check-changelog-integrity.mjs — OK
  • Pre-commit hooks (lint-staged, docs-sync, any-budget:t11, tracked-artifacts) — all passed at commit time
  • Base-green check: no open base-red issue for release/v3.8.51; branch fast-forwarded onto the current tip (cde49c9) before opening this PR

Note for a maintainer (not fixed here, out of scope for this failing test)

open-sse/utils/stream.ts::restoreResponsesPassthroughFunctionCallIdentity (the native-Codex-passthrough streaming counterpart) has the identical unguarded identity.namespace/identity.name shape assumption. It isn't currently reachable with an alias-shaped map (the alias _toolNameMap is only published by the non-passthrough Gemini/Claude pivot, which doesn't feed the codex-native passthrough branch), so no failing test proves it broken today — flagging as a latent variant worth a follow-up look rather than fixing speculatively.

…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.
@diegosouzapw
diegosouzapw merged commit 5574eab into release/v3.8.51 Sep 16, 2026
19 of 21 checks passed
muhamadgalihsaputra pushed a commit to niyatna/NiyatnaRoute that referenced this pull request Sep 27, 2026
…iegosouzapw#12370) (diegosouzapw#13824)

Merged in the 2026-09-16 sweep of the maintainer's own open PRs, at the owner's explicit instruction. No push was made to the PR branch: the merge took the head as the owning session left it (verified OPEN, non-draft and MERGEABLE against the release tip immediately before merging).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(providers): Codex Responses + Gemini 3 Flash loops on narrated tool intent instead of emitting function_call

1 participant