From 8dfc194405562f563ebd932dbc3dab4b7cae9bf5 Mon Sep 17 00:00:00 2001 From: Griko Nibras Date: Thu, 15 Oct 2026 09:07:59 +0700 Subject: [PATCH 1/2] fix: resolve model-specific OpenCode inference routes --- .changeset/tiny-days-retire.md | 5 +++++ docs/models.md | 8 ++++++++ src/models/catalog.test.ts | 16 ++++++++++++++++ src/transport/protocol.test.ts | 22 +++++++++++++++++++++- src/transport/protocol.ts | 15 ++++++++++++--- 5 files changed, 62 insertions(+), 4 deletions(-) create mode 100644 .changeset/tiny-days-retire.md diff --git a/.changeset/tiny-days-retire.md b/.changeset/tiny-days-retire.md new file mode 100644 index 0000000..7c41dc7 --- /dev/null +++ b/.changeset/tiny-days-retire.md @@ -0,0 +1,5 @@ +--- +"opencode-bridge-for-copilot-chat": patch +--- + +Resolve OpenCode gateway URLs by model protocol, including Gemini on Go and metadata fallback. diff --git a/docs/models.md b/docs/models.md index 9ff65fb..33435e5 100644 --- a/docs/models.md +++ b/docs/models.md @@ -16,6 +16,14 @@ organization's configuration on the Console server and never show public or other-organization models. Console service-account key entries use the public discovery catalog like Go. +Requests resolve each model's SDK metadata to its wire protocol, with model-family +fallbacks when metadata is unavailable. Generic OpenCode gateway bases are mapped +to the canonical `/inference` routes: `openai/v1` for Chat Completions and Responses, +`anthropic/v1` for Messages, and `google/v1beta/models` for Gemini, with `/go` after +`/inference` for Go models. This includes Gemini contributor models on Go, whose +Google route is absent from the legacy `/zen/go/v1` gateway. Custom endpoints and +explicit protocol-specific bases are preserved. + Fields the live responses omit are enriched from the canonical `opencode` and `opencode-go` providers in a six-hour models.dev snapshot (`https://models.dev/api.json`) stored in VS Code `globalState`. Stale metadata diff --git a/src/models/catalog.test.ts b/src/models/catalog.test.ts index 16cc908..aa5b35d 100644 --- a/src/models/catalog.test.ts +++ b/src/models/catalog.test.ts @@ -1,6 +1,22 @@ import assert from "node:assert/strict"; import test from "node:test"; import { catalogScope, ModelCatalog, modelsFromProvider } from "./catalog"; +import { endpointUrl } from "../transport/protocol"; + +test("routes Go Gemini discovery with fresh, missing, or unavailable metadata", async () => { + for (const metadata of [ + { "opencode-go": { npm: "@ai-sdk/openai-compatible", api: "https://opencode.ai/inference/go/v1", models: { "gemini-4-argon-contributor": { provider: { npm: "@ai-sdk/google" } } } } }, + { "opencode-go": { models: {} } }, + undefined, + ]) { + const catalog = new ModelCatalog(async (input) => String(input).endsWith("/models") + ? Response.json({ data: [{ id: "gemini-4-argon-contributor" }] }) + : metadata ? Response.json(metadata) : new Response("unavailable", { status: 503 })); + const [model] = await catalog.refresh("go", undefined, false); + assert.equal(model.endpoint, "google"); + assert.equal(endpointUrl(model.baseUrl, model.endpoint, model.rawModelId), "https://opencode.ai/inference/go/google/v1beta/models/gemini-4-argon-contributor:streamGenerateContent?alt=sse"); + } +}); test("filters deprecated and paid Console models when free-only is enabled", () => { const models = modelsFromProvider("console", "opencode", { api: "https://example.test/v1" }, { diff --git a/src/transport/protocol.test.ts b/src/transport/protocol.test.ts index ea52d93..ac9d8c1 100644 --- a/src/transport/protocol.test.ts +++ b/src/transport/protocol.test.ts @@ -49,6 +49,7 @@ test("routes known OpenCode model families to their native gateway shape", () => assert.equal(resolveEndpointKind("gpt-6-astra", "console"), "responses"); assert.equal(resolveEndpointKind("claude-fable-5", "console"), "messages"); assert.equal(resolveEndpointKind("gemini-3.5-flash", "console"), "google"); + assert.equal(resolveEndpointKind("gemini-4-argon-contributor", "go"), "google"); assert.equal(resolveEndpointKind("grok-4.6", "go"), "responses"); assert.equal(resolveEndpointKind("grok-build-0.1", "console"), "responses"); assert.equal(resolveEndpointKind("muse-spark-1.3", "console"), "responses"); @@ -88,7 +89,6 @@ test("keeps undiscovered families on the shared chat completions shape", () => { assert.equal(resolveEndpointKind("omen-alpha", "go"), "chat-completions"); assert.equal(resolveEndpointKind("big-pickle", "console"), "chat-completions"); assert.equal(resolveEndpointKind("nemotron-3-ultra-free", "console"), "chat-completions"); - assert.equal(resolveEndpointKind("gemini-3.5-flash", "go"), "chat-completions"); }); test("lets catalog package names override family heuristics", () => { @@ -97,3 +97,23 @@ test("lets catalog package names override family heuristics", () => { assert.equal(resolveEndpointKind("custom-model", "go", "@ai-sdk/openai"), "responses"); assert.equal(resolveEndpointKind("grok-4.6", "go", "@ai-sdk/openai-compatible"), "responses"); }); + +test("resolves generic OpenCode gateway bases to protocol-specific inference routes", () => { + for (const mode of ["console", "go"] as const) { + const scope = mode === "go" ? "/go" : ""; + for (const base of [apiBaseForMode(mode), `https://opencode.ai/inference${scope}/v1/`]) { + assert.equal(endpointUrl(base, "chat-completions", "kimi-k3"), `https://opencode.ai/inference${scope}/openai/v1/chat/completions`); + assert.equal(endpointUrl(base, "responses", "gpt-6-luna"), `https://opencode.ai/inference${scope}/openai/v1/responses`); + assert.equal(endpointUrl(base, "messages", "qwen3.8-max"), `https://opencode.ai/inference${scope}/anthropic/v1/messages`); + assert.equal(endpointUrl(base, "google", "gemini-4-argon-contributor"), `https://opencode.ai/inference${scope}/google/v1beta/models/gemini-4-argon-contributor:streamGenerateContent?alt=sse`); + } + } +}); + +test("preserves custom and explicit protocol endpoints and encodes Google model ids", () => { + assert.equal(endpointUrl("https://example.test/v1/", "messages", "custom"), "https://example.test/v1/messages"); + for (const base of ["https://example.test/v1beta", "https://example.test/v1beta/models/"]) { + assert.equal(endpointUrl(base, "google", "model/id"), "https://example.test/v1beta/models/model%2Fid:streamGenerateContent?alt=sse"); + } + assert.equal(endpointUrl("https://opencode.ai/inference/go/google/v1beta", "google", "gemini-4-argon-contributor"), "https://opencode.ai/inference/go/google/v1beta/models/gemini-4-argon-contributor:streamGenerateContent?alt=sse"); +}); diff --git a/src/transport/protocol.ts b/src/transport/protocol.ts index 43a4599..5279da3 100644 --- a/src/transport/protocol.ts +++ b/src/transport/protocol.ts @@ -84,14 +84,23 @@ export function resolveEndpointKind( if (/^qwen-?3\.8-max$/i.test(modelId)) return mode === "go" ? "messages" : "chat-completions"; if (/^qwen/i.test(modelId)) return "messages"; if (mode === "go" && /^minimax-/i.test(modelId)) return "messages"; - if (mode === "console" && /^gemini-/i.test(modelId)) return "google"; + if (/^gemini-/i.test(modelId)) return "google"; return "chat-completions"; } export function endpointUrl(baseUrl: string, endpoint: EndpointKind, modelId: string): string { - const base = baseUrl.replace(/\/+$/, ""); + const base = gatewayBaseUrl(baseUrl, endpoint); if (endpoint === "messages") return `${base}/messages`; if (endpoint === "responses") return `${base}/responses`; - if (endpoint === "google") return `${base}/${encodeURIComponent(modelId)}:streamGenerateContent?alt=sse`; + if (endpoint === "google") return `${base.endsWith("/models") ? base : `${base}/models`}/${encodeURIComponent(modelId)}:streamGenerateContent?alt=sse`; return `${base}/chat/completions`; } + +/** Generic catalog bases are not inference roots for every protocol. */ +function gatewayBaseUrl(baseUrl: string, endpoint: EndpointKind): string { + const base = baseUrl.replace(/\/+$/, ""); + const gateway = /^https:\/\/opencode\.ai\/(?:zen|inference)(\/go)?\/v1$/.exec(base); + if (!gateway) return base; + const protocol = endpoint === "google" ? "google/v1beta" : endpoint === "messages" ? "anthropic/v1" : "openai/v1"; + return `https://opencode.ai/inference${gateway[1] ?? ""}/${protocol}`; +} From 8d39034adfca5dd1b0e62f7159e16cb83cc87709 Mon Sep 17 00:00:00 2001 From: Griko Nibras Date: Thu, 15 Oct 2026 09:19:37 +0700 Subject: [PATCH 2/2] refactor: centralize canonical OpenCode URL constants --- .changeset/tiny-days-retire.md | 2 +- docs/models.md | 9 +++++---- src/autocomplete/engine.test.ts | 10 +++++----- src/models/catalog.test.ts | 3 +++ src/transport/protocol.test.ts | 13 ++++++++++--- src/transport/protocol.ts | 30 +++++++++++++++++++++--------- 6 files changed, 45 insertions(+), 22 deletions(-) diff --git a/.changeset/tiny-days-retire.md b/.changeset/tiny-days-retire.md index 7c41dc7..1de74c9 100644 --- a/.changeset/tiny-days-retire.md +++ b/.changeset/tiny-days-retire.md @@ -2,4 +2,4 @@ "opencode-bridge-for-copilot-chat": patch --- -Resolve OpenCode gateway URLs by model protocol, including Gemini on Go and metadata fallback. +Resolve OpenCode gateway URLs by model protocol, including Gemini on Go and metadata fallback. Derive discovery and inference URLs from shared canonical OpenCode constants while supporting legacy cached gateway bases. diff --git a/docs/models.md b/docs/models.md index 33435e5..c15aaea 100644 --- a/docs/models.md +++ b/docs/models.md @@ -3,10 +3,9 @@ ## Live metadata The extension discovers the catalog available to each configured provider entry -from that gateway's live `models` endpoint: `https://opencode.ai/zen/v1/models` -for Console and `https://opencode.ai/zen/go/v1/models` for Go. (The paths retain -the historical `zen` segment upstream; the former Zen gateway is now Console's -pay-as-you-go inference service.) Live responses +from that gateway's live `models` endpoint: `https://opencode.ai/inference/v1/models` +for Console and `https://opencode.ai/inference/go/v1/models` for Go. The former +Zen gateway is now Console's pay-as-you-go inference service. Live responses provide names, context windows, maximum output lengths, reasoning support and effort options, image input, and tool-calling support used by Copilot Chat. Console free-tier entries can be filtered to cost-free models only. @@ -23,6 +22,8 @@ to the canonical `/inference` routes: `openai/v1` for Chat Completions and Respo `/inference` for Go models. This includes Gemini contributor models on Go, whose Google route is absent from the legacy `/zen/go/v1` gateway. Custom endpoints and explicit protocol-specific bases are preserved. +The Console server, discovery URLs, and inference URLs share one OpenCode origin +constant. Legacy `/zen` bases in cached descriptors are normalized on use. Fields the live responses omit are enriched from the canonical `opencode` and `opencode-go` providers in a six-hour models.dev snapshot diff --git a/src/autocomplete/engine.test.ts b/src/autocomplete/engine.test.ts index 2b66110..11808a7 100644 --- a/src/autocomplete/engine.test.ts +++ b/src/autocomplete/engine.test.ts @@ -24,7 +24,7 @@ test("parses visible content deltas and ignores reasoning-only events", () => { test("streams a completion and sends the thinking-off payload", async () => { const requests: Array<{ url: string; init: RequestInit }> = []; const engine = new ChatCompletionEngine({ - url: "https://opencode.ai/zen/go/v1/chat/completions", + url: "https://opencode.ai/inference/go/openai/v1/chat/completions", headers: { Authorization: "Bearer test-key" }, timeoutMs: 1_000, fetcher: async (url, init) => { @@ -50,7 +50,7 @@ test("streams a completion and sends the thinking-off payload", async () => { max_tokens: number; temperature: number; }; - assert.equal(requests[0]?.url, "https://opencode.ai/zen/go/v1/chat/completions"); + assert.equal(requests[0]?.url, "https://opencode.ai/inference/go/openai/v1/chat/completions"); assert.equal(body.model, "qwen3.7-plus"); assert.equal(body.enable_thinking, false); assert.equal(body.stream, true); @@ -64,7 +64,7 @@ test("streams a completion and sends the thinking-off payload", async () => { test("never echoes upstream error bodies", async () => { const engine = new ChatCompletionEngine({ - url: "https://opencode.ai/zen/go/v1/chat/completions", + url: "https://opencode.ai/inference/go/openai/v1/chat/completions", headers: {}, timeoutMs: 1_000, fetcher: async () => new Response('{"error":"prompt context leak"}', { status: 401 }), @@ -78,7 +78,7 @@ test("never echoes upstream error bodies", async () => { test("aborted caller signal surfaces as a quiet no-result", async () => { const controller = new AbortController(); const engine = new ChatCompletionEngine({ - url: "https://opencode.ai/zen/go/v1/chat/completions", + url: "https://opencode.ai/inference/go/openai/v1/chat/completions", headers: {}, timeoutMs: 1_000, fetcher: async (_url, init) => { @@ -96,7 +96,7 @@ test("aborted caller signal surfaces as a quiet no-result", async () => { test("times out long requests via the request timeout", async () => { const engine = new ChatCompletionEngine({ - url: "https://opencode.ai/zen/go/v1/chat/completions", + url: "https://opencode.ai/inference/go/openai/v1/chat/completions", headers: {}, timeoutMs: 30, fetcher: (async (_url: string | URL | Request, init?: RequestInit) => { diff --git a/src/models/catalog.test.ts b/src/models/catalog.test.ts index aa5b35d..551fcd3 100644 --- a/src/models/catalog.test.ts +++ b/src/models/catalog.test.ts @@ -76,8 +76,10 @@ test("uses authenticated live models and enriches fields from models.dev", async test("uses the live public catalog without credentials and excludes stale metadata-only models", async () => { let authorization: string | null | undefined; + let discoveryUrl: string | undefined; const catalog = new ModelCatalog(async (input, init) => { if (String(input).endsWith("/models")) { + discoveryUrl = String(input); authorization = new Headers(init?.headers).get("authorization"); return Response.json({ data: [{ id: "current" }] }); } @@ -90,6 +92,7 @@ test("uses the live public catalog without credentials and excludes stale metada assert.deepEqual(models.map((model) => model.id), ["current"]); assert.equal(models[0].name, "Current"); assert.equal(authorization, null); + assert.equal(discoveryUrl, "https://opencode.ai/inference/v1/models"); }); test("enriches discovery-only Go models with supplemental metadata", async () => { diff --git a/src/transport/protocol.test.ts b/src/transport/protocol.test.ts index ac9d8c1..6d08e47 100644 --- a/src/transport/protocol.test.ts +++ b/src/transport/protocol.test.ts @@ -7,8 +7,8 @@ test("maps legacy zen mode values onto the Console gateway", () => { assert.equal(normalizeMode("console"), "console"); assert.equal(normalizeMode("go"), "go"); assert.equal(normalizeMode(undefined), "console"); - assert.equal(apiBaseForMode("console"), "https://opencode.ai/zen/v1"); - assert.equal(apiBaseForMode("go"), "https://opencode.ai/zen/go/v1"); + assert.equal(apiBaseForMode("console"), "https://opencode.ai/inference/v1"); + assert.equal(apiBaseForMode("go"), "https://opencode.ai/inference/go/v1"); }); test("resolves Console device verification URLs onto the /console subpath", () => { @@ -98,10 +98,16 @@ test("lets catalog package names override family heuristics", () => { assert.equal(resolveEndpointKind("grok-4.6", "go", "@ai-sdk/openai-compatible"), "responses"); }); +test("keeps MiniMax fallbacks gateway-specific while honoring model SDK metadata", () => { + assert.equal(resolveEndpointKind("minimax-m3", "console"), "chat-completions"); + assert.equal(resolveEndpointKind("minimax-m3", "go"), "messages"); + assert.equal(resolveEndpointKind("minimax-m3-free", "console", "@ai-sdk/anthropic"), "messages"); +}); + test("resolves generic OpenCode gateway bases to protocol-specific inference routes", () => { for (const mode of ["console", "go"] as const) { const scope = mode === "go" ? "/go" : ""; - for (const base of [apiBaseForMode(mode), `https://opencode.ai/inference${scope}/v1/`]) { + for (const base of [apiBaseForMode(mode), `https://opencode.ai/zen${scope}/v1/`]) { assert.equal(endpointUrl(base, "chat-completions", "kimi-k3"), `https://opencode.ai/inference${scope}/openai/v1/chat/completions`); assert.equal(endpointUrl(base, "responses", "gpt-6-luna"), `https://opencode.ai/inference${scope}/openai/v1/responses`); assert.equal(endpointUrl(base, "messages", "qwen3.8-max"), `https://opencode.ai/inference${scope}/anthropic/v1/messages`); @@ -116,4 +122,5 @@ test("preserves custom and explicit protocol endpoints and encodes Google model assert.equal(endpointUrl(base, "google", "model/id"), "https://example.test/v1beta/models/model%2Fid:streamGenerateContent?alt=sse"); } assert.equal(endpointUrl("https://opencode.ai/inference/go/google/v1beta", "google", "gemini-4-argon-contributor"), "https://opencode.ai/inference/go/google/v1beta/models/gemini-4-argon-contributor:streamGenerateContent?alt=sse"); + assert.equal(endpointUrl("https://opencode.ai.example.test/inference/v1", "responses", "custom"), "https://opencode.ai.example.test/inference/v1/responses"); }); diff --git a/src/transport/protocol.ts b/src/transport/protocol.ts index 5279da3..79613b9 100644 --- a/src/transport/protocol.ts +++ b/src/transport/protocol.ts @@ -1,6 +1,8 @@ -export const DEFAULT_CONSOLE_SERVER = "https://opencode.ai/console"; -export const CONSOLE_API_BASE_URL = "https://opencode.ai/zen/v1"; -export const GO_API_BASE_URL = "https://opencode.ai/zen/go/v1"; +export const OPENCODE_ORIGIN = "https://opencode.ai"; +export const OPENCODE_INFERENCE_BASE = `${OPENCODE_ORIGIN}/inference`; +export const DEFAULT_CONSOLE_SERVER = `${OPENCODE_ORIGIN}/console`; +export const CONSOLE_API_BASE_URL = `${OPENCODE_INFERENCE_BASE}/v1`; +export const GO_API_BASE_URL = `${OPENCODE_INFERENCE_BASE}/go/v1`; export const OPENCODE_CLIENT_ID = "opencode-cli"; export const OPENCODE_CLIENT = "opencode-copilot-chat"; @@ -10,6 +12,13 @@ export const LEGACY_ZEN_MODE = "zen"; export type OpenCodeMode = "console" | "go"; export type EndpointKind = "chat-completions" | "messages" | "responses" | "google"; +const PROTOCOL_BASE_PATHS: Readonly> = { + "chat-completions": "openai/v1", + messages: "anthropic/v1", + responses: "openai/v1", + google: "google/v1beta", +}; + /** Modes that can appear in persisted settings or state written by older versions. */ export type LegacyOpenCodeMode = OpenCodeMode | "zen"; @@ -73,9 +82,7 @@ export function resolveEndpointKind( if (npm === "@ai-sdk/openai" || npm.endsWith("/openai")) return "responses"; if (/^gpt-/i.test(modelId)) return "responses"; if (/^claude-/i.test(modelId)) return "messages"; - // Family-wide rather than version-pinned: every live grok* and qwen* model - // on both gateways is responses or messages, and models.dev omits `npm` for - // all qwen ids, which is the only signal the checks above have. + // Family fallbacks cover models whose SDK metadata is unavailable. if (/^grok(?:-|$)/i.test(modelId)) return "responses"; if (/^muse-spark-/i.test(modelId)) return "responses"; // qwen3.8-max is the only live qwen whose documented and live-verified shape @@ -83,6 +90,8 @@ export function resolveEndpointKind( // /messages with a ModelProtocolUnsupported error); Go keeps messages. if (/^qwen-?3\.8-max$/i.test(modelId)) return mode === "go" ? "messages" : "chat-completions"; if (/^qwen/i.test(modelId)) return "messages"; + // MiniMax defaults to Messages on Go and Chat Completions on Console. + // Explicit model SDK metadata above can override either fallback. if (mode === "go" && /^minimax-/i.test(modelId)) return "messages"; if (/^gemini-/i.test(modelId)) return "google"; return "chat-completions"; @@ -99,8 +108,11 @@ export function endpointUrl(baseUrl: string, endpoint: EndpointKind, modelId: st /** Generic catalog bases are not inference roots for every protocol. */ function gatewayBaseUrl(baseUrl: string, endpoint: EndpointKind): string { const base = baseUrl.replace(/\/+$/, ""); - const gateway = /^https:\/\/opencode\.ai\/(?:zen|inference)(\/go)?\/v1$/.exec(base); + let url: URL; + try { url = new URL(base); } + catch { return base; } + if (url.origin !== OPENCODE_ORIGIN || url.username || url.password || url.search || url.hash) return base; + const gateway = /^\/(?:zen|inference)(\/go)?\/v1$/.exec(url.pathname); if (!gateway) return base; - const protocol = endpoint === "google" ? "google/v1beta" : endpoint === "messages" ? "anthropic/v1" : "openai/v1"; - return `https://opencode.ai/inference${gateway[1] ?? ""}/${protocol}`; + return `${OPENCODE_INFERENCE_BASE}${gateway[1] ?? ""}/${PROTOCOL_BASE_PATHS[endpoint]}`; }