From 2b65c49ff5a9aa96c20287bf02ec13047d4bea35 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Thu, 17 Sep 2026 18:01:33 +0700 Subject: [PATCH] fix(opencode): route Union Alpha through Messages API Route union-alpha to /zen/v1/messages with targetFormat claude, add anthropic-version header, and register model capabilities (vision, 262K context, 131K max output). --- open-sse/executors/opencode.js | 19 +++++++---- open-sse/providers/capabilities.js | 2 ++ open-sse/providers/registry/opencode.js | 4 +-- .../unit/opencode-muse-spark-thinking.test.js | 33 +++++++++++++++++++ 4 files changed, 50 insertions(+), 8 deletions(-) diff --git a/open-sse/executors/opencode.js b/open-sse/executors/opencode.js index e2e36449..fe77137c 100644 --- a/open-sse/executors/opencode.js +++ b/open-sse/executors/opencode.js @@ -5,13 +5,14 @@ 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"; +import { ANTHROPIC_API_VERSION } from "../providers/shared.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", "muse-spark-1.3-contributor-free", ]); +const MESSAGES_MODELS = new Set(["union-alpha"]); function generateRequestId() { return `msg_${crypto.randomUUID().replace(/-/g, "")}`; @@ -31,6 +32,10 @@ function isResponsesModel(model) { return RESPONSES_MODELS.has(base) || isMuseSparkModel(base); } +function isMessagesModel(model) { + return MESSAGES_MODELS.has(baseModelId(model)); +} + function resolveOpencodeSession(body, credentials) { const headers = credentials?.rawHeaders || {}; return resolveSessionId({ @@ -89,12 +94,12 @@ export class OpenCodeExecutor extends BaseExecutor { buildUrl(model) { const base = this.config.baseUrl; - return isResponsesModel(model) - ? `${base}/zen/v1/responses` - : `${base}/zen/v1/chat/completions`; + if (isResponsesModel(model)) return `${base}/zen/v1/responses`; + if (isMessagesModel(model)) return `${base}/zen/v1/messages`; + return `${base}/zen/v1/chat/completions`; } - buildHeaders(credentials, stream = true) { + buildHeaders(credentials, stream = true, url = "") { const raw = credentials?.rawHeaders || {}; const lower = {}; for (const [k, v] of Object.entries(raw)) lower[k.toLowerCase()] = v; @@ -102,7 +107,7 @@ export class OpenCodeExecutor extends BaseExecutor { const downstreamUa = lower["user-agent"] || ""; const isOpencodeDownstream = downstreamUa.toLowerCase().includes("opencode"); - return { + const headers = { "Content-Type": "application/json", "Authorization": "Bearer public", "User-Agent": isOpencodeDownstream ? downstreamUa : OPENCODE_UA, @@ -112,5 +117,7 @@ export class OpenCodeExecutor extends BaseExecutor { "x-opencode-project": lower["x-opencode-project"] || "global", "Accept": stream ? "text/event-stream" : "*/*", }; + if (url.endsWith("/messages")) headers["anthropic-version"] = ANTHROPIC_API_VERSION; + return headers; } } diff --git a/open-sse/providers/capabilities.js b/open-sse/providers/capabilities.js index eac9089e..12c9d82f 100644 --- a/open-sse/providers/capabilities.js +++ b/open-sse/providers/capabilities.js @@ -141,6 +141,8 @@ export const MODEL_CAPABILITIES = { // 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 }, + // OpenCode Free Union Alpha — multimodal (text+vision), 262K context, 131K max output + "union-alpha": { vision: true, contextWindow: 262144, maxOutput: 131072 }, }; const KIRO_GPT_5_6_CAPABILITIES = { vision: true, reasoning: true, search: true, thinkingFormat: "openai", contextWindow: 272000, maxOutput: 128000 }; diff --git a/open-sse/providers/registry/opencode.js b/open-sse/providers/registry/opencode.js index 03e72eef..fc2ab8c6 100644 --- a/open-sse/providers/registry/opencode.js +++ b/open-sse/providers/registry/opencode.js @@ -20,10 +20,10 @@ export default { noAuth: true, }, models: [ - // 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. + // Endpoint formats differ per model, so declare non-chat models explicitly. { 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" }, + { id: "union-alpha", name: "Union Alpha Free", targetFormat: "claude" }, ], modelsFetcher: { url: "https://opencode.ai/zen/v1/models", type: "opencode-free" }, passthroughModels: true, diff --git a/tests/unit/opencode-muse-spark-thinking.test.js b/tests/unit/opencode-muse-spark-thinking.test.js index 800f7d4e..9ffc29b1 100644 --- a/tests/unit/opencode-muse-spark-thinking.test.js +++ b/tests/unit/opencode-muse-spark-thinking.test.js @@ -63,6 +63,39 @@ describe("OpenCode Free Muse Spark thinking", () => { expect(out.max_tokens).toBeUndefined(); }); + it("routes Union Alpha through Anthropic Messages", () => { + const caps = getCapabilitiesForModel(PROVIDER, "union-alpha"); + expect(caps.vision).toBe(true); + expect(caps.contextWindow).toBe(262144); + expect(caps.maxOutput).toBe(131072); + + const executor = new OpenCodeExecutor(); + + expect(getModelTargetFormat("oc", "union-alpha")).toBe(FORMATS.CLAUDE); + const url = executor.buildUrl("union-alpha"); + expect(url).toBe("https://opencode.ai/zen/v1/messages"); + expect(executor.buildHeaders({}, true, url)).toMatchObject({ + "anthropic-version": "2023-06-01", + }); + expect(executor.buildHeaders({}, true, executor.buildUrl("big-pickle"))) + .not.toHaveProperty("anthropic-version"); + + const translated = translateRequest( + FORMATS.OPENAI, + FORMATS.CLAUDE, + "union-alpha", + { messages: [{ role: "user", content: "ping" }], max_tokens: 1 }, + false, + {}, + PROVIDER, + ); + expect(translated).toMatchObject({ + model: "union-alpha", + messages: [{ role: "user", content: [{ type: "text", text: "ping" }] }], + max_tokens: 1, + }); + }); + it("leaves the other free models on Chat Completions", () => { const executor = new OpenCodeExecutor(); const body = { messages: [{ role: "user", content: "hi" }], max_tokens: 1024 };