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