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
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user