diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index 297f3a980da..d46bd5d80a0 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -2116,6 +2116,11 @@ const MUSE_SPARK_WEB_SEARCH_STRICT_MODELS = new Set([ "muse-spark-1.2-contributor", ]); +const MUSE_SPARK_WEB_SEARCH_STRICT_RESPONSE_URLS = new Set([ + "https://opencode.ai/zen/v1/responses", + "https://opencode.ai/zen/go/v1/responses", +]); + const MUSE_SPARK_UNSUPPORTED_WEB_SEARCH_FIELDS = [ "search_content_types", "indexed_web_access", @@ -2124,13 +2129,28 @@ const MUSE_SPARK_UNSUPPORTED_WEB_SEARCH_FIELDS = [ /** * OpenCode Zen / Go Muse Spark Responses gateway refuses a short list of Codex * `web_search` fields. `web_search_preview` keeps its accepted shape, and Luna - * remains untouched. Keep the rejected names together so a newly identified field - * is a one-line compatibility update rather than another bespoke rewrite. + * 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. */ -function stripMuseSparkUnsupportedWebSearchFields(body: unknown, modelId: unknown): unknown { +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; @@ -2409,7 +2429,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): if (provider.supportsOpenAiWebSearchToolFields === false) { outBody = stripOpenAiOnlyWebSearchFields(outBody); } - outBody = stripMuseSparkUnsupportedWebSearchFields(outBody, parsed.modelId); + outBody = stripMuseSparkUnsupportedWebSearchFields(outBody, parsed.modelId, url); // Last, so promoted namespace children are also cleared of Codex-private fields. outBody = stripCanonicalOnlyToolFields(outBody, provider.supportsOpenAiWebSearchToolFields === false); } diff --git a/tests/muse-spark-web-search-compat.test.ts b/tests/muse-spark-web-search-compat.test.ts index 08bb3a74fac..04e0f56a73c 100644 --- a/tests/muse-spark-web-search-compat.test.ts +++ b/tests/muse-spark-web-search-compat.test.ts @@ -7,12 +7,34 @@ import { withTestTranslatorBudget } from "./helpers/translator-budget"; const createResponsesPassthroughAdapter = (...args: Parameters) => withTestTranslatorBudget(createResponsesPassthroughAdapterProduction(...args)); -const PROVIDER = { +const ZEN_PROVIDER = { adapter: "openai-responses", baseUrl: "https://opencode.ai/zen/v1", apiKey: "test-key", } as unknown as OcxProviderConfig; +const ZEN_GO_PROVIDER = { + ...ZEN_PROVIDER, + baseUrl: "https://opencode.ai/zen/go/v1", +}; + +const ZEN_PATH_PROVIDER = { + ...ZEN_PROVIDER, + baseUrl: "https://opencode.ai", + responsesPath: "/zen/v1/responses", +}; + +const ZEN_GO_PATH_PROVIDER = { + ...ZEN_PROVIDER, + baseUrl: "https://opencode.ai", + responsesPath: "/zen/go/v1/responses", +}; + +const META_PROVIDER = { + ...ZEN_PROVIDER, + baseUrl: "https://api.meta.ai/v1", +}; + /** A Codex web_search declaration exactly as `hosted_spec.rs` emits it for TextAndImage. */ function webSearchTool(): Record { return { @@ -23,8 +45,13 @@ function webSearchTool(): Record { }; } -function build(modelId: string, rawBody: Record): Record { - const request = createResponsesPassthroughAdapter(PROVIDER).buildRequest({ +/** Build one passthrough request for an explicit Responses provider fixture. */ +function buildForProvider( + provider: OcxProviderConfig, + modelId: string, + rawBody: Record, +): Record { + const request = createResponsesPassthroughAdapter(provider).buildRequest({ modelId, context: { messages: [] }, stream: true, @@ -34,6 +61,11 @@ function build(modelId: string, rawBody: Record): Record; } +/** Build with the default OpenCode Zen fixture used by the original regressions. */ +function build(modelId: string, rawBody: Record): Record { + return buildForProvider(ZEN_PROVIDER, modelId, rawBody); +} + const toolsOf = (body: Record) => body.tools as Array>; /** @@ -125,4 +157,38 @@ describe("#2617/#3378 Muse Spark web_search compatibility", () => { expect(Object.hasOwn(nested, "search_content_types")).toBe(false); expect(Object.hasOwn(nested, "indexed_web_access")).toBe(false); }); + + test("OpenCode Go applies the same Muse compatibility guard", () => { + const body = buildForProvider(ZEN_GO_PROVIDER, "muse-spark-1.3-contributor", { + tools: [webSearchTool()], + }); + const tool = toolsOf(body)[0]!; + expect(Object.hasOwn(tool, "search_content_types")).toBe(false); + expect(Object.hasOwn(tool, "indexed_web_access")).toBe(false); + }); + + test("split baseUrl and responsesPath configurations derive both strict destinations", () => { + for (const provider of [ZEN_PATH_PROVIDER, ZEN_GO_PATH_PROVIDER]) { + const body = buildForProvider(provider, "muse-spark-1.3-contributor", { + tools: [webSearchTool()], + }); + const tool = toolsOf(body)[0]!; + expect(Object.hasOwn(tool, "search_content_types")).toBe(false); + expect(Object.hasOwn(tool, "indexed_web_access")).toBe(false); + } + }); + + test("direct Meta preserves its web_search fields at both tool positions", () => { + const body = buildForProvider(META_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.search_content_types).toEqual(["text", "image"]); + expect(declaration.indexed_web_access).toBe(true); + } + }); });