Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 13 additions & 22 deletions src/adapters/openai-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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;
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion structure/transports/responses.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
44 changes: 42 additions & 2 deletions tests/providers/muse-spark-web-search-compat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> {
return {
Expand Down Expand Up @@ -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()],
Expand Down Expand Up @@ -258,12 +264,46 @@ 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()],
});
const tool = toolsOf(body)[0]!;
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<Record<string, unknown>>)[0]!;
const nested = (item.tools as Array<Record<string, unknown>>)[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);
});
});
Loading