From 1c4c9946bd95419f31a1aed68c106d99fe65ae5d Mon Sep 17 00:00:00 2001 From: Gustavo Date: Wed, 2 Sep 2026 20:23:10 -0300 Subject: [PATCH 1/5] fix(opencode-go): route muse-spark-1.3-contributor over Responses with Zen Go tool-surface guards --- src/adapters/openai-responses.ts | 132 ++++++++++++++++++++- src/providers/registry.ts | 22 +++- tests/muse-spark-web-search-compat.test.ts | 87 ++++++++++++++ 3 files changed, 239 insertions(+), 2 deletions(-) diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index 781bbef414..37305804dc 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -24,6 +24,7 @@ import { injectXaiResponsesXSearch, normalizeXaiResponsesWebSearch } from "./xai import { EMPTY_TOOL_OUTPUT_ANNOTATION, isWhitespaceOnlyTextPartArray } from "./empty-tool-output-annotation"; import { isXaiSchemaTarget, + lookupLocalJsonPointer, normalizeXaiToolParameters, XaiToolSchemaCompatibilityError, } from "./xai-tool-schema"; @@ -1974,9 +1975,22 @@ export function stripOpenAiOnlyWebSearchFields(body: unknown): unknown { * shape, so this is Muse-only. Drop only the field the gateway refuses while * keeping the tool type and every other accepted option intact. */ +/** + * Muse Spark models served over Responses on Zen Go share the same gateway + * restrictions (probed 2026-08-26 for 1.2, 2026-09-02 for 1.3): plain + * `web_search` must not carry `search_content_types`, tool names are capped at + * 64 chars, and parameter schemas must not be recursive. The predicate matches + * the bare model id with or without a `provider/` namespace prefix. + */ +function isMuseSparkGatewayModel(modelId: unknown): boolean { + const normalized = typeof modelId === "string" ? modelId.trim().toLowerCase() : ""; + const base = normalized.includes("/") ? normalized.split("/").pop() ?? normalized : normalized; + return base === "muse-spark-1.2-contributor" || base === "muse-spark-1.3-contributor"; +} + function stripMuseSparkUnsupportedWebSearchFields(body: unknown, modelId: unknown): unknown { if (!isPlainObject(body)) return body; - if (typeof modelId !== "string" || modelId.trim().toLowerCase() !== "muse-spark-1.2-contributor") return body; + if (!isMuseSparkGatewayModel(modelId)) return body; const rewriteTools = (tools: unknown[]): { tools: unknown[]; changed: boolean } => { let changed = false; @@ -2016,6 +2030,120 @@ function stripMuseSparkUnsupportedWebSearchFields(body: unknown, modelId: unknow return changed ? next : body; } +/** + * Zen Go rejects function/custom tool names longer than 64 chars + * (`name must be at most 64 characters`), while Codex attaches MCP tools such + * as `mcp__codex_apps__codex_document_control___get_document_tool_schemas` + * (67 chars). Dropping only the over-long declarations (top-level and + * additional_tools) lets the turn proceed with the remaining catalog; the + * model simply cannot be offered those few tools. A tool_choice naming a + * dropped tool falls back to auto to avoid a second 400. + */ +function dropMuseSparkOverlongToolNames(body: unknown, modelId: unknown): unknown { + if (!isPlainObject(body)) return body; + if (!isMuseSparkGatewayModel(modelId)) return body; + const dropped = new Set(); + const filterTools = (tools: unknown[]): { tools: unknown[]; changed: boolean } => { + let changed = false; + const kept = tools.filter(tool => { + if (!isPlainObject(tool)) return true; + if (tool.type !== "function" && tool.type !== "custom") return true; + if (typeof tool.name !== "string" || tool.name.length <= 64) return true; + dropped.add(tool.name); + changed = true; + return false; + }); + return { tools: changed ? kept : tools, changed }; + }; + let next: Record = body; + if (Array.isArray(body.tools)) { + const rewritten = filterTools(body.tools); + if (rewritten.changed) next = { ...next, tools: rewritten.tools }; + } + if (Array.isArray(next.input)) { + const input = next.input.map(item => { + if (!isPlainObject(item) || item.type !== "additional_tools" || !Array.isArray(item.tools)) return item; + const rewritten = filterTools(item.tools); + return rewritten.changed ? { ...item, tools: rewritten.tools } : item; + }); + if (input.some((item, index) => item !== (next.input as unknown[])[index])) next = { ...next, input }; + } + if (dropped.size > 0) { + // eslint-disable-next-line no-console + console.warn(`[opencodex] muse-spark: dropped ${dropped.size} tool(s) with names >64 chars rejected by Zen Go`); + if (isPlainObject(next.tool_choice) && typeof next.tool_choice.name === "string" && dropped.has(next.tool_choice.name)) { + next = { ...next, tool_choice: "auto" }; + } + } + return next === body ? body : next; +} + +/** + * Zen Go rejects recursive JSON schemas (`Recursive JSON schemas are not + * currently supported`), which some MCP tools carry via cyclic local `$ref`s. + * Structural identity cycles cannot reach this point (JSON serialization would + * have thrown first), so only the `$ref` graph is checked, reusing the tested + * lookupLocalJsonPointer helper. Tools with cyclic schemas are dropped for + * Muse Spark models only; siblings sharing one `$defs` entry (diamonds) are + * kept. A tool_choice naming a dropped tool falls back to auto. + */ +function schemaRefGraphHasCycle(parameters: unknown): boolean { + if (!isPlainObject(parameters)) return false; + const root: Record = parameters; + const visit = (node: unknown, stack: string[]): boolean => { + if (Array.isArray(node)) return node.some(child => visit(child, stack)); + if (!isPlainObject(node)) return false; + if (typeof node.$ref === "string") { + if (stack.includes(node.$ref)) return true; + // Remote or unresolvable refs cannot be judged locally; leave them alone. + if (!node.$ref.startsWith("#/") && node.$ref !== "#" && node.$ref !== "#/") return false; + const target = lookupLocalJsonPointer(root, node.$ref); + if (target === undefined) return false; + return visit(target, [...stack, node.$ref]); + } + return Object.values(node).some(child => visit(child, stack)); + }; + return visit(root, []); +} + +function dropMuseSparkRecursiveSchemaTools(body: unknown, modelId: unknown): unknown { + if (!isPlainObject(body)) return body; + if (!isMuseSparkGatewayModel(modelId)) return body; + const dropped = new Set(); + const filterTools = (tools: unknown[]): { tools: unknown[]; changed: boolean } => { + let changed = false; + const kept = tools.filter(tool => { + if (!isPlainObject(tool) || tool.type !== "function") return true; + if (!isPlainObject(tool.parameters) || !schemaRefGraphHasCycle(tool.parameters)) return true; + if (typeof tool.name === "string") dropped.add(tool.name); + changed = true; + return false; + }); + return { tools: changed ? kept : tools, changed }; + }; + let next: Record = body; + if (Array.isArray(body.tools)) { + const rewritten = filterTools(body.tools); + if (rewritten.changed) next = { ...next, tools: rewritten.tools }; + } + if (Array.isArray(next.input)) { + const input = next.input.map(item => { + if (!isPlainObject(item) || item.type !== "additional_tools" || !Array.isArray(item.tools)) return item; + const rewritten = filterTools(item.tools); + return rewritten.changed ? { ...item, tools: rewritten.tools } : item; + }); + if (input.some((item, index) => item !== (next.input as unknown[])[index])) next = { ...next, input }; + } + if (dropped.size > 0) { + // eslint-disable-next-line no-console + console.warn(`[opencodex] muse-spark: dropped ${dropped.size} tool(s) with recursive schemas rejected by Zen Go: ${[...dropped].join(", ")}`); + if (isPlainObject(next.tool_choice) && typeof next.tool_choice.name === "string" && dropped.has(next.tool_choice.name)) { + next = { ...next, tool_choice: "auto" }; + } + } + return next === body ? body : next; +} + /** Replace every `input_image` part under a routed-compaction body with a short marker. */ function stripInputImagesDeep(value: unknown): unknown { if (Array.isArray(value)) return value.map(stripInputImagesDeep); @@ -2238,6 +2366,8 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): outBody = stripOpenAiOnlyWebSearchFields(outBody); } outBody = stripMuseSparkUnsupportedWebSearchFields(outBody, parsed.modelId); + outBody = dropMuseSparkOverlongToolNames(outBody, parsed.modelId); + outBody = dropMuseSparkRecursiveSchemaTools(outBody, parsed.modelId); // Last, so promoted namespace children are also cleared of Codex-private fields. outBody = stripCanonicalOnlyToolFields(outBody, provider.supportsOpenAiWebSearchToolFields === false); } diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 1b9ddf4d5b..1727705dc1 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -1487,7 +1487,13 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ - 다른 대안 대신 이 방식을 선택한 이유: OpenCode Go documents sibling models on Chat or Anthropic endpoints, and an exact registry default preserves both those routes and explicit opt-out precedence. - 장점, 단점 및 영향: Each listed model reaches `/responses` from every inbound surface without changing siblings; a future upstream endpoint change requires an evidence-backed registry update. */ - modelWireDefaults: { "gpt-5.6-luna": "openai-responses", "muse-spark-1.2-contributor": "openai-responses" }, + modelWireDefaults: { + "gpt-5.6-luna": "openai-responses", + "muse-spark-1.2-contributor": "openai-responses", + // 1.3 serves the same Responses-only shape on Zen Go (probed 2026-09-02: + // /chat/completions -> 500, /responses -> 200). + "muse-spark-1.3-contributor": "openai-responses", + }, modelContextWindows: { "kimi-k3": KIMI_K3_STANDARD_CONTEXT_WINDOW, // The DeepSeek vision preview id is metadata-only here: the Go roster is @@ -1510,6 +1516,9 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ }, modelReasoningEfforts: { "gpt-5.6-luna": OPENAI_API_GPT56_REASONING_EFFORTS, + // Zen Go rejects any other ladder for 1.3 (`reasoning.effort: unknown variant`, + // expected none/minimal/low/medium/high/xhigh — gateway error, probed 2026-09-02). + "muse-spark-1.3-contributor": ["none", "minimal", "low", "medium", "high", "xhigh"], "glm-5.3": ZAI_GLM_53_REASONING_EFFORTS, "glm-5.3-flash": ZAI_GLM_53_REASONING_EFFORTS, "glm-5.2": ZAI_GLM_52_REASONING_EFFORTS, @@ -1526,6 +1535,17 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // the thinking-toggle map is a REAL wire alias (effort -> enabled/disabled) and stays. modelReasoningEffortMap: { "kimi-k3": KIMI_CODING_K3_REASONING_EFFORT_MAP, + // 1.3 has no `max` rung: Codex default-max callers resolve to `xhigh` + // instead of taking a gateway 400. + "muse-spark-1.3-contributor": { + "none": "none", + "minimal": "minimal", + "low": "low", + "medium": "medium", + "high": "high", + "xhigh": "xhigh", + "max": "xhigh", + }, ...Object.fromEntries(OPENCODE_GO_THINKING_TOGGLE_MODELS.map(id => [id, THINKING_TOGGLE_MAP])), ...Object.fromEntries(DEEPSEEK_THINKING_MODELS.map(id => [id, deepseekReasoningMapFor(id)])), }, diff --git a/tests/muse-spark-web-search-compat.test.ts b/tests/muse-spark-web-search-compat.test.ts index 59b589178a..705a633918 100644 --- a/tests/muse-spark-web-search-compat.test.ts +++ b/tests/muse-spark-web-search-compat.test.ts @@ -83,5 +83,92 @@ describe("#2617 Muse Spark web_search compatibility", () => { expect(defaults["muse-spark-1.2-contributor"]).toBe("openai-responses"); // An exact-model allowlist, not a family rule: a sibling must not be dragged along. expect(defaults["muse-spark-1.2"]).toBeUndefined(); + // 1.3 serves the same Responses-only shape on Zen Go (probed: /chat/completions -> 500). + expect(defaults["muse-spark-1.3-contributor"]).toBe("openai-responses"); + }); + + test("1.3 gets the same web_search strip (probed: 1.3 + search_content_types -> 400)", () => { + const body = build("muse-spark-1.3-contributor", { tools: [webSearchTool()] }); + const tool = toolsOf(body)[0]!; + expect(tool.type).toBe("web_search"); + expect(Object.hasOwn(tool, "search_content_types")).toBe(false); + }); +}); + +/** + * Zen Go rejects function names longer than 64 chars and recursive JSON schemas + * (probed live: `name must be at most 64 characters, got 66` from Codex MCP tools + * such as `muse-spark-web-search-compat`, and `Recursive JSON schemas are not + * currently supported` from cyclic `$defs`). Dropping only the offending + * declarations lets the turn proceed with the remaining catalog instead of + * failing the whole request with a 400. + */ +describe("Muse Spark tool-surface compatibility", () => { + const functionTool = (name: string, parameters: Record = { type: "object" }) => ({ + type: "function", + name, + parameters, + }); + + test("drops function tools with names longer than 64 chars, keeps a 64-char name", () => { + const body = build("muse-spark-1.3-contributor", { + tools: [functionTool("a".repeat(65)), functionTool("b".repeat(64))], + }); + const names = toolsOf(body).map(tool => tool.name); + expect(names).toEqual(["b".repeat(64)]); + }); + + test("a nested additional_tools declaration is filtered too", () => { + const body = build("muse-spark-1.3-contributor", { + input: [{ type: "additional_tools", tools: [functionTool("c".repeat(66))] }], + }); + const item = (body.input as Array>)[0]!; + expect((item.tools as unknown[])).toEqual([]); + }); + + test("tool_choice naming a dropped tool falls back to auto", () => { + const longName = "d".repeat(65); + const body = build("muse-spark-1.3-contributor", { + tools: [functionTool(longName)], + tool_choice: { type: "function", name: longName }, + }); + expect(body.tool_choice).toBe("auto"); + }); + + test("another model on the same provider keeps over-long names untouched", () => { + const body = build("gpt-5.6-luna", { tools: [functionTool("e".repeat(65))] }); + expect(toolsOf(body).map(tool => tool.name)).toEqual(["e".repeat(65)]); + }); + + const cyclicParameters = () => ({ + type: "object", + properties: { q: { $ref: "#/$defs/q" } }, + $defs: { q: { type: "object", properties: { sub: { $ref: "#/$defs/q" } } } }, + }); + + test("drops function tools with cyclic local $refs", () => { + const body = build("muse-spark-1.3-contributor", { + tools: [functionTool("cyclic_tool", cyclicParameters()), functionTool("fine_tool")], + }); + expect(toolsOf(body).map(tool => tool.name)).toEqual(["fine_tool"]); + }); + + test("keeps diamond $refs that share one $defs entry without cycling", () => { + const diamond = { + type: "object", + properties: { a: { $ref: "#/$defs/x" }, b: { $ref: "#/$defs/x" } }, + $defs: { x: { type: "string" } }, + }; + const body = build("muse-spark-1.3-contributor", { + tools: [functionTool("diamond_tool", diamond)], + }); + expect(toolsOf(body).map(tool => tool.name)).toEqual(["diamond_tool"]); + }); + + test("another model on the same provider keeps cyclic schemas untouched", () => { + const body = build("gpt-5.6-luna", { + tools: [functionTool("cyclic_tool", cyclicParameters())], + }); + expect(toolsOf(body).map(tool => tool.name)).toEqual(["cyclic_tool"]); }); }); From 0067139889284283abcd5cf183de925d37ac65e0 Mon Sep 17 00:00:00 2001 From: Gustavo Date: Wed, 2 Sep 2026 20:27:21 -0300 Subject: [PATCH 2/5] fix(opencode-go): use debugProviderDiagnostic instead of console suppressions --- src/adapters/openai-responses.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index 37305804dc..8966b22832 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -2069,8 +2069,10 @@ function dropMuseSparkOverlongToolNames(body: unknown, modelId: unknown): unknow if (input.some((item, index) => item !== (next.input as unknown[])[index])) next = { ...next, input }; } if (dropped.size > 0) { - // eslint-disable-next-line no-console - console.warn(`[opencodex] muse-spark: dropped ${dropped.size} tool(s) with names >64 chars rejected by Zen Go`); + debugProviderDiagnostic("openai-responses", "muse-spark-tools-dropped", { + reason: "tool-name-gt-64-chars", + count: dropped.size, + }); if (isPlainObject(next.tool_choice) && typeof next.tool_choice.name === "string" && dropped.has(next.tool_choice.name)) { next = { ...next, tool_choice: "auto" }; } @@ -2135,8 +2137,11 @@ function dropMuseSparkRecursiveSchemaTools(body: unknown, modelId: unknown): unk if (input.some((item, index) => item !== (next.input as unknown[])[index])) next = { ...next, input }; } if (dropped.size > 0) { - // eslint-disable-next-line no-console - console.warn(`[opencodex] muse-spark: dropped ${dropped.size} tool(s) with recursive schemas rejected by Zen Go: ${[...dropped].join(", ")}`); + debugProviderDiagnostic("openai-responses", "muse-spark-tools-dropped", { + reason: "recursive-schema", + count: dropped.size, + tools: [...dropped], + }); if (isPlainObject(next.tool_choice) && typeof next.tool_choice.name === "string" && dropped.has(next.tool_choice.name)) { next = { ...next, tool_choice: "auto" }; } From c72a806440cf1b5679241aacbcb9625ad96c5630 Mon Sep 17 00:00:00 2001 From: Gustavo Date: Wed, 2 Sep 2026 20:29:54 -0300 Subject: [PATCH 3/5] fix(opencode-go): address review findings on muse-spark guards --- src/adapters/openai-responses.ts | 27 +++++++++++---- src/providers/registry.ts | 1 + tests/muse-spark-web-search-compat.test.ts | 39 ++++++++++++++++++++++ 3 files changed, 60 insertions(+), 7 deletions(-) diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index 8966b22832..ddfd2b0d0d 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -2092,16 +2092,27 @@ function dropMuseSparkOverlongToolNames(body: unknown, modelId: unknown): unknow function schemaRefGraphHasCycle(parameters: unknown): boolean { if (!isPlainObject(parameters)) return false; const root: Record = parameters; + // Bounds the walk: nested diamond `$defs` expand exponentially without ever + // cycling, which would block the request loop on a small body. Mirrors the + // node budget in xai-tool-schema.ts; exceeding it fails closed (drop). + const budget = { remaining: 4_096 }; const visit = (node: unknown, stack: string[]): boolean => { + if (budget.remaining <= 0) return true; + budget.remaining -= 1; if (Array.isArray(node)) return node.some(child => visit(child, stack)); if (!isPlainObject(node)) return false; if (typeof node.$ref === "string") { - if (stack.includes(node.$ref)) return true; + const ref = node.$ref; + if (stack.includes(ref)) return true; // Remote or unresolvable refs cannot be judged locally; leave them alone. - if (!node.$ref.startsWith("#/") && node.$ref !== "#" && node.$ref !== "#/") return false; - const target = lookupLocalJsonPointer(root, node.$ref); - if (target === undefined) return false; - return visit(target, [...stack, node.$ref]); + if (!ref.startsWith("#/") && ref !== "#" && ref !== "#/") return false; + const extended = [...stack, ref]; + const target = lookupLocalJsonPointer(root, ref); + if (target !== undefined && visit(target, extended)) return true; + // `$ref` siblings are schema too (JSON Schema 2020-12): a root pairing + // `$ref` with a property that references `#` is recursive and must not + // be classified safe just because the target itself is acyclic. + return Object.entries(node).some(([key, child]) => key !== "$ref" && visit(child, stack)); } return Object.values(node).some(child => visit(child, stack)); }; @@ -2115,7 +2126,10 @@ function dropMuseSparkRecursiveSchemaTools(body: unknown, modelId: unknown): unk const filterTools = (tools: unknown[]): { tools: unknown[]; changed: boolean } => { let changed = false; const kept = tools.filter(tool => { - if (!isPlainObject(tool) || tool.type !== "function") return true; + // `custom` tools carry no JSON-schema `parameters` today, so in practice + // only `function` tools trip the cycle check; both are listed so a future + // custom shape with parameters gets the same guard. + if (!isPlainObject(tool) || (tool.type !== "function" && tool.type !== "custom")) return true; if (!isPlainObject(tool.parameters) || !schemaRefGraphHasCycle(tool.parameters)) return true; if (typeof tool.name === "string") dropped.add(tool.name); changed = true; @@ -2140,7 +2154,6 @@ function dropMuseSparkRecursiveSchemaTools(body: unknown, modelId: unknown): unk debugProviderDiagnostic("openai-responses", "muse-spark-tools-dropped", { reason: "recursive-schema", count: dropped.size, - tools: [...dropped], }); if (isPlainObject(next.tool_choice) && typeof next.tool_choice.name === "string" && dropped.has(next.tool_choice.name)) { next = { ...next, tool_choice: "auto" }; diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 1727705dc1..330d76fcb4 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -1486,6 +1486,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ - 선택한 방식: Declare only the named models as `openai-responses` through the existing registry default mechanism; the map stays an exact-model allowlist rather than a family or provider-wide rule. - 다른 대안 대신 이 방식을 선택한 이유: OpenCode Go documents sibling models on Chat or Anthropic endpoints, and an exact registry default preserves both those routes and explicit opt-out precedence. - 장점, 단점 및 영향: Each listed model reaches `/responses` from every inbound surface without changing siblings; a future upstream endpoint change requires an evidence-backed registry update. + - 2026-09-02 probe (muse-spark-1.3-contributor): Zen Go answers the same Responses-only shape as 1.2 — `/chat/completions` -> 500, `/responses` -> 200; `reasoning.effort` ladder is none/minimal/low/medium/high/xhigh (no `max`); plain `web_search` must not carry `search_content_types`; tool names are capped at 64 chars; recursive `$ref` schemas are refused. Added 1.3 to the allowlist with its ladder and a `max` -> `xhigh` map; context window and input modalities stay undeclared (unverified). */ modelWireDefaults: { "gpt-5.6-luna": "openai-responses", diff --git a/tests/muse-spark-web-search-compat.test.ts b/tests/muse-spark-web-search-compat.test.ts index 705a633918..e15558f84f 100644 --- a/tests/muse-spark-web-search-compat.test.ts +++ b/tests/muse-spark-web-search-compat.test.ts @@ -165,6 +165,45 @@ describe("Muse Spark tool-surface compatibility", () => { expect(toolsOf(body).map(tool => tool.name)).toEqual(["diamond_tool"]); }); + test("catches recursion through $ref siblings (JSON Schema $ref-with-siblings form)", () => { + const sneaky = { + $ref: "#/$defs/base", + properties: { loop: { $ref: "#" } }, + $defs: { base: { type: "object" } }, + }; + const body = build("muse-spark-1.3-contributor", { + tools: [functionTool("sneaky_tool", sneaky), functionTool("fine_tool")], + }); + expect(toolsOf(body).map(tool => tool.name)).toEqual(["fine_tool"]); + }); + + test("fails closed on over-budget $defs graphs instead of expanding exponentially", () => { + // Each level references its predecessor twice: depth N costs ~2^N visits + // without a bound. Depth 14 (~16k unbudgeted visits) must already drop. + const defs: Record = { d0: { type: "string" } }; + for (let i = 1; i <= 14; i += 1) { + defs[`d${i}`] = { + type: "object", + properties: { left: { $ref: `#/$defs/d${i - 1}` }, right: { $ref: `#/$defs/d${i - 1}` } }, + }; + } + const nested = { type: "object", properties: { root: { $ref: "#/$defs/d14" } }, $defs: defs }; + const body = build("muse-spark-1.3-contributor", { + tools: [functionTool("nested_tool", nested), functionTool("fine_tool")], + }); + expect(toolsOf(body).map(tool => tool.name)).toEqual(["fine_tool"]); + }); + + test("keeps a wide flat diamond that stays far under the walk budget", () => { + const properties: Record = {}; + for (let i = 0; i < 100; i += 1) properties[`p${i}`] = { $ref: "#/$defs/x" }; + const wide = { type: "object", properties, $defs: { x: { type: "string" } } }; + const body = build("muse-spark-1.3-contributor", { + tools: [functionTool("wide_tool", wide)], + }); + expect(toolsOf(body).map(tool => tool.name)).toEqual(["wide_tool"]); + }); + test("another model on the same provider keeps cyclic schemas untouched", () => { const body = build("gpt-5.6-luna", { tools: [functionTool("cyclic_tool", cyclicParameters())], From 8605f22c42fef9aad21b2f5a91bb29fa77447cc7 Mon Sep 17 00:00:00 2001 From: Gustavo Date: Wed, 2 Sep 2026 20:41:01 -0300 Subject: [PATCH 4/5] fix(opencode-go): review round 2 on muse-spark guards --- src/adapters/openai-responses.ts | 69 +++++++++++++++------ src/providers/registry.ts | 5 ++ tests/muse-spark-web-search-compat.test.ts | 72 +++++++++++++++++----- 3 files changed, 114 insertions(+), 32 deletions(-) diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index ddfd2b0d0d..09c34664ae 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -2030,6 +2030,28 @@ function stripMuseSparkUnsupportedWebSearchFields(body: unknown, modelId: unknow return changed ? next : body; } +/** + * Re-point `tool_choice` after Muse Spark guards drop declarations. Handles the + * direct `{ type, name }` selector and Codex's `{ type: "allowed_tools", + * tools }` list: entries naming dropped tools are removed, an emptied list + * (or a dangling direct reference) falls back to `auto` so the turn does not + * take a second gateway 400 for a tool that is no longer declared. + */ +function fallbackMuseSparkToolChoice( + toolChoice: unknown, + dropped: ReadonlySet, +): unknown { + if (!isPlainObject(toolChoice)) return toolChoice; + if (typeof toolChoice.name === "string") { + return dropped.has(toolChoice.name) ? "auto" : toolChoice; + } + if (toolChoice.type !== "allowed_tools" || !Array.isArray(toolChoice.tools)) return toolChoice; + const kept = toolChoice.tools.filter(tool => + !isPlainObject(tool) || typeof tool.name !== "string" || !dropped.has(tool.name)); + if (kept.length === toolChoice.tools.length) return toolChoice; + return kept.length === 0 ? "auto" : { ...toolChoice, tools: kept }; +} + /** * Zen Go rejects function/custom tool names longer than 64 chars * (`name must be at most 64 characters`), while Codex attaches MCP tools such @@ -2073,9 +2095,8 @@ function dropMuseSparkOverlongToolNames(body: unknown, modelId: unknown): unknow reason: "tool-name-gt-64-chars", count: dropped.size, }); - if (isPlainObject(next.tool_choice) && typeof next.tool_choice.name === "string" && dropped.has(next.tool_choice.name)) { - next = { ...next, tool_choice: "auto" }; - } + const repaired = fallbackMuseSparkToolChoice(next.tool_choice, dropped); + if (repaired !== next.tool_choice) next = { ...next, tool_choice: repaired }; } return next === body ? body : next; } @@ -2094,29 +2115,42 @@ function schemaRefGraphHasCycle(parameters: unknown): boolean { const root: Record = parameters; // Bounds the walk: nested diamond `$defs` expand exponentially without ever // cycling, which would block the request loop on a small body. Mirrors the - // node budget in xai-tool-schema.ts; exceeding it fails closed (drop). + // ceilings in xai-tool-schema.ts; exceeding either fails closed (drop). const budget = { remaining: 4_096 }; - const visit = (node: unknown, stack: string[]): boolean => { - if (budget.remaining <= 0) return true; + const maxDepth = 64; + // Refs proven acyclic by a completed walk. Sound to reuse across stacks + // (standard gray/black cycle-detection coloring): only walks that finish + // without hitting the budget or depth ceiling earn the mark, so a marked ref + // can never hide a cycle on a later path. Keeps wide shared `$defs` graphs + // cheap instead of re-walking one subtree per reference. + const provenAcyclic = new Set(); + const visit = (node: unknown, stack: string[], depth: number): boolean => { + if (budget.remaining <= 0 || depth >= maxDepth) return true; budget.remaining -= 1; - if (Array.isArray(node)) return node.some(child => visit(child, stack)); + if (Array.isArray(node)) return node.some(child => visit(child, stack, depth + 1)); if (!isPlainObject(node)) return false; if (typeof node.$ref === "string") { const ref = node.$ref; if (stack.includes(ref)) return true; - // Remote or unresolvable refs cannot be judged locally; leave them alone. - if (!ref.startsWith("#/") && ref !== "#" && ref !== "#/") return false; - const extended = [...stack, ref]; - const target = lookupLocalJsonPointer(root, ref); - if (target !== undefined && visit(target, extended)) return true; + let targetRecursive = false; + if (ref.startsWith("#/") || ref === "#" || ref === "#/") { + if (!provenAcyclic.has(ref)) { + const target = lookupLocalJsonPointer(root, ref); + if (target !== undefined) { + targetRecursive = visit(target, [...stack, ref], depth + 1); + if (!targetRecursive) provenAcyclic.add(ref); + } + } + } + if (targetRecursive) return true; // `$ref` siblings are schema too (JSON Schema 2020-12): a root pairing // `$ref` with a property that references `#` is recursive and must not // be classified safe just because the target itself is acyclic. - return Object.entries(node).some(([key, child]) => key !== "$ref" && visit(child, stack)); + return Object.entries(node).some(([key, child]) => key !== "$ref" && visit(child, stack, depth + 1)); } - return Object.values(node).some(child => visit(child, stack)); + return Object.values(node).some(child => visit(child, stack, depth + 1)); }; - return visit(root, []); + return visit(root, [], 0); } function dropMuseSparkRecursiveSchemaTools(body: unknown, modelId: unknown): unknown { @@ -2155,9 +2189,8 @@ function dropMuseSparkRecursiveSchemaTools(body: unknown, modelId: unknown): unk reason: "recursive-schema", count: dropped.size, }); - if (isPlainObject(next.tool_choice) && typeof next.tool_choice.name === "string" && dropped.has(next.tool_choice.name)) { - next = { ...next, tool_choice: "auto" }; - } + const repaired = fallbackMuseSparkToolChoice(next.tool_choice, dropped); + if (repaired !== next.tool_choice) next = { ...next, tool_choice: repaired }; } return next === body ? body : next; } diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 330d76fcb4..d35f00961b 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -1514,6 +1514,11 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // advertises it text-only and the Codex app blocks image attachments client-side with // "This model does not support image inputs" before the request ever reaches the proxy. "muse-spark-1.2-contributor": ["text", "image"], + // 1.3 accepts input_image parts over /responses too (probed 2026-09-02: + // completed response with image part, no 400). No context-window row for + // 1.3 yet: only declare it once a probe (not a sibling's docs) confirms + // the size, so the catalog keeps its 128k fallback instead of a guess. + "muse-spark-1.3-contributor": ["text", "image"], }, modelReasoningEfforts: { "gpt-5.6-luna": OPENAI_API_GPT56_REASONING_EFFORTS, diff --git a/tests/muse-spark-web-search-compat.test.ts b/tests/muse-spark-web-search-compat.test.ts index e15558f84f..48e8de78a9 100644 --- a/tests/muse-spark-web-search-compat.test.ts +++ b/tests/muse-spark-web-search-compat.test.ts @@ -98,8 +98,9 @@ describe("#2617 Muse Spark web_search compatibility", () => { /** * Zen Go rejects function names longer than 64 chars and recursive JSON schemas * (probed live: `name must be at most 64 characters, got 66` from Codex MCP tools - * such as `muse-spark-web-search-compat`, and `Recursive JSON schemas are not - * currently supported` from cyclic `$defs`). Dropping only the offending + * such as `mcp__codex_apps__codex_document_control___get_document_tool_schemas` + * (67 chars), and `Recursive JSON schemas are not currently supported` from + * cyclic `$defs`). Dropping only the offending * declarations lets the turn proceed with the remaining catalog instead of * failing the whole request with a 400. */ @@ -135,6 +136,33 @@ describe("Muse Spark tool-surface compatibility", () => { expect(body.tool_choice).toBe("auto"); }); + test("allowed_tools entries naming dropped tools are filtered", () => { + const longName = "f".repeat(65); + const body = build("muse-spark-1.3-contributor", { + tools: [functionTool(longName), functionTool("fine_tool")], + tool_choice: { + type: "allowed_tools", + tools: [ + { type: "function", name: longName }, + { type: "function", name: "fine_tool" }, + ], + }, + }); + expect(body.tool_choice).toEqual({ + type: "allowed_tools", + tools: [{ type: "function", name: "fine_tool" }], + }); + }); + + test("an allowed_tools list left empty by drops falls back to auto", () => { + const longName = "g".repeat(65); + const body = build("muse-spark-1.3-contributor", { + tools: [functionTool(longName)], + tool_choice: { type: "allowed_tools", tools: [{ type: "function", name: longName }] }, + }); + expect(body.tool_choice).toBe("auto"); + }); + test("another model on the same provider keeps over-long names untouched", () => { const body = build("gpt-5.6-luna", { tools: [functionTool("e".repeat(65))] }); expect(toolsOf(body).map(tool => tool.name)).toEqual(["e".repeat(65)]); @@ -177,19 +205,35 @@ describe("Muse Spark tool-surface compatibility", () => { expect(toolsOf(body).map(tool => tool.name)).toEqual(["fine_tool"]); }); - test("fails closed on over-budget $defs graphs instead of expanding exponentially", () => { - // Each level references its predecessor twice: depth N costs ~2^N visits - // without a bound. Depth 14 (~16k unbudgeted visits) must already drop. - const defs: Record = { d0: { type: "string" } }; - for (let i = 1; i <= 14; i += 1) { - defs[`d${i}`] = { - type: "object", - properties: { left: { $ref: `#/$defs/d${i - 1}` }, right: { $ref: `#/$defs/d${i - 1}` } }, - }; - } - const nested = { type: "object", properties: { root: { $ref: "#/$defs/d14" } }, $defs: defs }; + test("fails closed on over-budget schemas instead of walking forever", () => { + // 5,000 distinct properties cost ~10k visits with nothing to share, past + // the 4,096-node budget: drop instead of blocking the request loop. + const properties: Record = {}; + for (let i = 0; i < 5000; i += 1) properties[`p${i}`] = { type: "string" }; + const body = build("muse-spark-1.3-contributor", { + tools: [functionTool("huge_tool", { type: "object", properties }), functionTool("fine_tool")], + }); + expect(toolsOf(body).map(tool => tool.name)).toEqual(["fine_tool"]); + }); + + test("memoizes shared $defs instead of re-walking one subtree per reference", () => { + // 1,500 properties sharing one $defs entry would cost ~6k unbudgeted + // visits; with proven-acyclic memoization it stays far under budget: kept. + const properties: Record = {}; + for (let i = 0; i < 1500; i += 1) properties[`p${i}`] = { $ref: "#/$defs/x" }; + const shared = { type: "object", properties, $defs: { x: { type: "string" } } }; + const body = build("muse-spark-1.3-contributor", { + tools: [functionTool("shared_tool", shared)], + }); + expect(toolsOf(body).map(tool => tool.name)).toEqual(["shared_tool"]); + }); + + test("fails closed past the depth ceiling on deep acyclic chains", () => { + // 100 nested levels are acyclic but deeper than the 64-level ceiling. + let level: Record = { type: "string" }; + for (let i = 0; i < 100; i += 1) level = { type: "object", properties: { next: level } }; const body = build("muse-spark-1.3-contributor", { - tools: [functionTool("nested_tool", nested), functionTool("fine_tool")], + tools: [functionTool("deep_tool", level), functionTool("fine_tool")], }); expect(toolsOf(body).map(tool => tool.name)).toEqual(["fine_tool"]); }); From be88701c4e6dd38c3ec9a623121e1ef8b772a166 Mon Sep 17 00:00:00 2001 From: Gustavo Date: Wed, 2 Sep 2026 20:57:25 -0300 Subject: [PATCH 5/5] fix(opencode-go): declare shared 1M context window for muse-spark-1.3 --- src/providers/registry.ts | 3 +++ tests/opencode-go-muse-context.test.ts | 7 +++++++ 2 files changed, 10 insertions(+) diff --git a/src/providers/registry.ts b/src/providers/registry.ts index d35f00961b..98964e252c 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -1504,6 +1504,9 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // /responses on Zen Go, matching its 1.1 sibling (Meta developer docs, verified 2026-08-28). // Without this declaration the catalog falls back to 128k, capping real usable context. "muse-spark-1.2-contributor": 1_048_576, + // 1.3 shares the same 1M window: Meta documents one shared window for the + // 1.1/1.2/1.3 family (ai.developer.meta.com/docs/models, verified 2026-09-02). + "muse-spark-1.3-contributor": 1_048_576, }, modelInputModalities: { "kimi-k3": ["text", "image"], diff --git a/tests/opencode-go-muse-context.test.ts b/tests/opencode-go-muse-context.test.ts index a387696d71..1e7312e4bb 100644 --- a/tests/opencode-go-muse-context.test.ts +++ b/tests/opencode-go-muse-context.test.ts @@ -52,3 +52,10 @@ describe("OpenCode Go Muse Spark context window", () => { expect(hinted.contextWindow).toBe(MUSE_CONTEXT); }); }); + +describe("OpenCode Go Muse Spark 1.3 Contributor context window", () => { + test("registry declares the shared 1M family window for 1.3", () => { + const entry = PROVIDER_REGISTRY.find(e => e.id === "opencode-go"); + expect(entry?.modelContextWindows?.["muse-spark-1.3-contributor"]).toBe(MUSE_CONTEXT); + }); +});