From 7f739213a1793a9b59659106e78eef86dfa3219c Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 18 Sep 2026 14:51:39 +0900 Subject: [PATCH] fix(responses): let a provider declare the hosted tools it rejects An OpenAI-compatible Responses gateway does not necessarily accept every capability OpenAI accepts, and until now it had no way to say so. The only mechanism was UNSUPPORTED_HOSTED_TOOLS in src/responses/hosted-tool-policy.ts, a table of (model, baseUrl) predicates, so supporting a narrower destination meant shipping a proxy release that named its endpoint. The reported destination (#5002) accepts plain Responses requests and function tools but rejects hosted web_search with HTTP 400 unsupported_request. Codex forwards its hosted declaration with every request, so even "Reply exactly with OK" failed before the model answered, and the only workaround was disabling web search globally for every provider. Add unsupportedHostedTools to the provider config. stripUnsupportedHostedTools now consults it alongside the built-in table and removes denied declarations from tools, from client-loaded additional_tools, and from tool_choice before serialization. The declaration is additive: the table still covers destinations that reject a tool regardless of configuration, so a declaration can only deny more, never re-enable a known-broken pairing. Two deliberate properties. Spelling variants of one capability are aliased, so declaring web_search also denies web_search_preview; the rest of the proxy already folds that pair into a single tool, and honouring only the spelling the operator wrote would reproduce the original 400 while the config claimed to have prevented it. And the value is validated against a closed vocabulary, because the provider schema ends in .passthrough(): an unvalidated misspelling would be persisted and then match no tool, leaving the operator with the rejection the field exists to prevent and nothing explaining why. That is the codexToolMode lesson from #2106. A provider can no longer both deny a hosted tool and prefer it in modelPreferHostedTools; the denial wins at request time, so accepting the pair would silently ignore the preference. The custom-tool half of the report needs no change: supportsResponsesCustomTools already exists as a provider capability and the reporter confirmed it works. The two are independent and are denied independently. --- .../docs/reference/configuration/providers.md | 1 + scripts/test-layout/layout.json | 1 + src/adapters/openai-responses/tool-schema.ts | 26 ++- src/config/schema/leaf-validators.ts | 35 ++- src/responses/hosted-tool-policy.ts | 87 +++++++- src/responses/schema.ts | 11 +- src/server/auth-cors.ts | 18 ++ src/server/management/provider-routes.ts | 22 ++ src/types/provider.ts | 17 ++ structure/providers/chat-compat.md | 33 +++ tests/fixtures/test-layout-expected.json | 1 + .../responses-hosted-tool-declaration.test.ts | 199 ++++++++++++++++++ 12 files changed, 438 insertions(+), 13 deletions(-) create mode 100644 tests/responses/responses-hosted-tool-declaration.test.ts diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index a62e8ed817..982c892fda 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -188,6 +188,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `xaiResponsesXSearch?` | `boolean` | Disabled by default. On an xAI Responses destination, append the provider-hosted `x_search` declaration only when a live `web_search` tool survives final request normalization. Existing declarations are not duplicated, caller `tool_choice`/`allowed_tools` selectors are never widened, and this is separate from the web-search sidecar's `search.xSearch` options. | | `modelPreferHostedTools?` | `Record` | Exact-model opt-in for non-forward Responses gateways that reserve a hosted-tool namespace. Currently accepts only `["image_generation"]`; a matching model must use the `openai-responses` wire and support that hosted tool. It removes colliding client `image_gen` declarations and rewrites their selectors to preserve caller tool choice. For OpenAI API virtual `-pro` models, the selected public ID is matched first and the resolved base wire-model ID is a fallback. `modelAdapters` resolves the public ID first, then the base ID; the second resolution determines the final wire. Other models retain normal alias behavior. | | `annotateEmptyToolOutputs?` | `boolean` | Replace a present-but-empty tool result with a short marker before it reaches the model, so a blank result is not read as a missing one. Applies to blank strings and text-only part arrays; image, file, and encrypted parts are never touched. Defaults to `true` for DeepSeek from the built-in registry and is otherwise unset. Set `false` to opt a provider out — an explicit `false` is preserved across later edits that omit the field. `PATCH /api/providers?name=` accepts `true`, `false`, or `null` to clear the override and return to registry-default behavior. | +| `unsupportedHostedTools?` | `string[]` | Hosted tool declarations this Responses destination rejects, so they are stripped from `tools`, from client-loaded `additional_tools`, and from `tool_choice` instead of being forwarded and rejected upstream. Use it for an OpenAI-compatible gateway with a narrower capability set — one that accepts plain Responses requests and `function` tools but returns HTTP 400 for hosted `web_search` — so a text-only prompt is not failed by a capability it never needed. Accepts only hosted tool type names (`web_search`, `web_search_preview`, `file_search`, `computer_use_preview`, `computer_use`, `code_interpreter`, `image_generation`, `image_gen`, `mcp`, `tool_search`, `local_shell`, `x_search`); an unrecognized name is rejected rather than silently ignored. Spelling variants of one capability are aliased, so `["web_search"]` also denies `web_search_preview`. A provider cannot both deny a hosted tool here and prefer it in `modelPreferHostedTools`. This is independent of `supportsResponsesCustomTools`; set both for a gateway that also rejects native custom tools. `PATCH /api/providers?name=` accepts an array or `null` to clear it. | | `reasoningEffortMap?` | `Record` | Provider-wide wire aliases for reasoning labels. Map a label to `"__omit__"` to drop the reasoning field from the upstream request entirely: `reasoning_effort` on an OpenAI-compatible wire, and Ollama's native `think` field on the Ollama native adapter (#2356). | | `modelReasoningEffortMap?` | `Record>` | Per-model wire aliases for reasoning labels. Map a label to `"__omit__"` to drop the reasoning field from the upstream request entirely. | | `reasoningWireFormat?` | `"gateway-object"` | For OpenAI-compatible gateways that accept `reasoning: { enabled, effort }` instead of `reasoning_effort`. The ClinePass preset sets this automatically. | diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index d1843a7e3f..5b3f4c3daf 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1208,6 +1208,7 @@ "responses-custom-tool-guidance.test.ts": "responses", "responses-custom-tool-repair.test.ts": "responses", "responses-fetch-helpers-boundary.test.ts": "responses", + "responses-hosted-tool-declaration.test.ts": "responses", "responses-field-backfill.test.ts": "responses", "responses-forward-dangling-call.test.ts": "responses", "responses-forward-incomplete-quota.test.ts": "responses", diff --git a/src/adapters/openai-responses/tool-schema.ts b/src/adapters/openai-responses/tool-schema.ts index 6158685dcc..12d11093cd 100644 --- a/src/adapters/openai-responses/tool-schema.ts +++ b/src/adapters/openai-responses/tool-schema.ts @@ -1,5 +1,5 @@ import { namespacedToolName, type AdapterEvent, type OcxParsedRequest, type OcxProviderConfig, type OcxUsage, type TierDecision } from "../../types"; -import { isHostedToolUnsupportedForModel } from "../../responses/hosted-tool-policy"; +import { declaredUnsupportedHostedTools, isHostedToolUnsupportedForModel } from "../../responses/hosted-tool-policy"; import { debugProviderDiagnostic } from "../../lib/debug"; import { stripUnicodePropertyPatterns } from "../responses-tool-schema"; import { @@ -225,17 +225,29 @@ export function promoteClientLoadedTools(body: unknown): unknown { } /** - * Remove hosted tool entries the target native slug rejects, so the OAuth-passthrough body never - * carries a tool the upstream model 400s on. No-op (returns the original reference) when nothing - * matches, keeping the common path allocation-free. + * Remove hosted tool entries the destination rejects, so the OAuth-passthrough body never + * carries a tool the upstream 400s on. Two sources of truth are consulted: the built-in + * table of known-broken native slugs and destinations, and the routed provider's own + * `unsupportedHostedTools` declaration. The declaration is what lets an OpenAI-compatible + * Responses gateway with a narrower capability set be described in config instead of + * requiring a hard-coded destination rule per vendor (#5002). + * + * No-op (returns the original reference) when nothing matches, keeping the common path + * allocation-free. */ -export function stripUnsupportedHostedTools(body: unknown, provider: Pick): unknown { +export function stripUnsupportedHostedTools( + body: unknown, + provider: Pick, +): unknown { if (!isPlainObject(body)) return body; const model = typeof body.model === "string" ? body.model : ""; + // Expanded once per request rather than per tool: the alias walk is the only + // non-lookup work in this filter. + const declaredUnsupported = declaredUnsupportedHostedTools(provider); const filterTools = (tools: unknown[]): unknown[] => { const filtered = tools.filter(t => { const type = isPlainObject(t) && typeof t.type === "string" ? t.type : undefined; - return !type || !isHostedToolUnsupportedForModel(model, type, provider.baseUrl); + return !type || !isHostedToolUnsupportedForModel(model, type, provider.baseUrl, declaredUnsupported); }); return filtered.length === tools.length ? tools : filtered; }; @@ -274,7 +286,7 @@ export function stripUnsupportedHostedTools(body: unknown, provider: Pick tools.every(tool => DECLARABLE_HOSTED_TOOL_TYPES.has(tool)), + { message: `unsupportedHostedTools accepts only hosted tool types: ${[...DECLARABLE_HOSTED_TOOL_TYPES].join(", ")}` }, + ) + .optional(), retryOn429: retryOn429PolicySchema.optional(), transientRetryOn5xx: transientRetryOn5xxPolicySchema.optional(), codexAccountMode: z.enum(["pool", "direct"]).optional(), @@ -377,7 +393,13 @@ export function modelPreferHostedToolsConfigError( value: unknown, field: string, providerName: string, - provider: { adapter?: unknown; authMode?: unknown; modelAdapters?: unknown; baseUrl?: unknown }, + provider: { + adapter?: unknown; + authMode?: unknown; + modelAdapters?: unknown; + baseUrl?: unknown; + unsupportedHostedTools?: unknown; + }, ): string | null { if (value === undefined) return null; if (!value || typeof value !== "object" || Array.isArray(value)) return `${field} must be a plain object`; @@ -385,6 +407,12 @@ export function modelPreferHostedToolsConfigError( if (prototype !== Object.prototype && prototype !== null) return `${field} must be a plain object with own properties`; const entries = Object.entries(value); const registry = getProviderRegistryEntry(providerName); + // A provider that denies a hosted tool cannot also prefer it. Both keys are + // provider-owned capability statements about the same tool, and the denial wins at + // request time, so accepting the pair would silently ignore the preference. + const declaredUnsupported = declaredUnsupportedHostedTools( + provider as { unsupportedHostedTools?: readonly string[] }, + ); // Effective transport: a `preserveCustomDestination` registry row reused under a // different endpoint keeps its own adapter AND its own auth at runtime, because // `routedProviderConfig()` honors `providerMatchesRegistryTransport()`. Both the @@ -445,6 +473,9 @@ export function modelPreferHostedToolsConfigError( if (typeof tool !== "string" || !SUPPORTED_PREFERRED_HOSTED_TOOLS.has(tool)) { return `${field}.${key} supports only image_generation`; } + if (declaredUnsupported.has(tool)) { + return `${field}.${key} cannot prefer ${tool}: unsupportedHostedTools declares it unsupported`; + } if (isHostedToolUnsupportedForModel(key, tool)) { return `${field}.${key} cannot prefer ${tool}: the model does not support it`; } diff --git a/src/responses/hosted-tool-policy.ts b/src/responses/hosted-tool-policy.ts index 8502e6b4fc..696541b5fe 100644 --- a/src/responses/hosted-tool-policy.ts +++ b/src/responses/hosted-tool-policy.ts @@ -10,7 +10,90 @@ const UNSUPPORTED_HOSTED_TOOLS: ReadonlyArray<{ }, ]; -/** True when forwarding this hosted tool to the model would be rejected upstream. */ -export function isHostedToolUnsupportedForModel(modelId: string, tool: string, baseUrl?: string): boolean { +/** + * Hosted-tool declaration names an operator may list in `unsupportedHostedTools`. + * + * A gateway must be able to deny anything a client can send it, so this is the set of + * nameless hosted/private declaration types the proxy recognizes on a Responses request. + * It is deliberately a closed vocabulary: the provider config schema ends in + * `.passthrough()`, so an unvalidated misspelling would be accepted, persisted, and then + * silently strip nothing -- which is the exact 400 the operator set the field to avoid + * (the `codexToolMode` lesson in #2106). + */ +export const DECLARABLE_HOSTED_TOOL_TYPES: ReadonlySet = new Set([ + "web_search", + "web_search_preview", + "file_search", + "computer_use_preview", + "computer_use", + "code_interpreter", + "image_generation", + "image_gen", + "mcp", + "tool_search", + "local_shell", + "x_search", +]); + +/** + * Spellings that name one capability. Declaring either member denies both, because the + * rest of the proxy already treats these as a single tool: the parser folds + * `web_search_preview` onto one name (`src/responses/parser-tools.ts`), Chat ingress + * accepts the pair together (`src/chat/inbound.ts`), and the canonical-field strip lists + * the pair in a single `toolTypes` set + * (`src/adapters/openai-responses/request-strips.ts`). + * + * Without the alias a capability declaration would be honoured for the spelling the + * operator happened to write and ignored for the one the client happened to send, which + * reproduces the original rejection while the config claims to have prevented it. + */ +const HOSTED_TOOL_ALIAS_GROUPS: ReadonlyArray> = [ + new Set(["web_search", "web_search_preview"]), + new Set(["image_generation", "image_gen"]), + new Set(["computer_use_preview", "computer_use"]), +]; + +const NO_DECLARED_HOSTED_TOOLS: ReadonlySet = new Set(); + +/** + * Expand a provider's declared denials through the alias groups once per request, so the + * per-tool predicate stays a set lookup. Returns a shared empty set when the provider + * declares nothing, keeping the common path allocation-free. + */ +export function declaredUnsupportedHostedTools( + provider?: { unsupportedHostedTools?: readonly string[] }, +): ReadonlySet { + const declared = provider?.unsupportedHostedTools; + if (!Array.isArray(declared) || declared.length === 0) return NO_DECLARED_HOSTED_TOOLS; + const out = new Set(); + for (const raw of declared) { + if (typeof raw !== "string") continue; + const tool = raw.trim(); + if (!tool) continue; + out.add(tool); + for (const group of HOSTED_TOOL_ALIAS_GROUPS) { + if (!group.has(tool)) continue; + for (const alias of group) out.add(alias); + } + } + return out.size > 0 ? out : NO_DECLARED_HOSTED_TOOLS; +} + +/** + * True when forwarding this hosted tool to the model would be rejected upstream. + * + * `declaredUnsupported` is the provider's own capability declaration, expanded by + * `declaredUnsupportedHostedTools`. It is additive to the built-in table rather than a + * replacement for it: the table covers destinations that reject a tool regardless of how + * the operator configured them, so an operator who never heard of the field stays + * protected. + */ +export function isHostedToolUnsupportedForModel( + modelId: string, + tool: string, + baseUrl?: string, + declaredUnsupported?: ReadonlySet, +): boolean { + if (declaredUnsupported?.has(tool)) return true; return UNSUPPORTED_HOSTED_TOOLS.some(entry => entry.match(modelId, baseUrl) && entry.tools.has(tool)); } diff --git a/src/responses/schema.ts b/src/responses/schema.ts index 5f0cf4c2d7..fced1a9e6e 100644 --- a/src/responses/schema.ts +++ b/src/responses/schema.ts @@ -126,10 +126,17 @@ export const toolSchema = z.object({ const builtinToolSchema = z.object({ type: z.string() }).loose(); -const hostedToolType = z.enum([ +/** + * Hosted tool types a client may declare on an inbound Responses request. Exported so the + * provider-side capability vocabulary in `src/responses/hosted-tool-policy.ts` can be + * asserted to cover all of them: a gateway must be able to deny anything it can be sent. + */ +export const HOSTED_TOOL_TYPES = [ "web_search", "web_search_preview", "file_search", "computer_use_preview", "code_interpreter", "image_generation", "mcp", -]); +] as const; + +const hostedToolType = z.enum(HOSTED_TOOL_TYPES); const allowedToolEntrySchema = z.object({ type: z.string(), name: z.string().optional() }); diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index ddc87f948e..58ee12e50c 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -30,6 +30,7 @@ import { } from "../config/provider-validation"; import { providerDestinationConfigError } from "../lib/destination-policy"; import { redactSecretString } from "../lib/redact"; +import { DECLARABLE_HOSTED_TOOL_TYPES } from "../responses/hosted-tool-policy"; import { effectiveGoogleMode, getProviderRegistryEntry, providerCodexAccountMode, providerMatchesRegistryTransport, registryEntryForProviderDestination } from "../providers/registry"; import { providerConfigSeed } from "../providers/derive"; import type { OcxConfig, OcxProviderConfig } from "../types"; @@ -839,6 +840,22 @@ export function providerManagementConfigError( "omitReasoningEffortWithToolsModels", ); if (toolReasoningOptOutError) return `provider ${name} ${toolReasoningOptOutError}`; + const unsupportedHostedToolsError = nonBlankStringArrayConfigError( + raw.unsupportedHostedTools, + "unsupportedHostedTools", + ); + if (unsupportedHostedToolsError) return `provider ${name} ${unsupportedHostedToolsError}`; + if (Array.isArray(raw.unsupportedHostedTools)) { + // Closed vocabulary, same reason as the config schema: an unrecognized name would be + // stored and then strip nothing, so the operator would keep getting the upstream 400 + // this field exists to prevent. + const unknownTool = (raw.unsupportedHostedTools as unknown[]) + .find(tool => typeof tool === "string" && !DECLARABLE_HOSTED_TOOL_TYPES.has(tool.trim())); + if (unknownTool !== undefined) { + return `provider ${name} unsupportedHostedTools must name only hosted tool types: ` + + `${[...DECLARABLE_HOSTED_TOOL_TYPES].join(", ")}`; + } + } const openRouterError = openRouterRoutingConfigError(typed); if (openRouterError) return `provider ${name} ${openRouterError}`; const vercelError = vercelGatewayRoutingConfigError(typed); @@ -984,6 +1001,7 @@ const PROVIDER_CONFIG_FIELD_POLICY = { xaiResponsesDefaultVersion: "runtime", zaiResponsesDefaultVersion: "runtime", supportsResponsesCustomTools: "editor", + unsupportedHostedTools: "editor", responsesSnapshotRepair: "editor", webSearchBridge: "editor", reasoningEffortMap: "editor", diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index da93b0f900..ed85c534ae 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -1,4 +1,5 @@ import { modelCapabilitiesConfigError, mergeModelCapabilities } from "../../config/provider-validation"; +import { DECLARABLE_HOSTED_TOOL_TYPES } from "../../responses/hosted-tool-policy"; import { randomUUID } from "node:crypto"; import { readFileSync } from "node:fs"; import { isDeepStrictEqual } from "node:util"; @@ -672,6 +673,26 @@ function applyProviderPatchFields( } touched = true; } + if (Object.hasOwn(rawBody, "unsupportedHostedTools")) { + const value = rawBody.unsupportedHostedTools; + if (value === null) { + delete next.unsupportedHostedTools; + } else { + const error = nonBlankStringArrayConfigError(value, "unsupportedHostedTools"); + if (error) return { error }; + const tools = normalizeNonBlankStringArray(value as string[]); + const unknownTool = tools.find(tool => !DECLARABLE_HOSTED_TOOL_TYPES.has(tool)); + if (unknownTool !== undefined) { + return { + error: `unsupportedHostedTools must name only hosted tool types: ` + + `${[...DECLARABLE_HOSTED_TOOL_TYPES].join(", ")}`, + }; + } + if (tools.length > 0) next.unsupportedHostedTools = tools; + else delete next.unsupportedHostedTools; + } + touched = true; + } if (Object.hasOwn(rawBody, "retainModels")) { const value = rawBody.retainModels; if (value === null) { @@ -873,6 +894,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise Decision record: [ADR-0052](../decisions/ADR-0052-reasoning-and-tool-result-compatibility.md) +## Declared hosted-tool denials + +A gateway that speaks the Responses API does not necessarily accept everything OpenAI accepts. +`unsupportedHostedTools` is how such a destination says so: it names the hosted tool declarations +this provider rejects, and `stripUnsupportedHostedTools` in +`src/adapters/openai-responses/tool-schema.ts` removes them from `tools`, from client-loaded +`additional_tools`, and from `tool_choice` before the body is serialized. + +The capability is provider-declared rather than destination-matched, and that is the point. The +original mechanism in `src/responses/hosted-tool-policy.ts` was a table of `(model, baseUrl)` +predicates, so a narrower gateway could only be supported by shipping a proxy release naming its +endpoint. The reported destination (#5002) accepted plain Responses requests and `function` tools +but rejected hosted `web_search` with HTTP 400 `unsupported_request`, which meant a text-only +prompt failed before the model answered, because Codex's hosted declaration travelled with it. A +provider nobody has classified can now describe itself in config. + +The declaration is additive to that table, not a replacement for it. The table still covers +destinations that reject a tool regardless of configuration, so an operator who never heard of the +field stays protected; a declaration can only deny more, never re-enable a known-broken pairing. + +Two properties are deliberate. Spelling variants of one capability are aliased, so declaring +`web_search` also denies `web_search_preview` — the rest of the proxy already folds that pair into +a single tool, and honouring only the spelling the operator happened to write would reproduce the +original 400 while the config claimed to have prevented it. And the value is validated against a +closed vocabulary in `src/config/schema/leaf-validators.ts` and `src/server/auth-cors.ts`, because +the provider schema ends in `.passthrough()`: an unvalidated misspelling would be persisted and +then match no tool, leaving the operator with the upstream rejection this field exists to prevent +and nothing explaining why. That is the `codexToolMode` lesson from #2106. + +This capability is independent of `supportsResponsesCustomTools`, which denies native `custom` +tools and `custom_tool_call` items. A gateway that rejects both sets both; neither implies the +other. + ## OpenRouter provider routing The canonical OpenRouter `openai-chat` transport may carry optional provider-routing preferences diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 3f8b448cf9..fe5d375aae 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1035,6 +1035,7 @@ "responses-custom-tool-guidance.test.ts": "responses", "responses-custom-tool-repair.test.ts": "responses", "responses-fetch-helpers-boundary.test.ts": "responses", + "responses-hosted-tool-declaration.test.ts": "responses", "responses-field-backfill.test.ts": "responses", "responses-forward-dangling-call.test.ts": "responses", "responses-forward-incomplete-quota.test.ts": "responses", diff --git a/tests/responses/responses-hosted-tool-declaration.test.ts b/tests/responses/responses-hosted-tool-declaration.test.ts new file mode 100644 index 0000000000..f44d0c1527 --- /dev/null +++ b/tests/responses/responses-hosted-tool-declaration.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, test } from "bun:test"; +import { createResponsesPassthroughAdapter as createResponsesPassthroughAdapterProduction } from "../../src/adapters/openai-responses"; +import { stripUnsupportedHostedTools } from "../../src/adapters/openai-responses/tool-schema"; +import { + DECLARABLE_HOSTED_TOOL_TYPES, + declaredUnsupportedHostedTools, + isHostedToolUnsupportedForModel, +} from "../../src/responses/hosted-tool-policy"; +import { HOSTED_TOOL_TYPES } from "../../src/responses/schema"; +import { modelPreferHostedToolsConfigError } from "../../src/config"; +import { providerManagementConfigError } from "../../src/server/auth-cors"; +import type { OcxProviderConfig } from "../../src/types"; +import { withTestTranslatorBudget } from "../helpers/translator-budget"; + +/** + * #5002. A gateway that speaks the Responses API but accepts a narrower capability set + * than OpenAI used to be unrepresentable: it could only be handled by adding a hard-coded + * baseUrl rule to src/responses/hosted-tool-policy.ts. The reported destination accepts + * plain Responses requests and function tools but rejects hosted web_search with HTTP 400 + * unsupported_request, so even "Reply exactly with OK" failed before the model answered, + * because the hosted declaration travelled with the text-only prompt. + * + * These cases live in their own file rather than in + * tests/responses/openai-responses-passthrough.test.ts: that file is exactly at its + * file-size ratchet cap (4,809 lines in tests/fixtures/file-size-baseline.json), and the + * cap only ever moves downward, so appending there would fail the ratchet for every later + * pull request. + */ + +const createResponsesPassthroughAdapter = ( + ...args: Parameters +) => withTestTranslatorBudget(createResponsesPassthroughAdapterProduction(...args)); + +const GATEWAY_BASE_URL = "https://gateway.example/v1"; +const GATEWAY_MODEL = "gateway-mini"; + +/** A user-defined OpenAI-compatible Responses gateway; deliberately not a registry row. */ +function gateway(overrides: Partial = {}): OcxProviderConfig { + return { + adapter: "openai-responses", + baseUrl: GATEWAY_BASE_URL, + apiKey: "test-key", + models: [GATEWAY_MODEL], + ...overrides, + } as OcxProviderConfig; +} + +const functionTool = { + type: "function", + name: "noop", + description: "Do nothing", + parameters: { type: "object", properties: {}, additionalProperties: false }, +}; + +function build(rawBody: Record, provider: OcxProviderConfig): Record { + const request = createResponsesPassthroughAdapter(provider).buildRequest({ + modelId: GATEWAY_MODEL, + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: { model: GATEWAY_MODEL, input: "Reply exactly with OK.", ...rawBody }, + }, { headers: new Headers() }); + return JSON.parse(request.body) as Record; +} + +/** + * The declaration reaches the wire. Two adapter-level cases only, because the rest of the + * passthrough chain is not what changed; the body shapes are covered directly against + * stripUnsupportedHostedTools below. + */ +describe("a declared hosted-tool denial reaches the serialized request", () => { + test("a gateway can deny hosted web_search while keeping function tools", () => { + const body = build( + { tools: [{ type: "web_search" }, functionTool] }, + gateway({ unsupportedHostedTools: ["web_search"] }), + ); + + expect(body.tools).toEqual([functionTool]); + }); + + test("an undeclared gateway still forwards hosted web_search", () => { + // The declaration is the only thing that changes behaviour here: without it this + // destination is an unclassified gateway and the hosted tool must pass through, or the + // fix would silently break every gateway that does support web search. + const body = build({ tools: [{ type: "web_search" }, functionTool] }, gateway()); + + expect(body.tools).toEqual([{ type: "web_search" }, functionTool]); + }); +}); + +describe("provider-declared unsupported hosted tools", () => { + const strip = (body: Record, declared?: string[]): Record => + stripUnsupportedHostedTools( + { model: GATEWAY_MODEL, ...body }, + gateway(declared ? { unsupportedHostedTools: declared } : {}), + ) as Record; + + test("declaring one spelling of the web-search capability denies both", () => { + expect(strip({ tools: [{ type: "web_search_preview" }, functionTool] }, ["web_search"]).tools) + .toEqual([functionTool]); + expect(strip({ tools: [{ type: "web_search" }, functionTool] }, ["web_search_preview"]).tools) + .toEqual([functionTool]); + expect(declaredUnsupportedHostedTools({ unsupportedHostedTools: ["web_search_preview"] })) + .toEqual(new Set(["web_search", "web_search_preview"])); + }); + + test("a denied hosted tool is removed from tool_choice rather than left dangling", () => { + expect(strip({ tools: [{ type: "web_search" }], tool_choice: { type: "web_search" } }, ["web_search"]).tool_choice) + .toBe("none"); + expect(strip({ + tools: [{ type: "web_search" }, functionTool], + tool_choice: { + type: "allowed_tools", + mode: "auto", + tools: [{ type: "web_search" }, { type: "function", name: "noop" }], + }, + }, ["web_search"]).tool_choice).toEqual({ + type: "allowed_tools", + mode: "auto", + tools: [{ type: "function", name: "noop" }], + }); + }); + + test("the declaration also filters client-loaded additional_tools", () => { + expect(strip({ + input: [{ type: "additional_tools", tools: [{ type: "web_search" }, functionTool] }], + }, ["web_search"]).input).toEqual([{ type: "additional_tools", tools: [functionTool] }]); + }); + + test("the declaration is additive to the built-in destination table, not a replacement", () => { + // An operator who declares only image_generation must still be protected from the + // known-broken grok-4.6 destination, and a declaration must not disable that table. + const declaredImageOnly = declaredUnsupportedHostedTools({ unsupportedHostedTools: ["image_generation"] }); + + expect(isHostedToolUnsupportedForModel( + "grok-4.6", + "web_search", + "https://opencode.ai/zen/go/v1", + declaredImageOnly, + )).toBe(true); + expect(isHostedToolUnsupportedForModel("grok-4.6", "web_search", GATEWAY_BASE_URL, declaredImageOnly)) + .toBe(false); + expect(isHostedToolUnsupportedForModel(GATEWAY_MODEL, "image_gen", GATEWAY_BASE_URL, declaredImageOnly)) + .toBe(true); + }); + + test("no declaration allocates no set and strips nothing", () => { + expect(declaredUnsupportedHostedTools(undefined).size).toBe(0); + expect(declaredUnsupportedHostedTools({}).size).toBe(0); + expect(declaredUnsupportedHostedTools({ unsupportedHostedTools: [] }).size).toBe(0); + expect(declaredUnsupportedHostedTools({ unsupportedHostedTools: [" "] }).size).toBe(0); + + const body = { model: GATEWAY_MODEL, tools: [{ type: "web_search" }] }; + expect(stripUnsupportedHostedTools(body, gateway())).toBe(body); + }); + + test("a gateway can deny anything a client is able to declare", () => { + // The declaration vocabulary must cover the inbound hosted tool schema, otherwise a + // client could send a tool the destination rejects and the operator would have no way + // to say so. + for (const tool of HOSTED_TOOL_TYPES) { + expect(DECLARABLE_HOSTED_TOOL_TYPES.has(tool)).toBe(true); + } + }); +}); + +describe("unsupportedHostedTools configuration", () => { + test("an accepted declaration names only hosted tool types", () => { + expect(providerManagementConfigError("relay", { + ...gateway(), + unsupportedHostedTools: ["web_search", "image_generation"], + })).toBeNull(); + }); + + test("a misspelled hosted tool is rejected instead of silently stripping nothing", () => { + // The provider schema ends in .passthrough(), so an unvalidated "web_serch" would be + // stored and then match no tool: the operator would keep receiving the upstream 400 + // this field exists to prevent, with nothing explaining why (#2106). + const error = providerManagementConfigError("relay", { + ...gateway(), + unsupportedHostedTools: ["web_serch"], + }); + + expect(error).toContain("unsupportedHostedTools"); + expect(error).toContain("web_search"); + }); + + test("a provider cannot both deny and prefer the same hosted tool", () => { + const error = modelPreferHostedToolsConfigError( + { [GATEWAY_MODEL]: ["image_generation"] }, + "modelPreferHostedTools", + "relay", + { ...gateway(), unsupportedHostedTools: ["image_generation"] }, + ); + + expect(error).toContain("cannot prefer image_generation"); + expect(error).toContain("unsupportedHostedTools"); + }); +});