diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index 781bbef414..09c34664ae 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,171 @@ 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 + * 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) { + debugProviderDiagnostic("openai-responses", "muse-spark-tools-dropped", { + reason: "tool-name-gt-64-chars", + count: dropped.size, + }); + const repaired = fallbackMuseSparkToolChoice(next.tool_choice, dropped); + if (repaired !== next.tool_choice) next = { ...next, tool_choice: repaired }; + } + 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; + // Bounds the walk: nested diamond `$defs` expand exponentially without ever + // cycling, which would block the request loop on a small body. Mirrors the + // ceilings in xai-tool-schema.ts; exceeding either fails closed (drop). + const budget = { remaining: 4_096 }; + 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, depth + 1)); + if (!isPlainObject(node)) return false; + if (typeof node.$ref === "string") { + const ref = node.$ref; + if (stack.includes(ref)) 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, depth + 1)); + } + return Object.values(node).some(child => visit(child, stack, depth + 1)); + }; + return visit(root, [], 0); +} + +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 => { + // `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; + 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) { + debugProviderDiagnostic("openai-responses", "muse-spark-tools-dropped", { + reason: "recursive-schema", + count: dropped.size, + }); + const repaired = fallbackMuseSparkToolChoice(next.tool_choice, dropped); + if (repaired !== next.tool_choice) next = { ...next, tool_choice: repaired }; + } + 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 +2417,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..98964e252c 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -1486,8 +1486,15 @@ 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", "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 @@ -1497,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"], @@ -1507,9 +1517,17 @@ 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, + // 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 +1544,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..48e8de78a9 100644 --- a/tests/muse-spark-web-search-compat.test.ts +++ b/tests/muse-spark-web-search-compat.test.ts @@ -83,5 +83,175 @@ 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 `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. + */ +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("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)]); + }); + + 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("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 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("deep_tool", level), 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())], + }); + expect(toolsOf(body).map(tool => tool.name)).toEqual(["cyclic_tool"]); }); }); 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); + }); +});