mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-04 14:22:09 +03:00
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:
committed by
GitHub
parent
6e489039ef
commit
280c27bf2d
1
changelog.d/fixes/7532-tool-search-responses-to-chat.md
Normal file
1
changelog.d/fixes/7532-tool-search-responses-to-chat.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(sse): map `tool_search` to a Chat function tool instead of dropping it during Responses->Chat translation (#7532)
|
||||
@@ -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)
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 ---
|
||||
|
||||
141
tests/unit/verbosity-prompt-cache-key-provider-gate-7533.test.ts
Normal file
141
tests/unit/verbosity-prompt-cache-key-provider-gate-7533.test.ts
Normal 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");
|
||||
});
|
||||
Reference in New Issue
Block a user