diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index c5405b1c5a..70a76ffba5 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -166,16 +166,20 @@ function stripInvalidItemIds(body: unknown): unknown { * 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. */ -const CANONICAL_ONLY_TOOL_FIELDS: readonly { field: string; toolTypes?: ReadonlySet }[] = [ +const CANONICAL_ONLY_TOOL_FIELDS: readonly { field: string; toolTypes?: ReadonlySet; capabilityGated?: boolean }[] = [ // 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"]) }, + // OWNERSHIP: official OpenAI API-key traffic and unclassified gateways ACCEPT this field, so + // it is only stripped when the provider capability denies it (supportsOpenAiWebSearchToolFields + // === false), matching stripOpenAiOnlyWebSearchFields; see + // tests/responses-routed-web-search-fields.test.ts. + { field: "external_web_access", toolTypes: new Set(["web_search", "web_search_preview"]), capabilityGated: true }, // 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" }, ]; -function stripCanonicalOnlyToolFields(body: unknown): unknown { +function stripCanonicalOnlyToolFields(body: unknown, includeCapabilityGated: boolean): unknown { if (!isPlainObject(body)) return body; const rewriteTools = (tools: unknown[]): unknown[] => { @@ -183,7 +187,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, capabilityGated } of CANONICAL_ONLY_TOOL_FIELDS) { + if (capabilityGated && !includeCapabilityGated) continue; if (!Object.hasOwn(next, field)) continue; if (toolTypes && (typeof next.type !== "string" || !toolTypes.has(next.type))) continue; const { [field]: _private, ...rest } = next; @@ -1722,7 +1727,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): outBody = rewritten.body; convertedRoutedNamespaceToolAliases = rewritten.aliases; // Last, so promoted namespace children are also cleared of Codex-private fields. - outBody = stripCanonicalOnlyToolFields(outBody); + outBody = stripCanonicalOnlyToolFields(outBody, provider.supportsOpenAiWebSearchToolFields === false); } const threadServingIdentityChanged = parsed._stripReasoningEncryptedContent === true; const sanitizedBody = normalizeToolSchemas(stripSparkCompatibility(stripUnsupportedReasoningParams(stripItemIdsWhenUnstored(stripInvalidItemIds(stripUnsupportedHostedTools(sanitizeReasoningInputContent(scrubOcxCompactionItems( diff --git a/tests/openai-responses-passthrough.test.ts b/tests/openai-responses-passthrough.test.ts index 61c40c66b5..e4dc779225 100644 --- a/tests/openai-responses-passthrough.test.ts +++ b/tests/openai-responses-passthrough.test.ts @@ -976,13 +976,16 @@ describe("OpenAI Responses passthrough sanitization", () => { expect(body.tools[0]).toMatchObject({ type: "image_generation" }); }); - test("drops ChatGPT's external_web_access hint but keeps routed web search", () => { - const adapter = createResponsesPassthroughAdapter({ - adapter: "openai-responses", - baseUrl: "https://api.x.ai/v1", - authMode: "key" as const, - apiKey: "xai-test", - }); + test("drops ChatGPT's external_web_access hint but keeps routed web search", () => { + const adapter = createResponsesPassthroughAdapter({ + adapter: "openai-responses", + baseUrl: "https://api.x.ai/v1", + authMode: "key" as const, + apiKey: "xai-test", + // The registry declares this denial for xAI; the strip is capability-driven, not + // hostname-driven (official OpenAI API-key traffic keeps the fields). + supportsOpenAiWebSearchToolFields: false, + }); const request = adapter.buildRequest({ modelId: "grok-4.6", context: { messages: [] }, @@ -1033,13 +1036,14 @@ describe("OpenAI Responses passthrough sanitization", () => { // `activateDeferredTool` clears `defer_loading` only for tools a `tool_search_output` already // loaded, so the first turn of a deferred catalog — and any child promoted out of a namespace // group — otherwise carries the private field to a gateway that rejects unknown arguments. - test("drops Codex-private tool fields from routed declarations", () => { - const adapter = createResponsesPassthroughAdapter({ - adapter: "openai-responses", - baseUrl: "https://api.x.ai/v1", - authMode: "key" as const, - apiKey: "xai-test", - }); + test("drops Codex-private tool fields from routed declarations", () => { + const adapter = createResponsesPassthroughAdapter({ + adapter: "openai-responses", + baseUrl: "https://api.x.ai/v1", + authMode: "key" as const, + apiKey: "xai-test", + supportsOpenAiWebSearchToolFields: false, + }); const request = adapter.buildRequest({ modelId: "grok-4.6", context: { messages: [] },