diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index 6a07b84851..6046c540fa 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -1993,6 +1993,13 @@ export function stripOpenAiOnlyWebSearchFields(body: unknown): unknown { * same-shaped successor to 1.2 on the same Zen wire, and an equality check would * have let a Codex-emitted `web_search` body reach the * gateway and come back 400 for every request the moment 1.3 was selected. + * + * Keyed on the id alone, never on the send URL: the rejection travels with the model. + * These ids exist only on the Zen/Go and direct-Meta gateways, every one of which refuses + * the fields, so a relay of the same gateway needs the same sanitized body. The exact-URL + * allowlist that used to sit beside this set did the opposite — a Console Go reseller host + * (2026-09-14) served this model, kept `search_content_types`, and 400ed every turn that + * attached Codex's default `web_search` declaration. */ const MUSE_SPARK_WEB_SEARCH_STRICT_MODELS = new Set([ "muse-spark-1.3-contributor", @@ -2001,12 +2008,6 @@ const MUSE_SPARK_WEB_SEARCH_STRICT_MODELS = new Set([ "muse-spark-1.2-contributor-free", ]); -const MUSE_SPARK_WEB_SEARCH_STRICT_RESPONSE_URLS = new Set([ - "https://opencode.ai/zen/v1/responses", - "https://opencode.ai/zen/go/v1/responses", - "https://api.meta.ai/v1/responses", -]); - const MUSE_SPARK_UNSUPPORTED_WEB_SEARCH_FIELDS = [ "search_content_types", "indexed_web_access", @@ -2015,29 +2016,19 @@ const MUSE_SPARK_UNSUPPORTED_WEB_SEARCH_FIELDS = [ /** * OpenCode Zen / Go and the direct Meta Muse Spark Responses gateways refuse a * short list of Codex `web_search` fields. `web_search_preview` keeps its accepted - * shape, and Luna remains untouched. Match the exact effective request URL; - * malformed, credentialed, or parameterized destinations keep their original body - * instead of assuming this gateway contract. Keep the rejected names together so a - * newly identified field is a one-line compatibility update rather than another - * bespoke rewrite. + * shape, and Luna remains untouched. Only the model id decides: the ids above name one + * gateway family wherever it is reached from, and the canonical OpenAI forward path — the + * one destination that documents the field — never calls this transform. Keep the rejected + * names together so a newly identified field is a one-line compatibility update rather than + * another bespoke rewrite. */ function stripMuseSparkUnsupportedWebSearchFields( body: unknown, modelId: unknown, - responseUrl: string, ): unknown { if (!isPlainObject(body)) return body; if (typeof modelId !== "string") return body; if (!MUSE_SPARK_WEB_SEARCH_STRICT_MODELS.has(modelId.trim().toLowerCase())) return body; - let destination: string; - try { - const url = new URL(responseUrl); - if (url.username || url.password || url.search || url.hash) return body; - destination = `${url.origin.toLowerCase()}${url.pathname.replace(/\/+$/, "")}`; - } catch { - return body; - } - if (!MUSE_SPARK_WEB_SEARCH_STRICT_RESPONSE_URLS.has(destination)) return body; const rewriteTools = (tools: unknown[]): { tools: unknown[]; changed: boolean } => { let changed = false; @@ -2339,7 +2330,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): if (provider.supportsOpenAiWebSearchToolFields === false) { outBody = stripOpenAiOnlyWebSearchFields(outBody); } - outBody = stripMuseSparkUnsupportedWebSearchFields(outBody, parsed.modelId, url); + outBody = stripMuseSparkUnsupportedWebSearchFields(outBody, parsed.modelId); // Host-only: api.meta.ai rejects function names over 64 chars on every Muse model, // including default muse-spark-1.3. Do not reuse the contributor/Zen web_search // predicates. Namespace flattening has already produced the public wire names. diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 1bc2b4de0d..0a4eb8b5cc 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -338,7 +338,7 @@ operator-supplied header wins case-insensitively. Renamed providers are covered fixed key-auth destination still matches the registry; custom and lookalike URLs receive nothing. Muse Spark's Responses sanitizer also drops the provider-rejected `search_content_types` and `indexed_web_access` fields from plain `web_search` tools while preserving preview tools and -unrelated models. +unrelated models; it keys on those exact model ids, so a relay of the same gateway is covered too. Direct Meta Muse / Meta Model Responses (`https://api.meta.ai/v1`) also rejects function tool names longer than 64 characters or containing characters outside `[a-zA-Z0-9_-]`. After namespace diff --git a/tests/providers/muse-spark-web-search-compat.test.ts b/tests/providers/muse-spark-web-search-compat.test.ts index 5254c7163d..1627fcae42 100644 --- a/tests/providers/muse-spark-web-search-compat.test.ts +++ b/tests/providers/muse-spark-web-search-compat.test.ts @@ -41,6 +41,12 @@ const META_PATH_PROVIDER = { responsesPath: "/v1/responses", }; +/** A third-party relay of the same Console Go gateway: its own origin, the same validator. */ +const RELAY_PROVIDER = { + ...ZEN_PROVIDER, + baseUrl: "https://relay.example/v1", +}; + /** A Codex web_search declaration exactly as `hosted_spec.rs` emits it for TextAndImage. */ function webSearchTool(): Record { return { @@ -214,7 +220,7 @@ describe("#2617/#3378 Muse Spark web_search compatibility", () => { expect(Object.hasOwn(tool, "indexed_web_access")).toBe(false); }); - test("split baseUrl and responsesPath configurations derive both strict destinations", () => { + test("split baseUrl and responsesPath configurations reach the guard too", () => { for (const provider of [ZEN_PATH_PROVIDER, ZEN_GO_PATH_PROVIDER]) { const body = buildForProvider(provider, "muse-spark-1.3-contributor", { tools: [webSearchTool()], @@ -258,7 +264,7 @@ describe("#2617/#3378 Muse Spark web_search compatibility", () => { expect(tool.indexed_web_access).toBe(true); }); - test("split Meta baseUrl and responsesPath derives the same strict destination", () => { + test("split Meta baseUrl and responsesPath reaches the guard too", () => { const body = buildForProvider(META_PATH_PROVIDER, "muse-spark-1.3-contributor", { tools: [webSearchTool()], }); @@ -266,4 +272,38 @@ describe("#2617/#3378 Muse Spark web_search compatibility", () => { expect(Object.hasOwn(tool, "search_content_types")).toBe(false); expect(Object.hasOwn(tool, "indexed_web_access")).toBe(false); }); + + /** + * The rejection belongs to the model, not to the host. An exact-URL allowlist made the + * guard blind to a relay that fronted the same gateway from its own origin: on 2026-09-14 + * every Console Go reseller turn 400ed with "`tools[].search_content_types` is only + * supported for web_search_preview tools" while the same model and body worked on + * opencode.ai. Keying on the model id covers the relay without touching preview tools or + * unrelated models. + */ + test("a relay host of the same gateway gets the same sanitization", () => { + const body = buildForProvider(RELAY_PROVIDER, "muse-spark-1.3-contributor", { + tools: [webSearchTool()], + input: [{ type: "additional_tools", tools: [webSearchTool()] }], + }); + const tool = toolsOf(body)[0]!; + const item = (body.input as Array>)[0]!; + const nested = (item.tools as Array>)[0]!; + for (const declaration of [tool, nested]) { + expect(declaration.type).toBe("web_search"); + expect(declaration.search_context_size).toBe("medium"); + expect(Object.hasOwn(declaration, "search_content_types")).toBe(false); + expect(Object.hasOwn(declaration, "indexed_web_access")).toBe(false); + } + }); + + test("a relay host keeps the field on web_search_preview and for unrelated models", () => { + const preview = buildForProvider(RELAY_PROVIDER, "muse-spark-1.3-contributor", { + tools: [{ ...webSearchTool(), type: "web_search_preview" }], + }); + expect(toolsOf(preview)[0]!.search_content_types).toEqual(["text", "image"]); + const unrelated = buildForProvider(RELAY_PROVIDER, "gpt-5.6-luna", { tools: [webSearchTool()] }); + expect(toolsOf(unrelated)[0]!.search_content_types).toEqual(["text", "image"]); + expect(toolsOf(unrelated)[0]!.indexed_web_access).toBe(true); + }); });