diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index 3d19e360ce..7dc2152795 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -495,6 +495,39 @@ export const REGISTRY: Record = { ], }, + "opencode-go": { + id: "opencode-go", + alias: "opencode-go", + format: "openai", + executor: "opencode", + baseUrl: "https://opencode.ai/zen/go/v1", + authType: "apikey", + authHeader: "Authorization", + authPrefix: "Bearer", + models: [ + { id: "glm-5", name: "GLM-5" }, + { id: "kimi-k2.5", name: "Kimi K2.5" }, + { id: "minimax-m2.7", name: "MiniMax M2.7", targetFormat: "claude" }, + { id: "minimax-m2.5", name: "MiniMax M2.5", targetFormat: "claude" }, + ], + }, + + "opencode-zen": { + id: "opencode-zen", + alias: "opencode-zen", + format: "openai", + executor: "opencode", + baseUrl: "https://opencode.ai/zen/v1", + authType: "apikey", + authHeader: "Authorization", + authPrefix: "Bearer", + models: [ + { id: "minimax-m2.5-free", name: "MiniMax M2.5 Free" }, + { id: "big-pickle", name: "Big Pickle" }, + { id: "gpt-5-nano", name: "GPT 5 Nano" }, + ], + }, + openrouter: { id: "openrouter", alias: "openrouter", diff --git a/open-sse/executors/index.ts b/open-sse/executors/index.ts index 0544119dca..a9e37f6048 100644 --- a/open-sse/executors/index.ts +++ b/open-sse/executors/index.ts @@ -8,6 +8,7 @@ import { CursorExecutor } from "./cursor.ts"; import { DefaultExecutor } from "./default.ts"; import { PollinationsExecutor } from "./pollinations.ts"; import { CloudflareAIExecutor } from "./cloudflare-ai.ts"; +import { OpencodeExecutor } from "./opencode.ts"; const executors = { antigravity: new AntigravityExecutor(), @@ -22,6 +23,8 @@ const executors = { pol: new PollinationsExecutor(), // Alias "cloudflare-ai": new CloudflareAIExecutor(), cf: new CloudflareAIExecutor(), // Alias + "opencode-zen": new OpencodeExecutor("opencode-zen"), + "opencode-go": new OpencodeExecutor("opencode-go"), }; const defaultCache = new Map(); @@ -47,3 +50,4 @@ export { CursorExecutor } from "./cursor.ts"; export { DefaultExecutor } from "./default.ts"; export { PollinationsExecutor } from "./pollinations.ts"; export { CloudflareAIExecutor } from "./cloudflare-ai.ts"; +export { OpencodeExecutor } from "./opencode.ts"; diff --git a/open-sse/executors/opencode.ts b/open-sse/executors/opencode.ts new file mode 100644 index 0000000000..51a0472852 --- /dev/null +++ b/open-sse/executors/opencode.ts @@ -0,0 +1,61 @@ +import { BaseExecutor, type ExecuteInput, type ProviderCredentials } from "./base.ts"; +import { PROVIDERS } from "../config/constants.ts"; +import { getModelTargetFormat } from "../config/providerModels.ts"; + +export class OpencodeExecutor extends BaseExecutor { + _requestFormat: string | null = null; + + constructor(provider: string) { + super(provider, PROVIDERS[provider] || PROVIDERS.openai); + } + + async execute(input: ExecuteInput) { + this._requestFormat = getModelTargetFormat(this.provider, input.model) || "openai"; + try { + return await super.execute(input); + } finally { + this._requestFormat = null; + } + } + + buildUrl( + model: string, + stream: boolean, + urlIndex = 0, + credentials: ProviderCredentials | null = null + ) { + void urlIndex; + void credentials; + + const base = this.config.baseUrl; + switch (this._requestFormat) { + case "claude": + return `${base}/messages`; + case "openai-responses": + return `${base}/responses`; + case "gemini": + return `${base}/models/${model}:${stream ? "streamGenerateContent?alt=sse" : "generateContent"}`; + default: + return `${base}/chat/completions`; + } + } + + buildHeaders(credentials: ProviderCredentials | null, stream = true) { + const headers: Record = { "Content-Type": "application/json" }; + const key = credentials?.apiKey || credentials?.accessToken; + + if (key) { + headers["Authorization"] = `Bearer ${key}`; + } + + if (this._requestFormat === "claude") { + headers["anthropic-version"] = "2023-06-01"; + } + + if (stream) { + headers["Accept"] = "text/event-stream"; + } + + return headers; + } +} diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index 8124f18936..8203aad08e 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -496,6 +496,22 @@ export const APIKEY_PROVIDERS = { website: "https://tavily.com", authHint: "API key from app.tavily.com (format: tvly-...)", }, + "opencode-zen": { + id: "opencode-zen", + alias: "opencode-zen", + name: "OpenCode Zen", + icon: "opencode", + color: "#6366f1", + website: "https://opencode.ai/zen", + }, + "opencode-go": { + id: "opencode-go", + alias: "opencode-go", + name: "OpenCode Go", + icon: "opencode", + color: "#6366f1", + website: "https://opencode.ai/zen/go", + }, alibaba: { id: "alibaba", alias: "ali", diff --git a/tests/unit/opencode-executor.test.mjs b/tests/unit/opencode-executor.test.mjs new file mode 100644 index 0000000000..11710961f7 --- /dev/null +++ b/tests/unit/opencode-executor.test.mjs @@ -0,0 +1,189 @@ +import { afterEach, beforeEach, describe, it } from "node:test"; +import assert from "node:assert/strict"; + +const { OpencodeExecutor } = await import("../../open-sse/executors/opencode.ts"); +const { PROVIDER_MODELS } = await import("../../open-sse/config/providerModels.ts"); + +function createMockResponse() { + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +function createInput(model, stream = true, credentials = { apiKey: "test-key" }) { + return { + model, + stream, + credentials, + body: { + model, + stream, + messages: [{ role: "user", content: "hello" }], + }, + }; +} + +function registerModel(provider, model) { + PROVIDER_MODELS[provider] = [...(PROVIDER_MODELS[provider] || []), model]; +} + +describe("OpencodeExecutor", () => { + let zenExecutor; + let goExecutor; + let fetchCalls; + let originalFetch; + let originalZenModels; + let originalGoModels; + + beforeEach(() => { + zenExecutor = new OpencodeExecutor("opencode-zen"); + goExecutor = new OpencodeExecutor("opencode-go"); + fetchCalls = []; + originalFetch = globalThis.fetch; + originalZenModels = [...(PROVIDER_MODELS["opencode-zen"] || [])]; + originalGoModels = [...(PROVIDER_MODELS["opencode-go"] || [])]; + globalThis.fetch = async (url, options) => { + fetchCalls.push({ url, options }); + return createMockResponse(); + }; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + PROVIDER_MODELS["opencode-zen"] = originalZenModels; + PROVIDER_MODELS["opencode-go"] = originalGoModels; + }); + + describe("execute", () => { + it("routes opencode zen default models to chat completions", async () => { + const minimaxResult = await zenExecutor.execute(createInput("minimax-m2.5-free")); + assert.equal(minimaxResult.url, "https://opencode.ai/zen/v1/chat/completions"); + assert.equal(fetchCalls[0].url, "https://opencode.ai/zen/v1/chat/completions"); + + const pickleResult = await zenExecutor.execute(createInput("big-pickle")); + assert.equal(pickleResult.url, "https://opencode.ai/zen/v1/chat/completions"); + assert.equal(fetchCalls[1].url, "https://opencode.ai/zen/v1/chat/completions"); + + const nanoResult = await zenExecutor.execute(createInput("gpt-5-nano")); + assert.equal(nanoResult.url, "https://opencode.ai/zen/v1/chat/completions"); + assert.equal(fetchCalls[2].url, "https://opencode.ai/zen/v1/chat/completions"); + }); + + it("routes claude target format models to messages endpoint", async () => { + const m27Result = await goExecutor.execute( + createInput("minimax-m2.7", true, { apiKey: "claude-key" }) + ); + assert.equal(m27Result.url, "https://opencode.ai/zen/go/v1/messages"); + assert.equal(fetchCalls[0].url, "https://opencode.ai/zen/go/v1/messages"); + assert.equal(m27Result.headers["anthropic-version"], "2023-06-01"); + + const m25Result = await goExecutor.execute( + createInput("minimax-m2.5", true, { apiKey: "claude-key" }) + ); + assert.equal(m25Result.url, "https://opencode.ai/zen/go/v1/messages"); + assert.equal(fetchCalls[1].url, "https://opencode.ai/zen/go/v1/messages"); + assert.equal(m25Result.headers["anthropic-version"], "2023-06-01"); + }); + + it("routes openai responses target format models to responses endpoint", async () => { + registerModel("opencode-zen", { + id: "gpt-5-responses", + name: "GPT 5 Responses", + targetFormat: "openai-responses", + }); + + const result = await zenExecutor.execute(createInput("gpt-5-responses")); + + assert.equal(result.url, "https://opencode.ai/zen/v1/responses"); + assert.equal(fetchCalls[0].url, "https://opencode.ai/zen/v1/responses"); + }); + + it("routes gemini streaming requests to streamGenerateContent", async () => { + registerModel("opencode-zen", { + id: "gemini-2.5-pro", + name: "Gemini 2.5 Pro", + targetFormat: "gemini", + }); + + const result = await zenExecutor.execute(createInput("gemini-2.5-pro")); + + assert.equal( + result.url, + "https://opencode.ai/zen/v1/models/gemini-2.5-pro:streamGenerateContent?alt=sse" + ); + assert.equal( + fetchCalls[0].url, + "https://opencode.ai/zen/v1/models/gemini-2.5-pro:streamGenerateContent?alt=sse" + ); + }); + + it("routes gemini non streaming requests to generateContent", async () => { + registerModel("opencode-zen", { + id: "gemini-2.5-pro", + name: "Gemini 2.5 Pro", + targetFormat: "gemini", + }); + + const result = await zenExecutor.execute(createInput("gemini-2.5-pro", false)); + + assert.equal(result.url, "https://opencode.ai/zen/v1/models/gemini-2.5-pro:generateContent"); + assert.equal( + fetchCalls[0].url, + "https://opencode.ai/zen/v1/models/gemini-2.5-pro:generateContent" + ); + }); + + it("falls back to chat completions for unknown models", async () => { + const result = await zenExecutor.execute(createInput("unknown-model")); + + assert.equal(result.url, "https://opencode.ai/zen/v1/chat/completions"); + assert.equal(fetchCalls[0].url, "https://opencode.ai/zen/v1/chat/completions"); + }); + + it("builds default headers for standard models", async () => { + const result = await zenExecutor.execute(createInput("gpt-5-nano")); + + assert.deepEqual(result.headers, { + Authorization: "Bearer test-key", + "Content-Type": "application/json", + Accept: "text/event-stream", + }); + assert.deepEqual(fetchCalls[0].options.headers, result.headers); + }); + + it("adds anthropic version for claude target format", async () => { + const result = await goExecutor.execute( + createInput("minimax-m2.7", true, { apiKey: "claude-key" }) + ); + + assert.deepEqual(result.headers, { + Authorization: "Bearer claude-key", + "Content-Type": "application/json", + "anthropic-version": "2023-06-01", + Accept: "text/event-stream", + }); + assert.deepEqual(fetchCalls[0].options.headers, result.headers); + }); + + it("omits accept header when stream is false", async () => { + const result = await zenExecutor.execute(createInput("big-pickle", false)); + + assert.deepEqual(result.headers, { + Authorization: "Bearer test-key", + "Content-Type": "application/json", + }); + assert.deepEqual(fetchCalls[0].options.headers, result.headers); + }); + + it("omits authorization when credentials are missing", async () => { + const result = await zenExecutor.execute(createInput("minimax-m2.5-free", true, null)); + + assert.deepEqual(result.headers, { + "Content-Type": "application/json", + Accept: "text/event-stream", + }); + assert.deepEqual(fetchCalls[0].options.headers, result.headers); + }); + }); +});