Skip to content

Commit 6ebf1a1

Browse files
committed
Add OpenCode Zen model discovery and protocol routing
1 parent 1271571 commit 6ebf1a1

23 files changed

Lines changed: 1150 additions & 25 deletions

bun.lock

Lines changed: 6 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/first-class-providers/src/providers.ts

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@ import {
55
OPENCODE_GO_MODEL_IDS,
66
OPENCODE_GO_PROVIDER_ID,
77
} from "../../opencode-go/src/index.js";
8+
import {
9+
ZEN_AUTH_HINT,
10+
ZEN_DEFAULT_BASE_URL,
11+
ZEN_DEFAULT_MODEL,
12+
ZEN_DISPLAY_NAME,
13+
ZEN_MODEL_IDS,
14+
ZEN_PROVIDER_ID,
15+
} from "../../zen/src/index.js";
816
import type { FirstClassProviderDef } from "./types.js";
917

1018
const OPENAI_API_MODELS = [
@@ -85,23 +93,15 @@ export const FIRST_CLASS_PROVIDERS: readonly FirstClassProviderDef[] = [
8593
billingProduct: "subscription",
8694
},
8795
{
88-
id: "zen",
89-
label: "OpenCode Zen",
96+
id: ZEN_PROVIDER_ID,
97+
label: ZEN_DISPLAY_NAME,
9098
auth: "api-key",
91-
baseURL: "https://opencode.ai/zen/v1",
92-
models: [
93-
"gpt-6-astra",
94-
"gpt-5.4",
95-
"gpt-5.4-mini",
96-
"claude-fable-5-1",
97-
"claude-sonnet-4-5",
98-
"claude-opus-4-5",
99-
"gemini-3-flash",
100-
"gemini-3-pro",
101-
],
102-
defaultModel: "claude-sonnet-4-5",
103-
authHint:
104-
"OpenCode Zen pay-as-you-go credits — paste your API key from https://opencode.ai/auth",
99+
baseURL: ZEN_DEFAULT_BASE_URL,
100+
// Static fallback seed: the Zen /models catalog is discovered live and
101+
// overlaid at runtime; this list only covers discovery being unavailable.
102+
models: ZEN_MODEL_IDS,
103+
defaultModel: ZEN_DEFAULT_MODEL,
104+
authHint: ZEN_AUTH_HINT,
105105
billingProduct: "credits",
106106
},
107107
{

packages/zen/package.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"name": "@corbits/provider-zen",
3+
"version": "0.1.0",
4+
"private": true,
5+
"type": "module",
6+
"license": "SEE LICENSE IN LICENSE.md",
7+
"exports": {
8+
".": {
9+
"types": "./src/index.ts",
10+
"default": "./src/index.ts"
11+
}
12+
}
13+
}

packages/zen/src/constants.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// OpenCode Zen pay-as-you-go endpoints.
2+
// Docs: https://opencode.ai/docs/zen/
3+
// Auth: API key from https://opencode.ai/auth topped up with Zen credits.
4+
5+
export const ZEN_PROVIDER_ID = "zen";
6+
export const ZEN_DISPLAY_NAME = "OpenCode Zen";
7+
8+
// OpenAI-compatible surface (chat completions + responses). Paths are relative
9+
// to this base: /chat/completions, /responses, /models.
10+
export const ZEN_DEFAULT_BASE_URL = "https://opencode.ai/zen/v1";
11+
12+
// Anthropic Messages adapter appends `/v1/messages`, so the root (without /v1)
13+
// is the correct base for message-protocol models.
14+
export const ZEN_BASE_URL = "https://opencode.ai/zen";
15+
16+
export const ZEN_MODELS_PATH = "/models";
17+
18+
export const ZEN_AUTH_HINT =
19+
"OpenCode Zen pay-as-you-go credits — paste your API key from https://opencode.ai/auth";

packages/zen/src/endpoint.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// Resolve the Zen endpoint + provider adapter from the protocol map.
2+
// Mirrors the OpenCode Go endpoint module; Go paths are untouched.
3+
4+
import { ZEN_BASE_URL, ZEN_DEFAULT_BASE_URL } from "./constants.js";
5+
import { protocolForZenModel, type ZenProtocol } from "./models.js";
6+
7+
export type ZenEndpointKind =
8+
| "anthropic"
9+
| "openai-compatible"
10+
| "openai-responses";
11+
12+
export interface ZenEndpoint {
13+
baseURL: string;
14+
adapter: ZenEndpointKind;
15+
}
16+
17+
const ENDPOINT_BY_PROTOCOL: Record<ZenProtocol, ZenEndpoint> = {
18+
messages: { baseURL: ZEN_BASE_URL, adapter: "anthropic" },
19+
responses: { baseURL: ZEN_DEFAULT_BASE_URL, adapter: "openai-responses" },
20+
"chat-completions": {
21+
baseURL: ZEN_DEFAULT_BASE_URL,
22+
adapter: "openai-compatible",
23+
},
24+
};
25+
26+
/** Map hit if and only if this model pins Zen protocol selection. */
27+
export function zenProtocolForModel(model: string): ZenProtocol {
28+
return protocolForZenModel(model);
29+
}
30+
31+
/** Route a Zen model to its endpoint. Unknown ids stay on chat completions. */
32+
export function resolveZenEndpoint(model: string): ZenEndpoint {
33+
return ENDPOINT_BY_PROTOCOL[zenProtocolForModel(model)];
34+
}

packages/zen/src/identity.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// Zen provider identity: detect a Zen-configured provider from an explicit
2+
// provider id or a bare Zen base URL. Mirrors the OpenCode Go identity
3+
// helpers; Go's own URL and id keep matching Go first, never Zen.
4+
5+
import { ZEN_DISPLAY_NAME, ZEN_PROVIDER_ID } from "./constants.js";
6+
7+
function hostMatchesOpencodeDotAi(host: string): boolean {
8+
const normalized = host.trim().toLowerCase();
9+
return normalized === "opencode.ai" || normalized.endsWith(".opencode.ai");
10+
}
11+
12+
/**
13+
* Match a bare Zen base URL: an opencode.ai URL whose first path segment is
14+
* "zen". The Go catalog URL (https://opencode.ai/zen/go/...) matches Go,
15+
* never Zen — callers must check Go first.
16+
*/
17+
export function isZenURL(value: string | undefined): boolean {
18+
if (!value) return false;
19+
try {
20+
const url = new URL(value);
21+
if (!hostMatchesOpencodeDotAi(url.hostname)) return false;
22+
const segments = url.pathname.split("/").filter((part) => part !== "");
23+
return (
24+
segments[0]?.toLowerCase() === "zen" &&
25+
segments[1]?.toLowerCase() !== "go"
26+
);
27+
} catch {
28+
return false;
29+
}
30+
}
31+
32+
/** Match the Zen provider id or display name (case-insensitive). */
33+
export function isZenProviderId(value: string | undefined): boolean {
34+
if (!value) return false;
35+
const normalized = value.trim().toLowerCase();
36+
return (
37+
normalized === ZEN_PROVIDER_ID ||
38+
normalized === ZEN_DISPLAY_NAME.toLowerCase()
39+
);
40+
}
41+
42+
/** Match either an explicit Zen provider id or a bare Zen base URL. */
43+
export function isZenProvider(ref: {
44+
name?: string;
45+
baseURL?: string;
46+
}): boolean {
47+
return isZenProviderId(ref.name) || isZenURL(ref.baseURL);
48+
}

packages/zen/src/index.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
export {
2+
ZEN_AUTH_HINT,
3+
ZEN_BASE_URL,
4+
ZEN_DEFAULT_BASE_URL,
5+
ZEN_DISPLAY_NAME,
6+
ZEN_MODELS_PATH,
7+
ZEN_PROVIDER_ID,
8+
} from "./constants.js";
9+
export {
10+
resolveZenEndpoint,
11+
zenProtocolForModel,
12+
type ZenEndpoint,
13+
type ZenEndpointKind,
14+
} from "./endpoint.js";
15+
export { isZenProvider, isZenProviderId, isZenURL } from "./identity.js";
16+
export {
17+
isKnownZenModel,
18+
protocolForZenModel,
19+
ZEN_DEFAULT_MODEL,
20+
ZEN_MODEL_IDS,
21+
type ZenProtocol,
22+
} from "./models.js";

packages/zen/src/models.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
// Known OpenCode Zen models and the wire protocol each one requires, from
2+
// https://opencode.ai/docs/zen/#available-models. Zen's /models catalog is
3+
// the live source of truth; this map only pins the protocol assignment per
4+
// known id. Unknown ids default to chat completions (the status quo).
5+
6+
/** Packaged fallback seed: live discovery fallback + picker default, never empty. */
7+
export const ZEN_MODEL_IDS: readonly string[] = [
8+
"gpt-6-astra",
9+
"gpt-5.4",
10+
"gpt-5.4-mini",
11+
"claude-fable-5-1",
12+
"claude-sonnet-4-5",
13+
"claude-opus-4-5",
14+
"gemini-3-flash",
15+
"gemini-3-pro",
16+
];
17+
18+
export const ZEN_DEFAULT_MODEL = "claude-sonnet-4-5";
19+
20+
export type ZenProtocol = "messages" | "responses" | "chat-completions";
21+
22+
interface ZenModel {
23+
id: string;
24+
protocol: ZenProtocol;
25+
}
26+
27+
const ZEN_MODELS: readonly ZenModel[] = [
28+
// Anthropic Messages API
29+
{ id: "claude-fable-5-1", protocol: "messages" },
30+
{ id: "claude-fable-5", protocol: "messages" },
31+
{ id: "claude-opus-5", protocol: "messages" },
32+
{ id: "claude-opus-4-8", protocol: "messages" },
33+
{ id: "claude-opus-4-7", protocol: "messages" },
34+
{ id: "claude-opus-4-6", protocol: "messages" },
35+
{ id: "claude-opus-4-5", protocol: "messages" },
36+
{ id: "claude-sonnet-5", protocol: "messages" },
37+
{ id: "claude-sonnet-4-6", protocol: "messages" },
38+
{ id: "claude-sonnet-4-5", protocol: "messages" },
39+
{ id: "claude-haiku-4-5", protocol: "messages" },
40+
{ id: "qwen3.7-max", protocol: "messages" },
41+
{ id: "qwen3.7-plus", protocol: "messages" },
42+
{ id: "qwen3.6-plus", protocol: "messages" },
43+
{ id: "qwen3.5-plus", protocol: "messages" },
44+
// OpenAI Responses API
45+
{ id: "gpt-6-astra", protocol: "responses" },
46+
{ id: "gpt-5.6-sol", protocol: "responses" },
47+
{ id: "gpt-5.6-terra", protocol: "responses" },
48+
{ id: "gpt-5.6-luna", protocol: "responses" },
49+
{ id: "gpt-5.5", protocol: "responses" },
50+
{ id: "gpt-5.5-pro", protocol: "responses" },
51+
{ id: "gpt-5.4", protocol: "responses" },
52+
{ id: "gpt-5.4-pro", protocol: "responses" },
53+
{ id: "gpt-5.4-mini", protocol: "responses" },
54+
{ id: "gpt-5.4-nano", protocol: "responses" },
55+
{ id: "gpt-5.3-codex", protocol: "responses" },
56+
{ id: "gpt-5.3-codex-spark", protocol: "responses" },
57+
{ id: "gpt-5.2", protocol: "responses" },
58+
{ id: "gpt-5.2-codex", protocol: "responses" },
59+
{ id: "gpt-5.1", protocol: "responses" },
60+
{ id: "gpt-5.1-codex", protocol: "responses" },
61+
{ id: "gpt-5.1-codex-max", protocol: "responses" },
62+
{ id: "gpt-5.1-codex-mini", protocol: "responses" },
63+
{ id: "gpt-5", protocol: "responses" },
64+
{ id: "gpt-5-codex", protocol: "responses" },
65+
{ id: "gpt-5-nano", protocol: "responses" },
66+
{ id: "grok-4.6", protocol: "responses" },
67+
{ id: "grok-4.5", protocol: "responses" },
68+
{ id: "grok-build-0.1", protocol: "responses" },
69+
{ id: "muse-spark-1.3", protocol: "responses" },
70+
{ id: "muse-spark-1.2", protocol: "responses" },
71+
{ id: "muse-spark-1.3-contributor-free", protocol: "responses" },
72+
// OpenAI Chat Completions API (explicit; unknown ids also land here)
73+
{ id: "deepseek-v4-pro", protocol: "chat-completions" },
74+
{ id: "deepseek-v4-flash", protocol: "chat-completions" },
75+
{ id: "deepseek-v4-flash-vision-exp", protocol: "chat-completions" },
76+
{ id: "minimax-m3", protocol: "chat-completions" },
77+
{ id: "minimax-m2.7", protocol: "chat-completions" },
78+
{ id: "minimax-m2.5", protocol: "chat-completions" },
79+
{ id: "glm-5.3-flash", protocol: "chat-completions" },
80+
{ id: "glm-5.3", protocol: "chat-completions" },
81+
{ id: "glm-5.2", protocol: "chat-completions" },
82+
{ id: "glm-5.1", protocol: "chat-completions" },
83+
{ id: "glm-5", protocol: "chat-completions" },
84+
{ id: "kimi-k2.5", protocol: "chat-completions" },
85+
{ id: "kimi-k2.6", protocol: "chat-completions" },
86+
{ id: "kimi-k2.7-code", protocol: "chat-completions" },
87+
{ id: "kimi-k3", protocol: "chat-completions" },
88+
{ id: "big-pickle", protocol: "chat-completions" },
89+
{ id: "mimo-v2.5-free", protocol: "chat-completions" },
90+
{ id: "ling-3.0-flash-fin-free", protocol: "chat-completions" },
91+
{ id: "nemotron-3-ultra-free", protocol: "chat-completions" },
92+
{ id: "nemotron-3.5-lightning-free", protocol: "chat-completions" },
93+
{ id: "gemini-3-flash", protocol: "chat-completions" },
94+
{ id: "gemini-3-pro", protocol: "chat-completions" },
95+
];
96+
97+
const PROTOCOL_BY_ID = new Map<string, ZenProtocol>(
98+
ZEN_MODELS.map((model) => [model.id, model.protocol]),
99+
);
100+
101+
/**
102+
* Return the wire protocol for a known Zen model id. Unknown ids default to
103+
* chat completions — the status quo for an unmapped model, never inferred
104+
* from name prefixes.
105+
*/
106+
export function protocolForZenModel(modelId: string): ZenProtocol {
107+
return PROTOCOL_BY_ID.get(modelId) ?? "chat-completions";
108+
}
109+
110+
export function isKnownZenModel(modelId: string): boolean {
111+
return PROTOCOL_BY_ID.has(modelId);
112+
}

0 commit comments

Comments
 (0)