fix(responses): normalize custom tool names in non-streaming conversion - #3373
Open
krasumashi wants to merge 1 commit into
Open
fix(responses): normalize custom tool names in non-streaming conversion#3373krasumashi wants to merge 1 commit into
krasumashi wants to merge 1 commit into
Conversation
The Responses request translator collects custom tool names in a Set but
exports them as an array:
if (customToolNames.size > 0) result._customToolNames = [...customToolNames];
-- translator/request/openai-responses.js
handlers/chatCore.js lifts that value off the translated body and forwards
it unchanged to the response converters, which asked it for Set semantics:
const custom = customToolNames?.has(fn.name);
-- handlers/chatCore/nonStreamingHandler.js (openAICompletionToResponses)
-- handlers/chatCore/sseToJsonHandler.js (chatCompletionToResponses)
Arrays have no `.has`, and optional chaining only guards null/undefined, so
any custom tool call threw TypeError *after* the provider had already
answered successfully. Next surfaced the unhandled rejection as a bodyless
HTTP 500, which reads like a routing or upstream failure rather than a
translation bug. Standard function tools were unaffected, because the
`size > 0` guard means the property is absent when no custom tool is
declared and the optional chain then short-circuits.
Streaming was never affected: utils/stream.js already normalizes with
`new Set(customToolNames || [])`. This applies the same normalization at
the two non-streaming boundaries, accepting Array | Set | null | undefined
and never mutating the caller's collection. The producer's array contract
is deliberately left alone, since tests/unit/openai-responses-custom-tools.js
pins it with `expect(out._customToolNames).toEqual(["exec"])`.
Why this was not caught: every existing test constructed `customToolNames`
as a hand-built Set, so the seam between the producer and these consumers
was never exercised. The added regression test feeds the real output of
openaiResponsesToOpenAIRequest into the converter, and covers null,
undefined, [], Set, matching and non-matching names, raw-input fidelity
(no `{"input":...}` wrapper leakage), multi-line payloads, and the
standard function_call path.
Verified end to end against a patched instance on 127.0.0.1:20129: a
Responses client declaring a custom tool now receives
`type: "custom_tool_call"` with the raw input intact, in both non-streaming
and streaming mode, and a `custom_tool_call_output` continuation resumes
normally.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A Responses-API client that declares a custom tool gets a bodyless HTTP 500 whenever the model actually calls it — after the provider has already answered successfully. Standard function tools are unaffected, which makes it look like a provider or routing fault rather than a translation bug.
Reproduced on
v0.5.55and the defect is still present onmaster.Root cause
The Responses request translator collects custom tool names in a
Setbut exports them as an array:handlers/chatCore.jslifts that value off the translated body and forwards it unchanged to the response converters, which ask it forSetsemantics:Arrays have no
.has, and optional chaining only guardsnull/undefined, so this throwsTypeError: customToolNames?.has is not a function. The rejection escapes the route handler and Next returns an empty 500.Two details explain the symptom pattern:
size > 0guard means the property is absent when no custom tool is declared, so the optional chain short-circuits and ordinary function tools keep working.for (… of message.tool_calls)loop, i.e. only once the provider has already returned a tool call — so tokens are spent and then the request dies.Streaming was never affected:
open-sse/utils/stream.jsalready normalises withnew Set(customToolNames || []). This PR applies the same normalisation at the two non-streaming boundaries.Why it wasn't caught
Every existing test constructs
customToolNamesas a hand-builtSet, whiletests/unit/openai-responses-custom-tools.test.jsseparately asserts the producer emits an array (expect(out._customToolNames).toEqual(["exec"])). Both halves are individually correct; the seam between them was never exercised.The fix
Normalise at the consumer, accepting
Array | Set | null | undefined, without mutating the caller's collection:The producer's array contract is deliberately left alone, since existing tests pin it.
utils/stream.jsis untouched — it is already correct, and changing it would swap a copy for a shared reference for no benefit.Tests
Added to
tests/unit/openai-responses-nonstream.test.js(8 cases for the non-streaming consumer, 1 for the forced-SSE→JSON consumer):null,undefined,[],new Set()→function_call["exec"]andnew Set(["exec"])→custom_tool_call, byte-identical outputfunction_call{"input":"…"}unwraps, with no wrapper leakageopenaiResponsesToOpenAIRequestinto the converter, assertingArray.isArray(_customToolNames)firstBefore the fix these fail with the
TypeError; after, the file is 18/18.Run with:
I also ran the full suite before and after the change on the same machine to compare failure sets: no test that passed beforehand fails afterwards. (A plain checkout is not all-green, as
CLAUDE.mddocuments, so the comparison is before/after rather than an absolute pass.)End-to-end verification
Built from this branch and run against a real provider: a Responses client declaring a custom tool now receives
type: "custom_tool_call"with the raw input intact — non-streaming and streaming — and acustom_tool_call_outputcontinuation resumes normally. Multi-line patch-shaped input round-trips byte-for-byte.This matters for Codex compatibility specifically, since Codex transports
apply_patchas a freeform custom tool, so every emittedapply_patchcall hits this path.