diff --git a/changelog.d/fixes/7532-tool-search-responses-to-chat.md b/changelog.d/fixes/7532-tool-search-responses-to-chat.md new file mode 100644 index 0000000000..28b0466b4f --- /dev/null +++ b/changelog.d/fixes/7532-tool-search-responses-to-chat.md @@ -0,0 +1 @@ +- fix(sse): map `tool_search` to a Chat function tool instead of dropping it during Responses->Chat translation (#7532) diff --git a/changelog.d/fixes/7533-verbosity-prompt-cache-key-leak.md b/changelog.d/fixes/7533-verbosity-prompt-cache-key-leak.md new file mode 100644 index 0000000000..7fd90f93f1 --- /dev/null +++ b/changelog.d/fixes/7533-verbosity-prompt-cache-key-leak.md @@ -0,0 +1 @@ +- fix(sse): gate `verbosity`/`prompt_cache_key` on OpenAI destination during Responses->Chat translation, stopping the leak to non-OpenAI upstreams like NVIDIA (#7533) diff --git a/open-sse/translator/request/openai-responses.ts b/open-sse/translator/request/openai-responses.ts index 83b1aedb20..7141d9f946 100644 --- a/open-sse/translator/request/openai-responses.ts +++ b/open-sse/translator/request/openai-responses.ts @@ -79,11 +79,28 @@ export function openaiResponsesToOpenAIRequest( const result: JsonRecord = { ...root }; + // #7533: `verbosity` and `prompt_cache_key` are GPT-5/OpenAI-only Chat Completions + // parameters. A strict-protocol non-OpenAI upstream (NVIDIA confirmed by the reporter; + // likely also GLM/Kimi/Deepseek direct endpoints) 400s on unrecognized top-level + // parameters, so they must only survive the downgrade when the destination really is + // an OpenAI-operated endpoint. + // + // Allowlist, NOT a denylist: over-stripping costs a cache hit, over-preserving costs a + // hard 400. `codex` is in the list because it IS an OpenAI upstream + // (chatgpt.com/backend-api/codex) and is precisely the destination #517 needed + // `prompt_cache_key` preserved for — /v1/responses runs every request through this + // downgrade (handleResponsesCore -> convertResponsesApiFormat) regardless of provider, + // so gating on "openai" alone silently re-broke Codex prompt caching. Other + // OpenAI-compatible passthroughs (e.g. Azure OpenAI) are deliberately NOT assumed in — + // add them only with evidence that the endpoint accepts these fields. + const OPENAI_PARAM_DESTINATIONS = new Set(["openai", "codex"]); + const isOpenAIDestination = OPENAI_PARAM_DESTINATIONS.has(toString(credentialRecord.provider)); + // GPT-5 verbosity: Responses `text.verbosity` → Chat Completions top-level `verbosity`. // Chat has no `text` wrapper, so carry the level across and drop the Responses-only // `text` object (a strict Chat endpoint 400s on unknown fields). const responsesVerbosity = normalizeVerbosity(toRecord(result.text).verbosity); - if (responsesVerbosity) result.verbosity = responsesVerbosity; + if (responsesVerbosity && isOpenAIDestination) result.verbosity = responsesVerbosity; delete result.text; // background: true requests a deferred Responses API run (the upstream @@ -331,11 +348,12 @@ export function openaiResponsesToOpenAIRequest( .filter((toolValue) => { const tool = toRecord(toolValue); const toolType = toString(tool.type); - // tool_search (#2766) and image_generation (#2950) are Responses API built-ins - // with no Chat Completions equivalent; drop them silently. - return ( - !TOOL_SEARCH_TOOL_TYPES.test(toolType) && !IMAGE_GENERATION_TOOL_TYPES.test(toolType) - ); + // image_generation (#2950) is a Responses API server-side hosted tool with no + // Chat Completions equivalent; drop it silently. tool_search (#2766) used to be + // dropped here too, but it is a CLIENT-executed tool (Codex sends it with + // `execution: "client"`) — see the flatMap branch below (#7532) for why it is + // now mapped onto a Chat function tool instead of discarded. + return !IMAGE_GENERATION_TOOL_TYPES.test(toolType); }) .flatMap((toolValue) => { const tool = toRecord(toolValue); @@ -365,6 +383,33 @@ export function openaiResponsesToOpenAIRequest( }, })); } + // tool_search (#2766) is a Responses API built-in Codex sends with + // `execution: "client"` — the CLIENT (Codex CLI) resolves the call locally, + // regardless of whether the wire format is Responses `{type:"tool_search"}` or + // Chat `{type:"function"}`. Dropping it silently (as before) hid the tool from + // the model entirely and broke Codex's lazy/deferred tool-loading protocol for + // any provider downgraded to Chat Completions (#7532). Map it onto a normal + // Chat function tool instead, mirroring the local_shell -> shell pattern below. + if (TOOL_SEARCH_TOOL_TYPES.test(toolType)) { + return { + type: "function", + function: { + name: toString(tool.name) || "tool_search", + description: + toString(tool.description) || "Search for additional deferred tools by query.", + parameters: tool.parameters ?? { + type: "object", + properties: { + query: { + type: "string", + description: "Natural-language or keyword query over available tools.", + }, + }, + required: ["query"], + }, + }, + }; + } // Pass web_search server tools through with their original type (versioned or plain). // These have no Chat Completions equivalent; preserve as-is so upstreams that understand // Anthropic-style web_search_YYYYMMDD naming receive the exact name they expect. @@ -483,8 +528,12 @@ export function openaiResponsesToOpenAIRequest( } // Cleanup Responses API specific fields - // Note: prompt_cache_key is intentionally preserved — it is used by Codex and other - // providers as a cache-affinity signal. Stripping it breaks prompt caching (#517). + // Note: prompt_cache_key is intentionally preserved for OpenAI destinations — it is + // used by Codex as a cache-affinity signal and stripping it unconditionally broke + // prompt caching (#517). But #517's fix never added a provider gate, so it leaked to + // every destination, OpenAI or not — a strict non-OpenAI upstream (NVIDIA) 400s on the + // unrecognized field (#7533). Strip it for any non-OpenAI destination. + if (!isOpenAIDestination) delete result.prompt_cache_key; delete result.input; delete result.instructions; delete result.include; diff --git a/tests/unit/openai-responses-verbosity.test.ts b/tests/unit/openai-responses-verbosity.test.ts index 863d0fa9c8..9769f42b98 100644 --- a/tests/unit/openai-responses-verbosity.test.ts +++ b/tests/unit/openai-responses-verbosity.test.ts @@ -42,12 +42,15 @@ test("Chat -> Responses ignores an invalid verbosity value", () => { }); test("Responses -> Chat maps text.verbosity to top-level verbosity and drops text", () => { + // #7533: verbosity is a GPT-5/OpenAI-only Chat Completions parameter and is only + // carried across for an OpenAI-destined request — pass `provider: "openai"` so this + // pins the real OpenAI-routed contract instead of the pre-#7533 unconditional one. const out = asRecord( openaiResponsesToOpenAIRequest( "gpt-5.5", { model: "gpt-5.5", input: [{ role: "user", content: "hi" }], text: { verbosity: "high" } }, false, - {} + { provider: "openai" } ) ); assert.equal(out.verbosity, "high"); diff --git a/tests/unit/tool-search-filtered-responses-to-chat-7532.test.ts b/tests/unit/tool-search-filtered-responses-to-chat-7532.test.ts new file mode 100644 index 0000000000..20596d5c8c --- /dev/null +++ b/tests/unit/tool-search-filtered-responses-to-chat-7532.test.ts @@ -0,0 +1,82 @@ +// #7532 — Responses -> Chat translation silently dropped `tool_search`, breaking +// Codex's deferred tool-discovery protocol for any built-in (non-openai-compatible-*) +// provider that gets downgraded from a Responses-shaped request to Chat Completions. +// +// Fix: `tool_search` (execution: "client", per Codex's wire shape) is a client-executed +// tool exactly like the existing `local_shell` -> `shell` mapping a few lines below it in +// the same file — the client (Codex CLI) resolves the call locally regardless of whether +// the wire format is Responses `{type:"tool_search"}` or Chat `{type:"function"}`. So +// instead of dropping it, the translator now maps it onto a proper Chat Completions +// function-tool declaration (mirroring the proven `local_shell` pattern), which lets the +// model see and call `tool_search` when the request is downgraded to Chat Completions. +import test from "node:test"; +import assert from "node:assert/strict"; + +const { openaiResponsesToOpenAIRequest } = await import( + "../../open-sse/translator/request/openai-responses.ts" +); + +function codexRequestWithToolSearch() { + return { + input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }], + tools: [ + { + type: "function", + name: "bash", + description: "Execute shell commands", + parameters: { + type: "object", + properties: { command: { type: "string" } }, + required: ["command"], + }, + }, + { + type: "tool_search", + name: "tool_search", + description: "Search for additional deferred tools by query", + execution: "client", + }, + ], + }; +} + +test("#7532: tool_search survives the Responses->Chat translator as a function tool", () => { + const body = codexRequestWithToolSearch(); + const out = openaiResponsesToOpenAIRequest("opencode-go/big-pickle", body, false, {}) as { + tools: { type?: string; function?: { name: string; description?: string } }[]; + }; + + assert.ok(out.tools.some((t) => t.function?.name === "bash")); + + const toolSearch = out.tools.find((t) => t.function?.name === "tool_search"); + assert.ok(toolSearch, "tool_search must not be silently dropped during Responses->Chat downgrade"); + assert.equal(toolSearch?.type, "function"); + assert.equal( + toolSearch?.function?.description, + "Search for additional deferred tools by query" + ); +}); + +test("#7532: tool_search without an explicit schema gets a usable default `query` parameter", () => { + const body = { + input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }], + tools: [{ type: "tool_search", name: "tool_search", execution: "client" }], + }; + const out = openaiResponsesToOpenAIRequest("opencode-go/big-pickle", body, false, {}) as { + tools: { function?: { name: string; parameters?: { properties?: Record } } }[]; + }; + const toolSearch = out.tools.find((t) => t.function?.name === "tool_search"); + assert.ok(toolSearch); + assert.ok(toolSearch?.function?.parameters?.properties?.query, "expected a `query` parameter"); +}); + +test("#7532: image_generation (a genuine server-side hosted tool, #2950) is still dropped", () => { + const body = { + input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }], + tools: [{ type: "image_generation", output_format: "png" }], + }; + const out = openaiResponsesToOpenAIRequest("opencode-go/big-pickle", body, false, {}) as { + tools: unknown[]; + }; + assert.equal(out.tools.length, 0); +}); diff --git a/tests/unit/translator-openai-responses-req.test.ts b/tests/unit/translator-openai-responses-req.test.ts index 3fdda8c31a..9618961bcc 100644 --- a/tests/unit/translator-openai-responses-req.test.ts +++ b/tests/unit/translator-openai-responses-req.test.ts @@ -910,21 +910,17 @@ test("Responses -> Chat: tool_search does not throw (issue #2766)", () => { ); }); -test("Responses -> Chat: tool_search is stripped from output tools array (issue #2766)", () => { - // Codex clients send tool_search alongside function tools. tool_search has no - // Chat Completions equivalent and must be dropped; function tools must remain. +test("Responses -> Chat: tool_search is mapped to a Chat function tool, not dropped (#7532)", () => { + // tool_search (execution: "client") is client-resolved, same as local_shell -> shell; + // dropping it (#2766) hid the tool and broke Codex's deferred tool-discovery on + // downgrade (#7532) — it is now mapped to a Chat function tool instead. const result = openaiResponsesToOpenAIRequest( "gpt-4o", { input: [{ role: "user", content: [{ type: "input_text", text: "hello" }] }], tools: [ { type: "tool_search", name: "search" }, - { - type: "function", - name: "foo", - description: "A function", - parameters: { type: "object" }, - }, + { type: "function", name: "foo", description: "A function", parameters: { type: "object" } }, ], }, false, @@ -933,14 +929,12 @@ test("Responses -> Chat: tool_search is stripped from output tools array (issue const tools = result.tools as any[]; assert.ok(Array.isArray(tools), "tools array must be present"); - assert.equal( - tools.some((t) => t.type === "tool_search"), - false, - "tool_search must be stripped from output" - ); - assert.equal(tools.length, 1, "only the function tool must remain"); - assert.equal(tools[0].type, "function"); - assert.equal(tools[0].function.name, "foo"); + assert.equal(tools.some((t) => t.type === "tool_search"), false, "raw tool_search type must not survive"); + assert.equal(tools.length, 2, "mapped tool_search function + the function tool must remain"); + const toolSearch = tools.find((t) => t.function?.name === "search"); + assert.ok(toolSearch, "tool_search must be mapped to a Chat function tool named after it"); + assert.equal(toolSearch.type, "function"); + assert.equal(tools.find((t) => t.function?.name === "foo")?.type, "function"); }); // --- Issue #2950: image_generation built-in should be silently dropped --- diff --git a/tests/unit/verbosity-prompt-cache-key-provider-gate-7533.test.ts b/tests/unit/verbosity-prompt-cache-key-provider-gate-7533.test.ts new file mode 100644 index 0000000000..61a29bd83a --- /dev/null +++ b/tests/unit/verbosity-prompt-cache-key-provider-gate-7533.test.ts @@ -0,0 +1,141 @@ +// #7533 — Responses -> Chat translation leaked two GPT-5-only fields (`verbosity`, +// `prompt_cache_key`) into the translated Chat Completions body regardless of the +// destination provider. Any strict-protocol Chat Completions upstream that 400s on +// unrecognized top-level parameters (NVIDIA confirmed by the reporter) rejected 100% of +// requests routed through a `wire_api: responses` combo targeting that provider. +// +// Fix: gate both fields on `credentials.provider` being an OpenAI-family destination — +// unset/strip them otherwise. The OpenAI-destined path (needed for #517's prompt-caching +// fix) must stay byte-identical, which the sanity test below encodes as a hard regression +// guard. +import test from "node:test"; +import assert from "node:assert/strict"; + +const { openaiResponsesToOpenAIRequest } = await import( + "../../open-sse/translator/request/openai-responses.ts" +); + +function asRecord(value: unknown): Record { + return value as Record; +} + +test("#7533: verbosity is stripped for a non-OpenAI upstream (NVIDIA)", () => { + const out = asRecord( + openaiResponsesToOpenAIRequest( + "z-ai/glm-5.2", + { + model: "z-ai/glm-5.2", + input: [{ role: "user", content: "hello" }], + text: { verbosity: "low" }, + }, + false, + { provider: "nvidia" } + ) + ); + + assert.equal( + out.verbosity, + undefined, + "verbosity is a GPT-5-only field and must be stripped for non-OpenAI upstreams" + ); +}); + +test("#7533: prompt_cache_key is stripped for a non-OpenAI upstream (NVIDIA)", () => { + const out = asRecord( + openaiResponsesToOpenAIRequest( + "z-ai/glm-5.2", + { + model: "z-ai/glm-5.2", + input: [{ role: "user", content: "hello" }], + prompt_cache_key: "abc-123", + }, + false, + { provider: "nvidia" } + ) + ); + + assert.equal( + out.prompt_cache_key, + undefined, + "prompt_cache_key is a GPT-5-only field and must be stripped for non-OpenAI upstreams" + ); +}); + +test("#7533 sanity: both fields are still preserved for an actual OpenAI upstream (#517 regression guard)", () => { + const out = asRecord( + openaiResponsesToOpenAIRequest( + "gpt-5.5", + { + model: "gpt-5.5", + input: [{ role: "user", content: "hello" }], + text: { verbosity: "low" }, + prompt_cache_key: "abc-123", + }, + false, + { provider: "openai" } + ) + ); + + assert.equal(out.verbosity, "low"); + assert.equal(out.prompt_cache_key, "abc-123"); +}); + +test("#7533: fields are also stripped when no credentials/provider is supplied at all", () => { + const out = asRecord( + openaiResponsesToOpenAIRequest( + "z-ai/glm-5.2", + { + model: "z-ai/glm-5.2", + input: [{ role: "user", content: "hello" }], + text: { verbosity: "high" }, + prompt_cache_key: "abc-123", + }, + false, + {} + ) + ); + + assert.equal(out.verbosity, undefined); + assert.equal(out.prompt_cache_key, undefined); +}); + +// --- #517 regression guard: the provider gate must not starve Codex of its cache key --- + +test("#517 (guard): prompt_cache_key survives the downgrade for the 'codex' provider", () => { + // /v1/responses runs EVERY request through this downgrade (handleResponsesCore -> + // convertResponsesApiFormat) regardless of provider, and codex is an OpenAI-operated + // upstream (chatgpt.com/backend-api/codex). Gating #7533 on provider === "openai" + // alone stripped the key here and silently re-broke the Codex prompt-cache affinity + // that #517 exists to protect. + const out = asRecord( + openaiResponsesToOpenAIRequest( + "gpt-5.5", + { + model: "gpt-5.5", + input: [{ role: "user", content: "hello" }], + prompt_cache_key: "session-abc-123", + }, + false, + { provider: "codex" } + ) + ); + + assert.equal(out.prompt_cache_key, "session-abc-123"); +}); + +test("#517 (guard): verbosity also survives for the 'codex' provider", () => { + const out = asRecord( + openaiResponsesToOpenAIRequest( + "gpt-5.5", + { + model: "gpt-5.5", + input: [{ role: "user", content: "hello" }], + text: { verbosity: "high" }, + }, + false, + { provider: "codex" } + ) + ); + + assert.equal(out.verbosity, "high"); +});