fix(opencode): route Muse Spark models to Responses API and declare vision

Route all Muse Spark models (not just 1.2) on OpenCode Free to
/zen/v1/responses via isMuseSparkModel(), fixing HTTP 500 on
muse-spark-1.3-contributor-free. Declare vision:true on Muse Spark
models so image input is no longer stripped; register 1.3 in the
registry and capabilities. Scoped to opencode only — other providers
keep Chat Completions routing.
This commit is contained in:
anojndr
2026-09-03 09:23:24 +07:00
parent b870b5d41b
commit acb5c34cdc
7 changed files with 94 additions and 26 deletions

View File

@@ -3,7 +3,8 @@ import REGISTRY from "../providers/registry/index.js";
// PROVIDER_MODELS now built from providers/registry (transport + models co-located) // PROVIDER_MODELS now built from providers/registry (transport + models co-located)
import { PROVIDER_MODELS } from "../providers/index.js"; import { PROVIDER_MODELS } from "../providers/index.js";
import { modelQuotaFamily, modelStrip, modelTargetFormat, modelSupportedFormats, normalizeModelId } from "../providers/models/schema.js"; import { modelQuotaFamily, modelStrip, modelTargetFormat, modelSupportedFormats, normalizeModelId } from "../providers/models/schema.js";
import { CODEX_REVIEW_SUFFIX } from "../providers/models/helpers.js"; import { CODEX_REVIEW_SUFFIX, isMuseSparkModel } from "../providers/models/helpers.js";
import { FORMATS } from "../translator/formats.js";
export { PROVIDER_MODELS }; export { PROVIDER_MODELS };
@@ -49,6 +50,9 @@ export function findModelName(aliasOrId, modelId) {
} }
export function getModelTargetFormat(aliasOrId, modelId) { export function getModelTargetFormat(aliasOrId, modelId) {
if ((!aliasOrId || aliasOrId === "oc" || aliasOrId === "opencode") && isMuseSparkModel(modelId)) {
return FORMATS.OPENAI_RESPONSES;
}
const models = PROVIDER_MODELS[aliasOrId]; const models = PROVIDER_MODELS[aliasOrId];
if (!models) return null; if (!models) return null;
return modelTargetFormat(findModel(models, modelId, aliasOrId)); return modelTargetFormat(findModel(models, modelId, aliasOrId));

View File

@@ -4,10 +4,14 @@ import { PROVIDERS } from "../config/providers.js";
import { getThinkingLevels } from "../providers/thinkingLevels.js"; import { getThinkingLevels } from "../providers/thinkingLevels.js";
import { injectReasoningContent } from "../utils/reasoningContentInjector.js"; import { injectReasoningContent } from "../utils/reasoningContentInjector.js";
import { resolveSessionId } from "../utils/sessionManager.js"; import { resolveSessionId } from "../utils/sessionManager.js";
import { isMuseSparkModel } from "../providers/models/helpers.js";
const OPENCODE_UA = "opencode"; const OPENCODE_UA = "opencode";
// Models served by /zen/v1/responses; every other model stays on /chat/completions. // Models served by /zen/v1/responses; every other model stays on /chat/completions.
const RESPONSES_MODELS = new Set(["muse-spark-1.2-contributor-free"]); const RESPONSES_MODELS = new Set([
"muse-spark-1.2-contributor-free",
"muse-spark-1.3-contributor-free",
]);
function generateRequestId() { function generateRequestId() {
return `msg_${crypto.randomUUID().replace(/-/g, "")}`; return `msg_${crypto.randomUUID().replace(/-/g, "")}`;
@@ -23,7 +27,8 @@ function baseModelId(model) {
} }
function isResponsesModel(model) { function isResponsesModel(model) {
return RESPONSES_MODELS.has(baseModelId(model)); const base = baseModelId(model);
return RESPONSES_MODELS.has(base) || isMuseSparkModel(base);
} }
function resolveOpencodeSession(body, credentials) { function resolveOpencodeSession(body, credentials) {

View File

@@ -127,8 +127,10 @@ export const MODEL_CAPABILITIES = {
"kimi-for-coding-highspeed": { vision: true, videoInput: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: false, contextWindow: 262144, maxOutput: 65536 }, "kimi-for-coding-highspeed": { vision: true, videoInput: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: false, contextWindow: 262144, maxOutput: 65536 },
"kimi-k2.7-code": { vision: true, videoInput: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: false, contextWindow: 262144, maxOutput: 65536 }, "kimi-k2.7-code": { vision: true, videoInput: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: false, contextWindow: 262144, maxOutput: 65536 },
"kimi-k2.7-code-highspeed": { vision: true, videoInput: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: false, contextWindow: 262144, maxOutput: 65536 }, "kimi-k2.7-code-highspeed": { vision: true, videoInput: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: false, contextWindow: 262144, maxOutput: 65536 },
// OpenCode Free Muse Spark — OpenAI Responses reasoning supports up to xhigh. // OpenCode Free Muse Spark — multimodal (text+image per models.dev meta/muse-spark)
"muse-spark-1.2-contributor-free": { reasoning: true, thinkingFormat: "openai", contextWindow: 1048576, maxOutput: 131072 }, // via OpenAI Responses input_image; reasoning supports up to xhigh.
"muse-spark-1.2-contributor-free": { vision: true, reasoning: true, thinkingFormat: "openai", contextWindow: 1048576, maxOutput: 131072 },
"muse-spark-1.3-contributor-free": { vision: true, reasoning: true, thinkingFormat: "openai", contextWindow: 1048576, maxOutput: 131072 },
}; };
const KIRO_GPT_5_6_CAPABILITIES = { vision: true, reasoning: true, search: true, thinkingFormat: "openai", contextWindow: 272000, maxOutput: 128000 }; const KIRO_GPT_5_6_CAPABILITIES = { vision: true, reasoning: true, search: true, thinkingFormat: "openai", contextWindow: 272000, maxOutput: 128000 };
@@ -337,6 +339,9 @@ export const PATTERN_CAPABILITIES = [
{ pattern: "*laguna-s-2.1*", caps: { reasoning: true, thinkingFormat: "openai", contextWindow: 1000000, maxOutput: 32000 } }, { pattern: "*laguna-s-2.1*", caps: { reasoning: true, thinkingFormat: "openai", contextWindow: 1000000, maxOutput: 32000 } },
{ pattern: "*laguna*", caps: { reasoning: true, thinkingFormat: "openai", contextWindow: 200000, maxOutput: 32000 } }, { pattern: "*laguna*", caps: { reasoning: true, thinkingFormat: "openai", contextWindow: 200000, maxOutput: 32000 } },
// ── OpenCode Free Muse Spark (multimodal text+image; OpenAI Responses reasoning supports up to xhigh) ─
{ pattern: "*muse*spark*", caps: { vision: true, reasoning: true, thinkingFormat: "openai", contextWindow: 1048576, maxOutput: 131072 } },
// ── Others ─────────────────────────────────────────────────────── // ── Others ───────────────────────────────────────────────────────
{ pattern: "*hunyuan*", caps: { reasoning: true, thinkingFormat: "hunyuan", contextWindow: 262144, maxOutput: 262144 } }, { pattern: "*hunyuan*", caps: { reasoning: true, thinkingFormat: "hunyuan", contextWindow: 262144, maxOutput: 262144 } },
{ pattern: "hy3*", caps: { reasoning: true, thinkingFormat: "hunyuan", contextWindow: 262144, maxOutput: 262144 } }, { pattern: "hy3*", caps: { reasoning: true, thinkingFormat: "hunyuan", contextWindow: 262144, maxOutput: 262144 } },

View File

@@ -18,3 +18,10 @@ export function withCodexReviewModels(models) {
]; ];
}); });
} }
export function isMuseSparkModel(modelId) {
if (!modelId || typeof modelId !== "string") return false;
const clean = modelId.replace(/\([^()]+\)\s*$/, "").trim();
const base = clean.includes("/") ? clean.split("/").pop() : clean;
return /^muse[-_]?spark(?:$|[-_:.\s])/i.test(base);
}

View File

@@ -20,9 +20,10 @@ export default {
noAuth: true, noAuth: true,
}, },
models: [ models: [
// Only this model is served by /zen/v1/responses; the rest stay on // Muse Spark models are served by /zen/v1/responses; the rest stay on
// /chat/completions, so the format is declared per-model, not per-provider. // /chat/completions, so the format is declared per-model, not per-provider.
{ id: "muse-spark-1.2-contributor-free", name: "Muse Spark 1.2 Contributor Free", targetFormat: "openai-responses" }, { id: "muse-spark-1.2-contributor-free", name: "Muse Spark 1.2 Contributor Free", targetFormat: "openai-responses" },
{ id: "muse-spark-1.3-contributor-free", name: "Muse Spark 1.3 Contributor Free", targetFormat: "openai-responses" },
], ],
modelsFetcher: { url: "https://opencode.ai/zen/v1/models", type: "opencode-free" }, modelsFetcher: { url: "https://opencode.ai/zen/v1/models", type: "opencode-free" },
passthroughModels: true, passthroughModels: true,

View File

@@ -55,12 +55,17 @@ describe("OpenCode Free endpoint routing", () => {
expect(opencode.transport.format).toBeUndefined(); expect(opencode.transport.format).toBeUndefined();
const muse = opencode.models.find((m) => m.id === MUSE); const muse = opencode.models.find((m) => m.id === MUSE);
expect(muse?.targetFormat).toBe("openai-responses"); expect(muse?.targetFormat).toBe("openai-responses");
const muse13 = opencode.models.find((m) => m.id === "muse-spark-1.3-contributor-free");
expect(muse13?.targetFormat).toBe("openai-responses");
}); });
it("routes Muse Spark to /responses and every other model to /chat/completions", () => { it("routes Muse Spark to /responses and every other model to /chat/completions", () => {
const executor = new OpenCodeExecutor(); const executor = new OpenCodeExecutor();
expect(executor.buildUrl(MUSE)).toBe("https://opencode.ai/zen/v1/responses"); expect(executor.buildUrl(MUSE)).toBe("https://opencode.ai/zen/v1/responses");
expect(executor.buildUrl(`${MUSE}(xhigh)`)).toBe("https://opencode.ai/zen/v1/responses"); expect(executor.buildUrl(`${MUSE}(xhigh)`)).toBe("https://opencode.ai/zen/v1/responses");
expect(executor.buildUrl("muse-spark-1.3-contributor-free")).toBe("https://opencode.ai/zen/v1/responses");
expect(executor.buildUrl("muse-spark-1.4-contributor-free")).toBe("https://opencode.ai/zen/v1/responses");
expect(executor.buildUrl("muse-spark-2.0-contributor-free(xhigh)")).toBe("https://opencode.ai/zen/v1/responses");
expect(executor.buildUrl("big-pickle")).toBe("https://opencode.ai/zen/v1/chat/completions"); expect(executor.buildUrl("big-pickle")).toBe("https://opencode.ai/zen/v1/chat/completions");
expect(executor.buildUrl("hy3-free")).toBe("https://opencode.ai/zen/v1/chat/completions"); expect(executor.buildUrl("hy3-free")).toBe("https://opencode.ai/zen/v1/chat/completions");
}); });

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { getCapabilitiesForModel } from "../../open-sse/providers/capabilities.js"; import { getCapabilitiesForModel } from "../../open-sse/providers/capabilities.js";
import { PROVIDER_MODELS } from "../../open-sse/config/providerModels.js"; import { PROVIDER_MODELS, getModelTargetFormat } from "../../open-sse/config/providerModels.js";
import { getThinkingLevels } from "../../open-sse/providers/thinkingLevels.js"; import { getThinkingLevels } from "../../open-sse/providers/thinkingLevels.js";
import { FORMATS } from "../../open-sse/translator/formats.js"; import { FORMATS } from "../../open-sse/translator/formats.js";
import { OpenCodeExecutor } from "../../open-sse/executors/opencode.js"; import { OpenCodeExecutor } from "../../open-sse/executors/opencode.js";
@@ -19,18 +19,20 @@ const input = [{
describe("OpenCode Free Muse Spark thinking", () => { describe("OpenCode Free Muse Spark thinking", () => {
it("advertises reasoning and the requested model limits", () => { it("advertises reasoning and the requested model limits", () => {
expect(PROVIDER_MODELS.oc?.some((model) => model.id === MODEL)).toBe(true); expect(PROVIDER_MODELS.oc?.some((model) => model.id === MODEL)).toBe(true);
expect(getCapabilitiesForModel(PROVIDER, MODEL)).toMatchObject({ expect(PROVIDER_MODELS.oc?.some((model) => model.id === "muse-spark-1.3-contributor-free")).toBe(true);
for (const m of [MODEL, "muse-spark-1.3-contributor-free", "muse-spark-1.4-contributor-free", "muse-spark-2.0-contributor-free"]) {
expect(getCapabilitiesForModel(PROVIDER, m)).toMatchObject({
reasoning: true, reasoning: true,
thinkingFormat: "openai", thinkingFormat: "openai",
contextWindow: 1048576, contextWindow: 1048576,
maxOutput: 131072, maxOutput: 131072,
}); });
expect(getCapabilitiesForModel(PROVIDER, `oc/${MODEL}`)).toMatchObject({ expect(getCapabilitiesForModel(PROVIDER, `oc/${m}`)).toMatchObject({
reasoning: true, reasoning: true,
contextWindow: 1048576, contextWindow: 1048576,
maxOutput: 131072, maxOutput: 131072,
}); });
expect(getThinkingLevels(PROVIDER, MODEL)).toEqual([ expect(getThinkingLevels(PROVIDER, m)).toEqual([
"none", "none",
"minimal", "minimal",
"low", "low",
@@ -38,6 +40,10 @@ describe("OpenCode Free Muse Spark thinking", () => {
"high", "high",
"xhigh", "xhigh",
]); ]);
expect(getModelTargetFormat("oc", m)).toBe(FORMATS.OPENAI_RESPONSES);
expect(getModelTargetFormat("opencode", m)).toBe(FORMATS.OPENAI_RESPONSES);
expect(getModelTargetFormat("openrouter", m)).toBeNull();
}
}); });
it("clamps max to xhigh and emits the Responses reasoning shape", () => { it("clamps max to xhigh and emits the Responses reasoning shape", () => {
@@ -91,4 +97,39 @@ describe("OpenCode Free Muse Spark thinking", () => {
expect(out.max_output_tokens).toBe(131072); expect(out.max_output_tokens).toBe(131072);
expect(out.max_tokens).toBeUndefined(); expect(out.max_tokens).toBeUndefined();
}); });
it("routes muse-spark-1.3-contributor-free and future Muse Spark models to Responses API", () => {
const executor = new OpenCodeExecutor();
const futureModel = "muse-spark-1.4-contributor-free";
for (const m of ["muse-spark-1.3-contributor-free", futureModel]) {
expect(executor.buildUrl(m)).toBe("https://opencode.ai/zen/v1/responses");
expect(executor.buildUrl(`${m}(high)`)).toBe("https://opencode.ai/zen/v1/responses");
expect(getModelTargetFormat("oc", m)).toBe("openai-responses");
const body = {
model: `oc/${m}`,
messages: [{ role: "user", content: "Hello" }],
reasoning_effort: "high",
max_tokens: 2048,
};
const translated = translateRequest(
FORMATS.OPENAI,
FORMATS.OPENAI_RESPONSES,
m,
body,
true,
{},
PROVIDER,
);
const out = executor.transformRequest(m, translated, true, {
connectionId: "opencode-muse-spark-13-test",
});
expect(out.reasoning).toEqual({ effort: "high", summary: "auto" });
expect(out.max_output_tokens).toBe(2048);
expect(out.max_tokens).toBeUndefined();
}
});
}); });