feat(opencode-go): route by request format via transports + per-model guard

opencode-go hard-coded targetFormat: claude per model, so every client
format was force-routed to /messages (Codex/OpenAI clients paid a lossy
Responses->OpenAI->Claude double translation). Declare the existing
upstream multi-endpoint transports [openai, claude, openai-responses]
and guard per model via registry supportedFormats: kimi/glm/mimo only
support /chat/completions, minimax/qwen add /messages, deepseek adds
/responses. Undeclared models keep the upstream default.

Drop the bespoke OpenCodeGoExecutor (its shared _lastModel cache could
cross auth headers between concurrent requests); DefaultExecutor already
consumes runtimeTransport and injects reasoning content.
This commit is contained in:
KiMelody 2026-08-14 16:51:48 +07:00 committed by decolua
parent 27f3710c8b
commit e1115e2839
7 changed files with 129 additions and 121 deletions

View file

@ -2,7 +2,7 @@ import { PROVIDERS } from "./providers.js";
import REGISTRY from "../providers/registry/index.js"; 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, 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 } from "../providers/models/helpers.js";
export { PROVIDER_MODELS }; export { PROVIDER_MODELS };
@ -54,6 +54,14 @@ export function getModelTargetFormat(aliasOrId, modelId) {
return modelTargetFormat(findModel(models, modelId, aliasOrId)); 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) { export function getModelType(aliasOrId, modelId) {
const models = PROVIDER_MODELS[aliasOrId]; const models = PROVIDER_MODELS[aliasOrId];
if (!models) return null; if (!models) return null;

View file

@ -10,7 +10,6 @@ import { CodexExecutor } from "./codex.js";
import { CursorExecutor } from "./cursor.js"; import { CursorExecutor } from "./cursor.js";
import { VertexExecutor } from "./vertex.js"; import { VertexExecutor } from "./vertex.js";
import { OpenCodeExecutor } from "./opencode.js"; import { OpenCodeExecutor } from "./opencode.js";
import { OpenCodeGoExecutor } from "./opencode-go.js";
import { GrokWebExecutor } from "./grok-web.js"; import { GrokWebExecutor } from "./grok-web.js";
import { GrokCliExecutor } from "./grok-cli.js"; import { GrokCliExecutor } from "./grok-cli.js";
import { PerplexityWebExecutor } from "./perplexity-web.js"; import { PerplexityWebExecutor } from "./perplexity-web.js";
@ -41,7 +40,6 @@ const executors = {
vertex: new VertexExecutor("vertex"), vertex: new VertexExecutor("vertex"),
"vertex-partner": new VertexExecutor("vertex-partner"), "vertex-partner": new VertexExecutor("vertex-partner"),
opencode: new OpenCodeExecutor(), opencode: new OpenCodeExecutor(),
"opencode-go": new OpenCodeGoExecutor(),
"grok-web": new GrokWebExecutor(), "grok-web": new GrokWebExecutor(),
"grok-cli": new GrokCliExecutor(), "grok-cli": new GrokCliExecutor(),
gcli: new GrokCliExecutor(), // Alias gcli: new GrokCliExecutor(), // Alias
@ -86,7 +84,6 @@ export { CursorExecutor } from "./cursor.js";
export { VertexExecutor } from "./vertex.js"; export { VertexExecutor } from "./vertex.js";
export { DefaultExecutor } from "./default.js"; export { DefaultExecutor } from "./default.js";
export { OpenCodeExecutor } from "./opencode.js"; export { OpenCodeExecutor } from "./opencode.js";
export { OpenCodeGoExecutor } from "./opencode-go.js";
export { GrokWebExecutor } from "./grok-web.js"; export { GrokWebExecutor } from "./grok-web.js";
export { GrokCliExecutor } from "./grok-cli.js"; export { GrokCliExecutor } from "./grok-cli.js";
export { PerplexityWebExecutor } from "./perplexity-web.js"; export { PerplexityWebExecutor } from "./perplexity-web.js";

View file

@ -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 });
}
}

View file

@ -6,7 +6,7 @@ import { normalizeClaudePassthrough, anchorClaudeCache } from "../translator/for
import { createStreamController } from "../utils/streamHandler.js"; import { createStreamController } from "../utils/streamHandler.js";
import { refreshWithRetry } from "../services/tokenRefresh.js"; import { refreshWithRetry } from "../services/tokenRefresh.js";
import { createRequestLogger } from "../utils/requestLogger.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 { PROVIDERS } from "../config/providers.js";
import { createErrorResult, parseUpstreamError, formatProviderError } from "../utils/error.js"; import { createErrorResult, parseUpstreamError, formatProviderError } from "../utils/error.js";
import { HTTP_STATUS, TOKEN_SAVER_HEADER } from "../config/runtimeConfig.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 alias = PROVIDER_ID_TO_ALIAS[provider] || provider;
const modelTargetFormat = getModelTargetFormat(alias, model); 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 runtimeTransport = resolveTransport(provider, sourceFormat);
const targetFormat = modelTargetFormat || runtimeTransport?.format || getTargetFormat(provider, credentials); // Per-model guard: when a model declares supportedFormats, only use the
if (runtimeTransport && credentials) credentials.runtimeTransport = runtimeTransport; // 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 stripList = getModelStrip(alias, model);
const upstreamModel = getModelUpstreamId(alias, model); const upstreamModel = getModelUpstreamId(alias, model);

View file

@ -38,3 +38,11 @@ export function modelStrip(model) {
export function modelTargetFormat(model) { export function modelTargetFormat(model) {
return model?.targetFormat || MODEL_DEFAULTS.targetFormat; 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;
}

View file

@ -22,20 +22,28 @@ export default {
baseUrl: "https://opencode.ai/zen/go/v1/chat/completions", baseUrl: "https://opencode.ai/zen/go/v1/chat/completions",
headers: {}, 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: [ models: [
{ id: "glm-5.2", name: "GLM 5.2" }, { id: "glm-5.2", name: "GLM 5.2", supportedFormats: ["openai"] },
{ id: "glm-5.1", name: "GLM 5.1" }, { id: "glm-5.1", name: "GLM 5.1", supportedFormats: ["openai"] },
{ id: "kimi-k2.7-code", name: "Kimi K2.7 Code" }, { id: "kimi-k2.7-code", name: "Kimi K2.7 Code", supportedFormats: ["openai"] },
{ id: "kimi-k2.6", name: "Kimi K2.6" }, { id: "kimi-k2.6", name: "Kimi K2.6", supportedFormats: ["openai"] },
{ id: "deepseek-v4-pro", name: "DeepSeek V4 Pro" }, { id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", supportedFormats: ["openai", "claude", "openai-responses"] },
{ id: "deepseek-v4-flash", name: "DeepSeek V4 Flash" }, { id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", supportedFormats: ["openai", "claude", "openai-responses"] },
{ id: "mimo-v2.5", name: "MiMo V2.5" }, { id: "mimo-v2.5", name: "MiMo V2.5", supportedFormats: ["openai"] },
{ id: "mimo-v2.5-pro", name: "MiMo V2.5 Pro" }, { id: "mimo-v2.5-pro", name: "MiMo V2.5 Pro", supportedFormats: ["openai"] },
{ id: "minimax-m3", name: "MiniMax M3", targetFormat: "claude" }, { id: "minimax-m3", name: "MiniMax M3", supportedFormats: ["openai", "claude"] },
{ id: "minimax-m2.7", name: "MiniMax M2.7", targetFormat: "claude" }, { id: "minimax-m2.7", name: "MiniMax M2.7", supportedFormats: ["openai", "claude"] },
{ id: "minimax-m2.5", name: "MiniMax M2.5", targetFormat: "claude" }, { id: "minimax-m2.5", name: "MiniMax M2.5", supportedFormats: ["openai", "claude"] },
{ id: "qwen3.7-max", name: "Qwen 3.7 Max", targetFormat: "claude" }, { id: "qwen3.7-max", name: "Qwen 3.7 Max", supportedFormats: ["openai", "claude"] },
{ id: "qwen3.7-plus", name: "Qwen 3.7 Plus", targetFormat: "claude" }, { id: "qwen3.7-plus", name: "Qwen 3.7 Plus", supportedFormats: ["openai", "claude"] },
{ id: "qwen3.6-plus", name: "Qwen 3.6 Plus", targetFormat: "claude" }, { id: "qwen3.6-plus", name: "Qwen 3.6 Plus", supportedFormats: ["openai", "claude"] },
], ],
}; };

View file

@ -1,71 +1,97 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { PROVIDER_MODELS, getModelTargetFormat } from "../../open-sse/config/providerModels.js"; import { PROVIDER_MODELS, getModelSupportedFormats } from "../../open-sse/config/providerModels.js";
import { OpenCodeGoExecutor } from "../../open-sse/executors/opencode-go.js"; import { PROVIDERS } from "../../open-sse/config/providers.js";
import { resolveTransport } from "../../open-sse/services/provider.js";
const CHAT_MODELS = [ // Chat-only models (no /messages, no /responses support on opencode-go)
"glm-5.2", const CHAT_ONLY = ["glm-5.2", "glm-5.1", "kimi-k2.7-code", "kimi-k2.6", "mimo-v2.5", "mimo-v2.5-pro"];
"glm-5.1", // Models that also expose the Anthropic /messages endpoint
// OpenCode Go docs' endpoint table currently says kimi-k2.7, but its const CLAUDE_CAPABLE = ["minimax-m3", "minimax-m2.7", "minimax-m2.5", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus"];
// config example and the live API use kimi-k2.7-code. // Models that also expose the OpenAI /responses endpoint
"kimi-k2.7-code", const RESPONSES_CAPABLE = ["deepseek-v4-pro", "deepseek-v4-flash"];
"kimi-k2.6",
"deepseek-v4-pro",
"deepseek-v4-flash",
"mimo-v2.5",
"mimo-v2.5-pro",
];
const MESSAGES_MODELS = [ // Mirror of chatCore's per-model transport guard: use the sourceFormat-matched
"minimax-m3", // transport only when the model declares support for that sourceFormat.
"minimax-m2.7", function pickTransport(provider, sourceFormat, alias, model) {
"minimax-m2.5", const supported = getModelSupportedFormats(alias, model);
"qwen3.7-max", const rt = resolveTransport(provider, sourceFormat);
"qwen3.7-plus", return supported?.includes(sourceFormat) ? rt : null;
"qwen3.6-plus", }
];
describe("OpenCode Go official model catalog", () => { describe("OpenCode Go model catalog", () => {
it("matches the documented OpenCode Go model IDs", () => { it("matches the documented model IDs", () => {
const ids = (PROVIDER_MODELS["opencode-go"] || []).map((model) => model.id); const ids = (PROVIDER_MODELS["opencode-go"] || []).map((m) => m.id);
expect(ids).toEqual([
expect(ids).toEqual([...CHAT_MODELS, ...MESSAGES_MODELS]); "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", () => { describe("OpenCode Go per-model supportedFormats", () => {
for (const model of MESSAGES_MODELS) { it("declares [openai, claude] for MiniMax + Qwen models", () => {
expect(getModelTargetFormat("opencode-go", model)).toBe("claude"); 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", () => { it("declares [openai, claude, openai-responses] for DeepSeek models", () => {
for (const model of CHAT_MODELS) { for (const m of RESPONSES_CAPABLE) {
expect(getModelTargetFormat("opencode-go", model)).toBeNull(); 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", () => { describe("OpenCode Go multi-endpoint transports", () => {
it("routes Qwen and MiniMax models to the messages endpoint with x-api-key auth", () => { it("declares openai / claude / openai-responses transports", () => {
const executor = new OpenCodeGoExecutor(); const formats = (PROVIDERS["opencode-go"].transports || []).map((t) => t.format);
expect(formats).toEqual(["openai", "claude", "openai-responses"]);
});
for (const model of MESSAGES_MODELS) { it("resolveTransport picks the endpoint matching the client sourceFormat", () => {
expect(executor.buildUrl(model)).toBe("https://opencode.ai/zen/go/v1/messages"); expect(resolveTransport("opencode-go", "claude").baseUrl).toBe("https://opencode.ai/zen/go/v1/messages");
const headers = executor.buildHeaders({ apiKey: "sk-test" }, false); expect(resolveTransport("opencode-go", "openai-responses").baseUrl).toBe("https://opencode.ai/zen/go/v1/responses");
expect(headers["x-api-key"]).toBe("sk-test"); expect(resolveTransport("opencode-go", "openai").baseUrl).toBe("https://opencode.ai/zen/go/v1/chat/completions");
expect(headers["anthropic-version"]).toBeDefined(); });
expect(headers.Authorization).toBeUndefined();
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", () => { it("does NOT route chat-only models to /messages on a claude-format request", () => {
const executor = new OpenCodeGoExecutor(); for (const m of CHAT_ONLY) {
expect(pickTransport("opencode-go", "claude", "opencode-go", m)).toBeNull();
}
});
for (const model of CHAT_MODELS) { it("routes DeepSeek + responses-format client to /responses", () => {
expect(executor.buildUrl(model)).toBe("https://opencode.ai/zen/go/v1/chat/completions"); for (const m of RESPONSES_CAPABLE) {
const headers = executor.buildHeaders({ apiKey: "sk-test" }, false); expect(pickTransport("opencode-go", "openai-responses", "opencode-go", m)?.baseUrl).toBe("https://opencode.ai/zen/go/v1/responses");
expect(headers.Authorization).toBe("Bearer sk-test"); }
expect(headers["x-api-key"]).toBeUndefined(); });
expect(headers["anthropic-version"]).toBeUndefined();
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();
} }
}); });
}); });