diff --git a/open-sse/config/providerModels.js b/open-sse/config/providerModels.js index 300fef5a..90a6b0b2 100644 --- a/open-sse/config/providerModels.js +++ b/open-sse/config/providerModels.js @@ -3,7 +3,8 @@ import REGISTRY from "../providers/registry/index.js"; // PROVIDER_MODELS now built from providers/registry (transport + models co-located) import { PROVIDER_MODELS } from "../providers/index.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 }; @@ -49,6 +50,9 @@ export function findModelName(aliasOrId, modelId) { } export function getModelTargetFormat(aliasOrId, modelId) { + if ((!aliasOrId || aliasOrId === "oc" || aliasOrId === "opencode") && isMuseSparkModel(modelId)) { + return FORMATS.OPENAI_RESPONSES; + } const models = PROVIDER_MODELS[aliasOrId]; if (!models) return null; return modelTargetFormat(findModel(models, modelId, aliasOrId)); diff --git a/open-sse/executors/opencode.js b/open-sse/executors/opencode.js index 4215c218..e2e36449 100644 --- a/open-sse/executors/opencode.js +++ b/open-sse/executors/opencode.js @@ -4,10 +4,14 @@ import { PROVIDERS } from "../config/providers.js"; import { getThinkingLevels } from "../providers/thinkingLevels.js"; import { injectReasoningContent } from "../utils/reasoningContentInjector.js"; import { resolveSessionId } from "../utils/sessionManager.js"; +import { isMuseSparkModel } from "../providers/models/helpers.js"; const OPENCODE_UA = "opencode"; // 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() { return `msg_${crypto.randomUUID().replace(/-/g, "")}`; @@ -23,7 +27,8 @@ function baseModelId(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) { diff --git a/open-sse/providers/capabilities.js b/open-sse/providers/capabilities.js index cdb6bae9..60181eb0 100644 --- a/open-sse/providers/capabilities.js +++ b/open-sse/providers/capabilities.js @@ -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-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 }, - // OpenCode Free Muse Spark — OpenAI Responses reasoning supports up to xhigh. - "muse-spark-1.2-contributor-free": { reasoning: true, thinkingFormat: "openai", contextWindow: 1048576, maxOutput: 131072 }, + // OpenCode Free Muse Spark — multimodal (text+image per models.dev meta/muse-spark) + // 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 }; @@ -337,6 +339,9 @@ export const PATTERN_CAPABILITIES = [ { 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 } }, + + // ── 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 ─────────────────────────────────────────────────────── { pattern: "*hunyuan*", caps: { reasoning: true, thinkingFormat: "hunyuan", contextWindow: 262144, maxOutput: 262144 } }, { pattern: "hy3*", caps: { reasoning: true, thinkingFormat: "hunyuan", contextWindow: 262144, maxOutput: 262144 } }, diff --git a/open-sse/providers/models/helpers.js b/open-sse/providers/models/helpers.js index a7d273d6..c66d662b 100644 --- a/open-sse/providers/models/helpers.js +++ b/open-sse/providers/models/helpers.js @@ -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); +} diff --git a/open-sse/providers/registry/opencode.js b/open-sse/providers/registry/opencode.js index 469c64ca..03e72eef 100644 --- a/open-sse/providers/registry/opencode.js +++ b/open-sse/providers/registry/opencode.js @@ -20,9 +20,10 @@ export default { noAuth: true, }, 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. { 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" }, passthroughModels: true, diff --git a/tests/unit/executor-const-guard.test.js b/tests/unit/executor-const-guard.test.js index e84f5733..f09ce793 100644 --- a/tests/unit/executor-const-guard.test.js +++ b/tests/unit/executor-const-guard.test.js @@ -55,12 +55,17 @@ describe("OpenCode Free endpoint routing", () => { expect(opencode.transport.format).toBeUndefined(); const muse = opencode.models.find((m) => m.id === MUSE); 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", () => { const executor = new OpenCodeExecutor(); 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-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("hy3-free")).toBe("https://opencode.ai/zen/v1/chat/completions"); }); diff --git a/tests/unit/opencode-muse-spark-thinking.test.js b/tests/unit/opencode-muse-spark-thinking.test.js index 36338117..800f7d4e 100644 --- a/tests/unit/opencode-muse-spark-thinking.test.js +++ b/tests/unit/opencode-muse-spark-thinking.test.js @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; 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 { FORMATS } from "../../open-sse/translator/formats.js"; import { OpenCodeExecutor } from "../../open-sse/executors/opencode.js"; @@ -19,25 +19,31 @@ const input = [{ describe("OpenCode Free Muse Spark thinking", () => { it("advertises reasoning and the requested model limits", () => { expect(PROVIDER_MODELS.oc?.some((model) => model.id === MODEL)).toBe(true); - expect(getCapabilitiesForModel(PROVIDER, MODEL)).toMatchObject({ - reasoning: true, - thinkingFormat: "openai", - contextWindow: 1048576, - maxOutput: 131072, - }); - expect(getCapabilitiesForModel(PROVIDER, `oc/${MODEL}`)).toMatchObject({ - reasoning: true, - contextWindow: 1048576, - maxOutput: 131072, - }); - expect(getThinkingLevels(PROVIDER, MODEL)).toEqual([ - "none", - "minimal", - "low", - "medium", - "high", - "xhigh", - ]); + 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, + thinkingFormat: "openai", + contextWindow: 1048576, + maxOutput: 131072, + }); + expect(getCapabilitiesForModel(PROVIDER, `oc/${m}`)).toMatchObject({ + reasoning: true, + contextWindow: 1048576, + maxOutput: 131072, + }); + expect(getThinkingLevels(PROVIDER, m)).toEqual([ + "none", + "minimal", + "low", + "medium", + "high", + "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", () => { @@ -91,4 +97,39 @@ describe("OpenCode Free Muse Spark thinking", () => { expect(out.max_output_tokens).toBe(131072); 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(); + } + }); });