fix(sse): stop dropping tool_search and leaking OpenAI-only params in Responses->Chat translation (#7571)

* fix(sse): stop dropping tool_search and stop leaking OpenAI-only params in Responses->Chat translation (#7532, #7533)

#7532: `openai-responses.ts` unconditionally dropped `tool_search` when
downgrading a Responses-shaped request to Chat Completions, hiding the tool
from the model and breaking Codex's deferred/lazy tool-discovery protocol for
any provider that gets downgraded (e.g. built-in providers like opencode-go).
tool_search carries `execution: "client"` — the client resolves the call
locally regardless of wire shape — so it is now mapped to a normal Chat
function tool, mirroring the existing local_shell -> shell pattern in the
same file, instead of being silently discarded.

#7533: the same translator unconditionally copied two GPT-5/OpenAI-only
fields (`verbosity`, `prompt_cache_key`) into the translated Chat body
regardless of destination provider. A strict-protocol non-OpenAI upstream
(NVIDIA confirmed by the reporter) 400s on unrecognized top-level parameters.
Both fields are now gated on `credentials.provider === "openai"`, stripped
otherwise; the existing OpenAI-destined behavior (needed for #517's
prompt-caching fix) is preserved byte-identical via a dedicated sanity test.

Regression tests: tests/unit/tool-search-filtered-responses-to-chat-7532.test.ts,
tests/unit/verbosity-prompt-cache-key-provider-gate-7533.test.ts. Two existing
tests that encoded the old buggy contract (unconditional tool_search drop /
unconditional field leak with no credentials) were aligned to the corrected
contract: tests/unit/translator-openai-responses-req.test.ts,
tests/unit/openai-responses-verbosity.test.ts.

Gates run green: file-size, complexity, cognitive-complexity, typecheck:core,
lint (scoped to changed files), and the full touched-area unit test suite
(329 tests, 0 failures).

* fix(sse): keep prompt_cache_key/verbosity for the codex destination (#7533)

The #7533 provider gate allowlisted only "openai", but /v1/responses routes
EVERY request through this downgrade (handleResponsesCore ->
convertResponsesApiFormat) regardless of provider, and codex is an
OpenAI-operated upstream (chatgpt.com/backend-api/codex). Gating it out
stripped prompt_cache_key for Codex and silently re-broke the prompt-cache
affinity #517 exists to protect — with no test covering it.

Allowlist is now {openai, codex} and carries two #517 regression guards.
Non-OpenAI upstreams (NVIDIA) still get both fields stripped, per #7533.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-17 10:41:49 -03:00
committed by GitHub
parent 6e489039ef
commit 280c27bf2d
7 changed files with 297 additions and 26 deletions

View File

@@ -0,0 +1 @@
- fix(sse): map `tool_search` to a Chat function tool instead of dropping it during Responses->Chat translation (#7532)

View File

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

View File

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

View File

@@ -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");

View File

@@ -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<string, unknown> } } }[];
};
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);
});

View File

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

View File

@@ -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<string, unknown> {
return value as Record<string, unknown>;
}
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");
});