Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tiny-days-retire.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"opencode-bridge-for-copilot-chat": patch
---

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.
17 changes: 13 additions & 4 deletions docs/models.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -16,6 +15,16 @@ 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.
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
(`https://models.dev/api.json`) stored in VS Code `globalState`. Stale metadata
Expand Down
10 changes: 5 additions & 5 deletions src/autocomplete/engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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);
Expand All @@ -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 }),
Expand All @@ -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) => {
Expand All @@ -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) => {
Expand Down
19 changes: 19 additions & 0 deletions src/models/catalog.test.ts
Original file line number Diff line number Diff line change
@@ -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" }, {
Expand Down Expand Up @@ -60,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" }] });
}
Expand All @@ -74,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 () => {
Expand Down
33 changes: 30 additions & 3 deletions src/transport/protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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", () => {
Expand All @@ -97,3 +97,30 @@ 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("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/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`);
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");
assert.equal(endpointUrl("https://opencode.ai.example.test/inference/v1", "responses", "custom"), "https://opencode.ai.example.test/inference/v1/responses");
});
39 changes: 30 additions & 9 deletions src/transport/protocol.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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<Record<EndpointKind, string>> = {
"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";

Expand Down Expand Up @@ -73,25 +82,37 @@ 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
// on the zen gateway is chat completions (the gateway rejects it on
// /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 (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(/\/+$/, "");
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;
return `${OPENCODE_INFERENCE_BASE}${gateway[1] ?? ""}/${PROTOCOL_BASE_PATHS[endpoint]}`;
}
Loading