diff --git a/open-sse/config/providerModels.js b/open-sse/config/providerModels.js index 860f7097..300fef5a 100644 --- a/open-sse/config/providerModels.js +++ b/open-sse/config/providerModels.js @@ -2,7 +2,7 @@ import { PROVIDERS } from "./providers.js"; 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, 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"; export { PROVIDER_MODELS }; @@ -54,6 +54,14 @@ export function getModelTargetFormat(aliasOrId, modelId) { return modelTargetFormat(findModel(models, modelId, aliasOrId)); } +// Declared upstream formats for a model (registry `supportedFormats`). Drives the +// per-model guard on the sourceFormat-matched transport; null when undeclared. +export function getModelSupportedFormats(aliasOrId, modelId) { + const models = PROVIDER_MODELS[aliasOrId]; + if (!models) return null; + return modelSupportedFormats(findModel(models, modelId, aliasOrId)); +} + export function getModelType(aliasOrId, modelId) { const models = PROVIDER_MODELS[aliasOrId]; if (!models) return null; diff --git a/open-sse/executors/index.js b/open-sse/executors/index.js index 8dd03421..bd96ab49 100644 --- a/open-sse/executors/index.js +++ b/open-sse/executors/index.js @@ -10,7 +10,6 @@ import { CodexExecutor } from "./codex.js"; import { CursorExecutor } from "./cursor.js"; import { VertexExecutor } from "./vertex.js"; import { OpenCodeExecutor } from "./opencode.js"; -import { OpenCodeGoExecutor } from "./opencode-go.js"; import { GrokWebExecutor } from "./grok-web.js"; import { GrokCliExecutor } from "./grok-cli.js"; import { PerplexityWebExecutor } from "./perplexity-web.js"; @@ -41,7 +40,6 @@ const executors = { vertex: new VertexExecutor("vertex"), "vertex-partner": new VertexExecutor("vertex-partner"), opencode: new OpenCodeExecutor(), - "opencode-go": new OpenCodeGoExecutor(), "grok-web": new GrokWebExecutor(), "grok-cli": new GrokCliExecutor(), gcli: new GrokCliExecutor(), // Alias @@ -86,7 +84,6 @@ export { CursorExecutor } from "./cursor.js"; export { VertexExecutor } from "./vertex.js"; export { DefaultExecutor } from "./default.js"; export { OpenCodeExecutor } from "./opencode.js"; -export { OpenCodeGoExecutor } from "./opencode-go.js"; export { GrokWebExecutor } from "./grok-web.js"; export { GrokCliExecutor } from "./grok-cli.js"; export { PerplexityWebExecutor } from "./perplexity-web.js"; diff --git a/open-sse/executors/opencode-go.js b/open-sse/executors/opencode-go.js deleted file mode 100644 index 7bf47edb..00000000 --- a/open-sse/executors/opencode-go.js +++ /dev/null @@ -1,49 +0,0 @@ -import { BaseExecutor } from "./base.js"; -import { PROVIDERS } from "../config/providers.js"; -import { injectReasoningContent } from "../utils/reasoningContentInjector.js"; -import { ANTHROPIC_API_VERSION } from "../providers/shared.js"; - -// Models that use /zen/go/v1/messages (Anthropic/Claude format + x-api-key auth) -const MESSAGES_FORMAT_MODELS = new Set([ - "minimax-m3", - "minimax-m2.7", - "minimax-m2.5", - "qwen3.7-max", - "qwen3.7-plus", - "qwen3.6-plus", -]); - -const BASE = "https://opencode.ai/zen/go/v1"; - -export class OpenCodeGoExecutor extends BaseExecutor { - constructor() { - super("opencode-go", PROVIDERS["opencode-go"]); - } - - // buildUrl runs before buildHeaders in BaseExecutor.execute, cache model here - buildUrl(model) { - this._lastModel = model; - return MESSAGES_FORMAT_MODELS.has(model) - ? `${BASE}/messages` - : `${BASE}/chat/completions`; - } - - buildHeaders(credentials, stream = true) { - const key = credentials?.apiKey || credentials?.accessToken; - const headers = { "Content-Type": "application/json" }; - - if (MESSAGES_FORMAT_MODELS.has(this._lastModel)) { - headers["x-api-key"] = key; - headers["anthropic-version"] = ANTHROPIC_API_VERSION; - } else { - headers["Authorization"] = `Bearer ${key}`; - } - - if (stream) headers["Accept"] = "text/event-stream"; - return headers; - } - - transformRequest(model, body) { - return injectReasoningContent({ provider: this.provider, model, body }); - } -} diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js index 9c0c88ae..cb6ce961 100644 --- a/open-sse/handlers/chatCore.js +++ b/open-sse/handlers/chatCore.js @@ -6,7 +6,7 @@ import { normalizeClaudePassthrough, anchorClaudeCache } from "../translator/for import { createStreamController } from "../utils/streamHandler.js"; import { refreshWithRetry } from "../services/tokenRefresh.js"; import { createRequestLogger } from "../utils/requestLogger.js"; -import { getModelTargetFormat, getModelStrip, getModelUpstreamId, getModelType, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.js"; +import { getModelTargetFormat, getModelSupportedFormats, getModelStrip, getModelUpstreamId, getModelType, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.js"; import { PROVIDERS } from "../config/providers.js"; import { createErrorResult, parseUpstreamError, formatProviderError } from "../utils/error.js"; import { HTTP_STATUS, TOKEN_SAVER_HEADER } from "../config/runtimeConfig.js"; @@ -78,10 +78,20 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred const alias = PROVIDER_ID_TO_ALIAS[provider] || provider; const modelTargetFormat = getModelTargetFormat(alias, model); - // Multi-endpoint providers: pick transport matching sourceFormat → zero translation + // Multi-endpoint providers: pick transport matching sourceFormat → zero translation. + // Per-model guard: only use the transport when the model declares support for that + // sourceFormat — opencode-go models differ in endpoint support (kimi/glm only do + // /chat/completions), so without this guard a claude-format request would wrongly + // route kimi to /messages. + const modelSupportedFormats = getModelSupportedFormats(alias, model); const runtimeTransport = resolveTransport(provider, sourceFormat); - const targetFormat = modelTargetFormat || runtimeTransport?.format || getTargetFormat(provider, credentials); - if (runtimeTransport && credentials) credentials.runtimeTransport = runtimeTransport; + // Per-model guard: when a model declares supportedFormats, only use the + // sourceFormat-matched transport if that format is declared (opencode-go models + // differ — kimi/glm only do /chat/completions). Undeclared models keep the + // upstream default (use the transport), preserving behavior for glm/deepseek/... + const useTransport = (!modelSupportedFormats || modelSupportedFormats.includes(sourceFormat)) ? runtimeTransport : null; + const targetFormat = modelTargetFormat || useTransport?.format || getTargetFormat(provider, credentials); + if (useTransport && credentials) credentials.runtimeTransport = useTransport; const stripList = getModelStrip(alias, model); const upstreamModel = getModelUpstreamId(alias, model); diff --git a/open-sse/providers/models/schema.js b/open-sse/providers/models/schema.js index c14a3d58..73bf7139 100644 --- a/open-sse/providers/models/schema.js +++ b/open-sse/providers/models/schema.js @@ -38,3 +38,11 @@ export function modelStrip(model) { export function modelTargetFormat(model) { return model?.targetFormat || MODEL_DEFAULTS.targetFormat; } + +// Per-model declared upstream formats (e.g. ["openai", "claude"]). Guards the +// sourceFormat-matched transport for multi-endpoint providers whose models differ +// in endpoint support (opencode-go: kimi/glm only do /chat/completions, minimax/qwen +// also do /messages, deepseek also does /responses). +export function modelSupportedFormats(model) { + return model?.supportedFormats || null; +} diff --git a/open-sse/providers/registry/opencode-go.js b/open-sse/providers/registry/opencode-go.js index c980cc85..4b189ba8 100644 --- a/open-sse/providers/registry/opencode-go.js +++ b/open-sse/providers/registry/opencode-go.js @@ -22,20 +22,28 @@ export default { baseUrl: "https://opencode.ai/zen/go/v1/chat/completions", headers: {}, }, + // Multi-endpoint: pick the transport matching the client sourceFormat to skip + // translation. Guarded per-model by `supportedFormats` (see chatCore) because + // opencode-go models differ in endpoint support. + transports: [ + { format: "openai", baseUrl: "https://opencode.ai/zen/go/v1/chat/completions", auth: { combined: true, header: "Authorization", scheme: "bearer" } }, + { format: "claude", baseUrl: "https://opencode.ai/zen/go/v1/messages", auth: { combined: true, header: "x-api-key", scheme: "raw", anthropicVersion: true } }, + { format: "openai-responses", baseUrl: "https://opencode.ai/zen/go/v1/responses", auth: { combined: true, header: "Authorization", scheme: "bearer" } }, + ], models: [ - { id: "glm-5.2", name: "GLM 5.2" }, - { id: "glm-5.1", name: "GLM 5.1" }, - { id: "kimi-k2.7-code", name: "Kimi K2.7 Code" }, - { id: "kimi-k2.6", name: "Kimi K2.6" }, - { id: "deepseek-v4-pro", name: "DeepSeek V4 Pro" }, - { id: "deepseek-v4-flash", name: "DeepSeek V4 Flash" }, - { id: "mimo-v2.5", name: "MiMo V2.5" }, - { id: "mimo-v2.5-pro", name: "MiMo V2.5 Pro" }, - { id: "minimax-m3", name: "MiniMax M3", targetFormat: "claude" }, - { id: "minimax-m2.7", name: "MiniMax M2.7", targetFormat: "claude" }, - { id: "minimax-m2.5", name: "MiniMax M2.5", targetFormat: "claude" }, - { id: "qwen3.7-max", name: "Qwen 3.7 Max", targetFormat: "claude" }, - { id: "qwen3.7-plus", name: "Qwen 3.7 Plus", targetFormat: "claude" }, - { id: "qwen3.6-plus", name: "Qwen 3.6 Plus", targetFormat: "claude" }, + { id: "glm-5.2", name: "GLM 5.2", supportedFormats: ["openai"] }, + { id: "glm-5.1", name: "GLM 5.1", supportedFormats: ["openai"] }, + { id: "kimi-k2.7-code", name: "Kimi K2.7 Code", supportedFormats: ["openai"] }, + { id: "kimi-k2.6", name: "Kimi K2.6", supportedFormats: ["openai"] }, + { id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", supportedFormats: ["openai", "claude", "openai-responses"] }, + { id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", supportedFormats: ["openai", "claude", "openai-responses"] }, + { id: "mimo-v2.5", name: "MiMo V2.5", supportedFormats: ["openai"] }, + { id: "mimo-v2.5-pro", name: "MiMo V2.5 Pro", supportedFormats: ["openai"] }, + { id: "minimax-m3", name: "MiniMax M3", supportedFormats: ["openai", "claude"] }, + { id: "minimax-m2.7", name: "MiniMax M2.7", supportedFormats: ["openai", "claude"] }, + { id: "minimax-m2.5", name: "MiniMax M2.5", supportedFormats: ["openai", "claude"] }, + { id: "qwen3.7-max", name: "Qwen 3.7 Max", supportedFormats: ["openai", "claude"] }, + { id: "qwen3.7-plus", name: "Qwen 3.7 Plus", supportedFormats: ["openai", "claude"] }, + { id: "qwen3.6-plus", name: "Qwen 3.6 Plus", supportedFormats: ["openai", "claude"] }, ], }; diff --git a/tests/unit/opencode-go-models.test.js b/tests/unit/opencode-go-models.test.js index 10946ab1..fcbffae9 100644 --- a/tests/unit/opencode-go-models.test.js +++ b/tests/unit/opencode-go-models.test.js @@ -1,71 +1,97 @@ import { describe, expect, it } from "vitest"; -import { PROVIDER_MODELS, getModelTargetFormat } from "../../open-sse/config/providerModels.js"; -import { OpenCodeGoExecutor } from "../../open-sse/executors/opencode-go.js"; +import { PROVIDER_MODELS, getModelSupportedFormats } from "../../open-sse/config/providerModels.js"; +import { PROVIDERS } from "../../open-sse/config/providers.js"; +import { resolveTransport } from "../../open-sse/services/provider.js"; -const CHAT_MODELS = [ - "glm-5.2", - "glm-5.1", - // OpenCode Go docs' endpoint table currently says kimi-k2.7, but its - // config example and the live API use kimi-k2.7-code. - "kimi-k2.7-code", - "kimi-k2.6", - "deepseek-v4-pro", - "deepseek-v4-flash", - "mimo-v2.5", - "mimo-v2.5-pro", -]; +// Chat-only models (no /messages, no /responses support on opencode-go) +const CHAT_ONLY = ["glm-5.2", "glm-5.1", "kimi-k2.7-code", "kimi-k2.6", "mimo-v2.5", "mimo-v2.5-pro"]; +// Models that also expose the Anthropic /messages endpoint +const CLAUDE_CAPABLE = ["minimax-m3", "minimax-m2.7", "minimax-m2.5", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus"]; +// Models that also expose the OpenAI /responses endpoint +const RESPONSES_CAPABLE = ["deepseek-v4-pro", "deepseek-v4-flash"]; -const MESSAGES_MODELS = [ - "minimax-m3", - "minimax-m2.7", - "minimax-m2.5", - "qwen3.7-max", - "qwen3.7-plus", - "qwen3.6-plus", -]; +// Mirror of chatCore's per-model transport guard: use the sourceFormat-matched +// transport only when the model declares support for that sourceFormat. +function pickTransport(provider, sourceFormat, alias, model) { + const supported = getModelSupportedFormats(alias, model); + const rt = resolveTransport(provider, sourceFormat); + return supported?.includes(sourceFormat) ? rt : null; +} -describe("OpenCode Go official model catalog", () => { - it("matches the documented OpenCode Go model IDs", () => { - const ids = (PROVIDER_MODELS["opencode-go"] || []).map((model) => model.id); - - expect(ids).toEqual([...CHAT_MODELS, ...MESSAGES_MODELS]); +describe("OpenCode Go model catalog", () => { + it("matches the documented model IDs", () => { + const ids = (PROVIDER_MODELS["opencode-go"] || []).map((m) => m.id); + expect(ids).toEqual([ + "glm-5.2", "glm-5.1", "kimi-k2.7-code", "kimi-k2.6", + "deepseek-v4-pro", "deepseek-v4-flash", + "mimo-v2.5", "mimo-v2.5-pro", + "minimax-m3", "minimax-m2.7", "minimax-m2.5", + "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus", + ]); }); +}); - it("marks documented Qwen and MiniMax models as Anthropic messages format", () => { - for (const model of MESSAGES_MODELS) { - expect(getModelTargetFormat("opencode-go", model)).toBe("claude"); +describe("OpenCode Go per-model supportedFormats", () => { + it("declares [openai, claude] for MiniMax + Qwen models", () => { + for (const m of CLAUDE_CAPABLE) { + expect(getModelSupportedFormats("opencode-go", m)).toEqual(["openai", "claude"]); } }); - it("keeps GLM, Kimi, DeepSeek, and MiMo on OpenAI-compatible chat format", () => { - for (const model of CHAT_MODELS) { - expect(getModelTargetFormat("opencode-go", model)).toBeNull(); + it("declares [openai, claude, openai-responses] for DeepSeek models", () => { + for (const m of RESPONSES_CAPABLE) { + expect(getModelSupportedFormats("opencode-go", m)).toEqual(["openai", "claude", "openai-responses"]); + } + }); + + it("declares [openai] only for chat-only models (GLM/Kimi/MiMo) → guards /messages routing", () => { + for (const m of CHAT_ONLY) { + expect(getModelSupportedFormats("opencode-go", m)).toEqual(["openai"]); } }); }); -describe("OpenCode Go endpoint routing", () => { - it("routes Qwen and MiniMax models to the messages endpoint with x-api-key auth", () => { - const executor = new OpenCodeGoExecutor(); +describe("OpenCode Go multi-endpoint transports", () => { + it("declares openai / claude / openai-responses transports", () => { + const formats = (PROVIDERS["opencode-go"].transports || []).map((t) => t.format); + expect(formats).toEqual(["openai", "claude", "openai-responses"]); + }); - for (const model of MESSAGES_MODELS) { - expect(executor.buildUrl(model)).toBe("https://opencode.ai/zen/go/v1/messages"); - const headers = executor.buildHeaders({ apiKey: "sk-test" }, false); - expect(headers["x-api-key"]).toBe("sk-test"); - expect(headers["anthropic-version"]).toBeDefined(); - expect(headers.Authorization).toBeUndefined(); + it("resolveTransport picks the endpoint matching the client sourceFormat", () => { + expect(resolveTransport("opencode-go", "claude").baseUrl).toBe("https://opencode.ai/zen/go/v1/messages"); + expect(resolveTransport("opencode-go", "openai-responses").baseUrl).toBe("https://opencode.ai/zen/go/v1/responses"); + expect(resolveTransport("opencode-go", "openai").baseUrl).toBe("https://opencode.ai/zen/go/v1/chat/completions"); + }); + + it("uses x-api-key + anthropicVersion on the claude transport", () => { + const t = resolveTransport("opencode-go", "claude"); + expect(t.auth.header).toBe("x-api-key"); + expect(t.auth.anthropicVersion).toBe(true); + }); +}); + +describe("OpenCode Go per-model transport guard (chatCore logic)", () => { + it("routes MiniMax/Qwen + claude-format client to /messages", () => { + for (const m of CLAUDE_CAPABLE) { + expect(pickTransport("opencode-go", "claude", "opencode-go", m)?.baseUrl).toBe("https://opencode.ai/zen/go/v1/messages"); } }); - it("routes GLM, Kimi, DeepSeek, and MiMo models to chat/completions with bearer auth", () => { - const executor = new OpenCodeGoExecutor(); + it("does NOT route chat-only models to /messages on a claude-format request", () => { + for (const m of CHAT_ONLY) { + expect(pickTransport("opencode-go", "claude", "opencode-go", m)).toBeNull(); + } + }); - for (const model of CHAT_MODELS) { - expect(executor.buildUrl(model)).toBe("https://opencode.ai/zen/go/v1/chat/completions"); - const headers = executor.buildHeaders({ apiKey: "sk-test" }, false); - expect(headers.Authorization).toBe("Bearer sk-test"); - expect(headers["x-api-key"]).toBeUndefined(); - expect(headers["anthropic-version"]).toBeUndefined(); + it("routes DeepSeek + responses-format client to /responses", () => { + for (const m of RESPONSES_CAPABLE) { + expect(pickTransport("opencode-go", "openai-responses", "opencode-go", m)?.baseUrl).toBe("https://opencode.ai/zen/go/v1/responses"); + } + }); + + it("does NOT route MiniMax (no responses support) to /responses", () => { + for (const m of CLAUDE_CAPABLE) { + expect(pickTransport("opencode-go", "openai-responses", "opencode-go", m)).toBeNull(); } }); });