From 657232d8713259658f37bb3b0515fb8421efd992 Mon Sep 17 00:00:00 2001 From: olddonkey Date: Thu, 20 Aug 2026 21:53:09 -0700 Subject: [PATCH] fix(responses): scope tool-field stripping per destination MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tests/responses-routed-web-search-fields.test.ts` > "official OpenAI API-key traffic retains OpenAI web_search fields" fails on untouched `dev`: `external_web_access` is stripped from traffic bound for the official OpenAI API, which supports it. Two layers strip that field. The capability layer is gated correctly — it runs only when `supportsOpenAiWebSearchToolFields === false`, which xAI declares in the registry. `CANONICAL_ONLY_TOOL_FIELDS` applied its whole table under one outer gate, `!isCanonicalOpenAiForwardProvider`, and an official OpenAI API provider is not the canonical ChatGPT surface, so the table stripped it there too. The two fields in that table do not have the same scope: - `external_web_access` is understood by both OpenAI-operated surfaces — the canonical ChatGPT Codex backend and the official OpenAI API. Routed third-party gateways reject it (xAI: `Argument not supported`). - `defer_loading` is private to the canonical surface; the official OpenAI API rejects it, so it must keep being stripped there. Deleting the `external_web_access` row is the obvious fix and it is wrong — verified: it turns the failing test green and breaks three that correctly require the field stripped for routed providers, including the xAI namespace lowering case. Give each row its own `isSupportedDestination` predicate instead, and move the call outside the namespace-lowering gate so OpenAI API-key traffic is also covered, where only the canonical-only row applies. The table stays declarative: adding a field remains a data change, which is the property that made it worth having after each private bit previously arrived as its own bespoke traversal. A comment names the capability flag as the mechanism owning the routed web-search case, so the two layers are not later collapsed back together. Co-Authored-By: Claude Fable 5 --- src/adapters/openai-responses.ts | 42 +++++++++++----- ...responses-routed-web-search-fields.test.ts | 49 +++++++++++++++++++ 2 files changed, 78 insertions(+), 13 deletions(-) diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index c5405b1c5a..dff0e02bae 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -156,26 +156,40 @@ function stripInvalidItemIds(body: unknown): unknown { } /** - * Codex-private tool fields that only the ChatGPT backend understands. + * Tool fields with destination-specific support. * - * A third-party Responses gateway validates its schema and rejects the whole request before - * inference — xAI answers `Argument not supported: external_web_access` — so these are removed at - * the noncanonical boundary while the tool and every public option stay. + * A third-party Responses gateway validates its schema and may reject the whole request before + * inference, so each entry declares which destinations understand it while the tool and every + * public option stay intact elsewhere. * * Keep this a table. Each private bit Codex attaches has so far arrived as its own bespoke strip * with its own traversal, and the traversals disagreed about which containers they covered; a new - * one should be a row here instead. `toolTypes` omitted means the field is private on any tool. + * one should be a row here instead. `toolTypes` omitted means the field is scoped on any tool. + * + * `external_web_access` is understood by both OpenAI-operated Responses surfaces. Routed web-search + * incompatibility is also handled by the `supportsOpenAiWebSearchToolFields` capability flag, which + * strips the broader OpenAI-only web-search field set for explicit denials; do not collapse that + * layer into this table. `defer_loading` differs because it is private to the canonical ChatGPT + * surface and the official OpenAI API rejects it. */ -const CANONICAL_ONLY_TOOL_FIELDS: readonly { field: string; toolTypes?: ReadonlySet }[] = [ - // ChatGPT's browsing policy bit. The public hosted tool is enabled by its presence alone. - { field: "external_web_access", toolTypes: new Set(["web_search", "web_search_preview"]) }, +const CANONICAL_ONLY_TOOL_FIELDS: readonly { + field: string; + toolTypes?: ReadonlySet; + isSupportedDestination: (provider: OcxProviderConfig) => boolean; +}[] = [ + // OpenAI's browsing policy bit. The public hosted tool is enabled by its presence alone. + { + field: "external_web_access", + toolTypes: new Set(["web_search", "web_search_preview"]), + isSupportedDestination: isOpenAiOperatedResponsesDestination, + }, // Deferred-discovery marker. `activateDeferredTool` clears it only for tools a `tool_search_output` // already loaded, so a still-deferred declaration — including one promoted out of a namespace // group — otherwise reaches the wire carrying it. - { field: "defer_loading" }, + { field: "defer_loading", isSupportedDestination: isCanonicalOpenAiForwardProvider }, ]; -function stripCanonicalOnlyToolFields(body: unknown): unknown { +function stripCanonicalOnlyToolFields(body: unknown, provider: OcxProviderConfig): unknown { if (!isPlainObject(body)) return body; const rewriteTools = (tools: unknown[]): unknown[] => { @@ -183,7 +197,8 @@ function stripCanonicalOnlyToolFields(body: unknown): unknown { const rewritten = tools.map(tool => { if (!isPlainObject(tool)) return tool; let next = tool; - for (const { field, toolTypes } of CANONICAL_ONLY_TOOL_FIELDS) { + for (const { field, toolTypes, isSupportedDestination } of CANONICAL_ONLY_TOOL_FIELDS) { + if (isSupportedDestination(provider)) continue; if (!Object.hasOwn(next, field)) continue; if (toolTypes && (typeof next.type !== "string" || !toolTypes.has(next.type))) continue; const { [field]: _private, ...rest } = next; @@ -1721,9 +1736,10 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): const rewritten = rewriteRoutedNamespaceToolsForUpstream(outBody); outBody = rewritten.body; convertedRoutedNamespaceToolAliases = rewritten.aliases; - // Last, so promoted namespace children are also cleared of Codex-private fields. - outBody = stripCanonicalOnlyToolFields(outBody); } + // Last, so promoted namespace children are also cleared of destination-unsupported fields. + // This still runs for OpenAI API-key traffic, where only the canonical-only entries apply. + outBody = stripCanonicalOnlyToolFields(outBody, provider); const threadServingIdentityChanged = parsed._stripReasoningEncryptedContent === true; const sanitizedBody = normalizeToolSchemas(stripSparkCompatibility(stripUnsupportedReasoningParams(stripItemIdsWhenUnstored(stripInvalidItemIds(stripUnsupportedHostedTools(sanitizeReasoningInputContent(scrubOcxCompactionItems( outBody, diff --git a/tests/responses-routed-web-search-fields.test.ts b/tests/responses-routed-web-search-fields.test.ts index 7df6e24a63..84cd82a7fe 100644 --- a/tests/responses-routed-web-search-fields.test.ts +++ b/tests/responses-routed-web-search-fields.test.ts @@ -28,6 +28,25 @@ function buildWebSearchBody(provider: OcxProviderConfig): Record; } +function buildScopedToolFieldsBody(provider: OcxProviderConfig): Record { + const request = createResponsesPassthroughAdapter(provider).buildRequest({ + modelId: "test-model", + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { + model: "test-model", + input: "ping", + tools: [{ + type: "web_search", + external_web_access: true, + defer_loading: true, + }], + }, + }, { headers: new Headers() }); + return JSON.parse(request.body) as Record; +} + // #2188 follow-up: routed Responses upstreams (xAI api.x.ai) 400 the WHOLE request on // OpenAI-only web_search config fields (probe 2026-08-21: external_web_access and // search_context_size each 400 individually; user_location and filters are accepted). @@ -81,3 +100,33 @@ describe("Responses buildRequest web_search capability", () => { }]); }); }); + +describe("Responses buildRequest destination-scoped tool fields", () => { + test("official OpenAI API-key provider keeps external_web_access and drops defer_loading", () => { + const body = buildScopedToolFieldsBody({ + adapter: "openai-responses", + baseUrl: "https://api.openai.com/v1", + authMode: "key", + apiKey: "test-openai-key", + }); + + expect(body.tools).toEqual([{ + type: "web_search", + external_web_access: true, + }]); + }); + + test("canonical ChatGPT forward provider keeps both fields", () => { + const body = buildScopedToolFieldsBody({ + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + }); + + expect(body.tools).toEqual([{ + type: "web_search", + external_web_access: true, + defer_loading: true, + }]); + }); +});