From 1c4c9946bd95419f31a1aed68c106d99fe65ae5d Mon Sep 17 00:00:00 2001 From: Gustavo Date: Wed, 2 Sep 2026 20:23:10 -0300 Subject: [PATCH] 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"]); }); });