From 11222eff0fb4b944324b8ab588cfc8c682de4538 Mon Sep 17 00:00:00 2001 From: Sina Sadeghi Date: Sat, 5 Sep 2026 22:39:22 +0700 Subject: [PATCH] feat(opencode-go): muse-spark-1.2 and Responses tool fixes (#3820) - Add muse-spark-1.2-contributor as responses-only model on OpenCode Go - Normalize object tool schemas without properties in OpenCode Go executor - Make fallback Responses call_ids unique across same-millisecond calls - Make Responses output coercion fail-soft for circular and non-stringifiable values --- open-sse/executors/opencode-go.js | 5 +++- open-sse/providers/registry/opencode-go.js | 1 + open-sse/translator/formats/responsesApi.js | 17 +++++++++++-- tests/unit/opencode-go-models.test.js | 11 +++++++- .../opencode-go-muse-spark-responses.test.js | 15 +++++++++++ .../responses-parallel-tool-calls.test.js | 25 +++++++++++++++++++ 6 files changed, 70 insertions(+), 4 deletions(-) diff --git a/open-sse/executors/opencode-go.js b/open-sse/executors/opencode-go.js index e171be62..efb656ac 100644 --- a/open-sse/executors/opencode-go.js +++ b/open-sse/executors/opencode-go.js @@ -61,9 +61,12 @@ function normalizeResponsesTools(body) { const name = rawName.trim(); if (!name) return false; const description = typeof tool.description === "string" ? tool.description : (typeof fn?.description === "string" ? fn.description : ""); - const parameters = (tool.parameters && typeof tool.parameters === "object" && !Array.isArray(tool.parameters)) + let parameters = (tool.parameters && typeof tool.parameters === "object" && !Array.isArray(tool.parameters)) ? tool.parameters : (fn?.parameters && typeof fn.parameters === "object" && !Array.isArray(fn.parameters) ? fn.parameters : { type: "object", properties: {} }); + // Mirror the request translator: {type:"object"} without properties is rejected + // by strict Responses backends, so fill in the empty properties map. + if (parameters.type === "object" && !parameters.properties) parameters = { ...parameters, properties: {} }; for (const k of Object.keys(tool)) delete tool[k]; tool.type = "function"; tool.name = name.slice(0, MAX_TOOL_NAME_LEN); diff --git a/open-sse/providers/registry/opencode-go.js b/open-sse/providers/registry/opencode-go.js index ac78da97..c6673223 100644 --- a/open-sse/providers/registry/opencode-go.js +++ b/open-sse/providers/registry/opencode-go.js @@ -52,6 +52,7 @@ export default { { id: "qwen3.6-plus", name: "Qwen 3.6 Plus", supportedFormats: ["openai", "claude"] }, // Muse Spark is served by /zen/go/v1/responses only — responses-only entry forces // chatCore past the sourceFormat-matched transports into translation (see chatCore guard). + { id: "muse-spark-1.2-contributor", name: "Muse Spark 1.2 Contributor", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] }, { id: "muse-spark-1.3-contributor", name: "Muse Spark 1.3 Contributor", targetFormat: "openai-responses", supportedFormats: ["openai-responses"] }, ], features: { diff --git a/open-sse/translator/formats/responsesApi.js b/open-sse/translator/formats/responsesApi.js index 26b737f1..5454f9f3 100644 --- a/open-sse/translator/formats/responsesApi.js +++ b/open-sse/translator/formats/responsesApi.js @@ -26,8 +26,13 @@ export function normalizeResponsesInput(input) { // Strict Responses upstreams reject overlong call_ids with InputValidationError (#393). export const MAX_RESPONSES_CALL_ID_LEN = 64; +// Fallback ids share one Date.now() when a batch of items is sanitized in a tight +// loop — a per-process sequence keeps same-millisecond ids unique so +// function_call ↔ function_call_output correlation never collides. +let responsesCallIdSeq = 0; + export function clampResponsesCallId(id) { - if (typeof id !== "string" || !id) return `call_${Date.now()}`; + if (typeof id !== "string" || !id) return `call_${Date.now()}_${(responsesCallIdSeq += 1)}`; return id.length > MAX_RESPONSES_CALL_ID_LEN ? id.substring(0, MAX_RESPONSES_CALL_ID_LEN) : id; } @@ -55,7 +60,15 @@ export function coerceResponsesArguments(value) { export function coerceResponsesOutput(value) { if (typeof value === "string") return value; if (value === undefined || value === null) return ""; - if (Array.isArray(value)) return value.map((c) => c?.text ?? JSON.stringify(c)).join(""); + if (Array.isArray(value)) { + return value.map((c) => { + try { + return c?.text ?? JSON.stringify(c); + } catch { + return String(c); + } + }).join(""); + } try { return JSON.stringify(value); } catch { diff --git a/tests/unit/opencode-go-models.test.js b/tests/unit/opencode-go-models.test.js index 1c5a940e..7ece100a 100644 --- a/tests/unit/opencode-go-models.test.js +++ b/tests/unit/opencode-go-models.test.js @@ -27,7 +27,7 @@ describe("OpenCode Go model catalog", () => { "mimo-v2.5", "mimo-v2.5-pro", "minimax-m3", "minimax-m2.7", "minimax-m2.5", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus", - "muse-spark-1.3-contributor", + "muse-spark-1.2-contributor", "muse-spark-1.3-contributor", ]); }); }); @@ -90,6 +90,15 @@ describe("OpenCode Go per-model transport guard (chatCore logic)", () => { } }); + it("routes Muse Spark (responses-only) to /responses, never to /messages", () => { + for (const m of ["muse-spark-1.2-contributor", "muse-spark-1.3-contributor"]) { + expect(getModelSupportedFormats("opencode-go", m)).toEqual(["openai-responses"]); + expect(pickTransport("opencode-go", "openai-responses", "opencode-go", m)?.baseUrl).toBe("https://opencode.ai/zen/go/v1/responses"); + expect(pickTransport("opencode-go", "claude", "opencode-go", m)).toBeNull(); + expect(pickTransport("opencode-go", "openai", "opencode-go", m)).toBeNull(); + } + }); + it("does NOT route MiniMax (no responses support) to /responses", () => { for (const m of CLAUDE_CAPABLE) { expect(pickTransport("opencode-go", "openai-responses", "opencode-go", m)).toBeNull(); diff --git a/tests/unit/opencode-go-muse-spark-responses.test.js b/tests/unit/opencode-go-muse-spark-responses.test.js index 2f696fd2..4f7dcb3d 100644 --- a/tests/unit/opencode-go-muse-spark-responses.test.js +++ b/tests/unit/opencode-go-muse-spark-responses.test.js @@ -105,6 +105,21 @@ describe("OpenCodeGoExecutor routing + sanitization", () => { expect(JSON.parse(outputs[0].output)).toEqual({ ok: true, text: "héllo \"w\"" }); expect(outputs[1].output).toBe(""); }); + + it("fills in properties for object tool schemas missing them", () => { + const ex = new OpenCodeGoExecutor(); + const body = { + model: MODEL, + input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }], + tools: [ + { type: "function", function: { name: "bare", parameters: { type: "object" } } }, + { type: "function", function: { name: "full", parameters: { type: "object", properties: { a: { type: "string" } } } } }, + ], + }; + const out = ex.transformRequest(MODEL, body, true, {}); + expect(out.tools.find((t) => t.name === "bare").parameters).toEqual({ type: "object", properties: {} }); + expect(out.tools.find((t) => t.name === "full").parameters).toEqual({ type: "object", properties: { a: { type: "string" } } }); + }); }); describe("chat/claude clients translate to Responses without breaking tools", () => { diff --git a/tests/unit/responses-parallel-tool-calls.test.js b/tests/unit/responses-parallel-tool-calls.test.js index e1bf5860..e33b00c1 100644 --- a/tests/unit/responses-parallel-tool-calls.test.js +++ b/tests/unit/responses-parallel-tool-calls.test.js @@ -6,6 +6,7 @@ import { describe, expect, it } from "vitest"; import "../translator/registerAll.js"; import { openaiResponsesToOpenAIResponse } from "../../open-sse/translator/response/openai-responses.js"; +import { clampResponsesCallId, coerceResponsesOutput, MAX_RESPONSES_CALL_ID_LEN } from "../../open-sse/translator/formats/responsesApi.js"; import { initState, translateResponse } from "../../open-sse/translator/index.js"; import { FORMATS } from "../../open-sse/translator/formats.js"; @@ -138,3 +139,27 @@ describe("responses → claude end-to-end keeps parallel tool_use blocks separat ]); }); }); + +describe("fallback call_ids stay unique within a batch", () => { + it("same-millisecond fallbacks never collide", () => { + const ids = new Set(Array.from({ length: 50 }, () => clampResponsesCallId(undefined))); + expect(ids.size).toBe(50); + for (const id of ids) { + expect(id.startsWith("call_")).toBe(true); + expect(id.length).toBeLessThanOrEqual(MAX_RESPONSES_CALL_ID_LEN); + } + expect(new Set([clampResponsesCallId(""), clampResponsesCallId(null)]).size).toBe(2); + }); +}); + +describe("output coercion stays fail-soft on unstringifiable values", () => { + it("never throws on BigInt/circular array elements", () => { + const circular = {}; + circular.self = circular; + const input = [1n, circular, { text: "ok" }]; + expect(() => coerceResponsesOutput(input)).not.toThrow(); + const out = coerceResponsesOutput(input); + expect(typeof out).toBe("string"); + expect(out).toContain("ok"); + }); +});