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
10 changes: 6 additions & 4 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

32 changes: 16 additions & 16 deletions packages/first-class-providers/src/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ import {
OPENCODE_GO_MODEL_IDS,
OPENCODE_GO_PROVIDER_ID,
} from "../../opencode-go/src/index.js";
import {
ZEN_AUTH_HINT,
ZEN_DEFAULT_BASE_URL,
ZEN_DEFAULT_MODEL,
ZEN_DISPLAY_NAME,
ZEN_MODEL_IDS,
ZEN_PROVIDER_ID,
} from "../../zen/src/index.js";
import type { FirstClassProviderDef } from "./types.js";

const OPENAI_API_MODELS = [
Expand Down Expand Up @@ -85,23 +93,15 @@ export const FIRST_CLASS_PROVIDERS: readonly FirstClassProviderDef[] = [
billingProduct: "subscription",
},
{
id: "zen",
label: "OpenCode Zen",
id: ZEN_PROVIDER_ID,
label: ZEN_DISPLAY_NAME,
auth: "api-key",
baseURL: "https://opencode.ai/zen/v1",
models: [
"gpt-6-astra",
"gpt-5.4",
"gpt-5.4-mini",
"claude-fable-5-1",
"claude-sonnet-4-5",
"claude-opus-4-5",
"gemini-3-flash",
"gemini-3-pro",
],
defaultModel: "claude-sonnet-4-5",
authHint:
"OpenCode Zen pay-as-you-go credits — paste your API key from https://opencode.ai/auth",
baseURL: ZEN_DEFAULT_BASE_URL,
// Static fallback seed: the Zen /models catalog is discovered live and
// overlaid at runtime; this list only covers discovery being unavailable.
models: ZEN_MODEL_IDS,
defaultModel: ZEN_DEFAULT_MODEL,
authHint: ZEN_AUTH_HINT,
billingProduct: "credits",
},
{
Expand Down
13 changes: 13 additions & 0 deletions packages/zen/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"name": "@corbits/provider-zen",
"version": "0.1.0",
"private": true,
"type": "module",
"license": "SEE LICENSE IN LICENSE.md",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
}
}
}
19 changes: 19 additions & 0 deletions packages/zen/src/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// OpenCode Zen pay-as-you-go endpoints.
// Docs: https://opencode.ai/docs/zen/
// Auth: API key from https://opencode.ai/auth topped up with Zen credits.

export const ZEN_PROVIDER_ID = "zen";
export const ZEN_DISPLAY_NAME = "OpenCode Zen";

// OpenAI-compatible surface (chat completions + responses). Paths are relative
// to this base: /chat/completions, /responses, /models.
export const ZEN_DEFAULT_BASE_URL = "https://opencode.ai/zen/v1";

// Anthropic Messages adapter appends `/v1/messages`, so the root (without /v1)
// is the correct base for message-protocol models.
export const ZEN_BASE_URL = "https://opencode.ai/zen";

export const ZEN_MODELS_PATH = "/models";

export const ZEN_AUTH_HINT =
"OpenCode Zen pay-as-you-go credits — paste your API key from https://opencode.ai/auth";
34 changes: 34 additions & 0 deletions packages/zen/src/endpoint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// Resolve the Zen endpoint + provider adapter from the protocol map.
// Mirrors the OpenCode Go endpoint module; Go paths are untouched.

import { ZEN_BASE_URL, ZEN_DEFAULT_BASE_URL } from "./constants.js";
import { protocolForZenModel, type ZenProtocol } from "./models.js";

export type ZenEndpointKind =
| "anthropic"
| "openai-compatible"
| "openai-responses";

export interface ZenEndpoint {
baseURL: string;
adapter: ZenEndpointKind;
}

const ENDPOINT_BY_PROTOCOL: Record<ZenProtocol, ZenEndpoint> = {
messages: { baseURL: ZEN_BASE_URL, adapter: "anthropic" },
responses: { baseURL: ZEN_DEFAULT_BASE_URL, adapter: "openai-responses" },
"chat-completions": {
baseURL: ZEN_DEFAULT_BASE_URL,
adapter: "openai-compatible",
},
};

/** Map hit if and only if this model pins Zen protocol selection. */
export function zenProtocolForModel(model: string): ZenProtocol {
return protocolForZenModel(model);
}

/** Route a Zen model to its endpoint. Unknown ids stay on chat completions. */
export function resolveZenEndpoint(model: string): ZenEndpoint {
return ENDPOINT_BY_PROTOCOL[zenProtocolForModel(model)];
}
48 changes: 48 additions & 0 deletions packages/zen/src/identity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// Zen provider identity: detect a Zen-configured provider from an explicit
// provider id or a bare Zen base URL. Mirrors the OpenCode Go identity
// helpers; Go's own URL and id keep matching Go first, never Zen.

import { ZEN_DISPLAY_NAME, ZEN_PROVIDER_ID } from "./constants.js";

function hostMatchesOpencodeDotAi(host: string): boolean {
const normalized = host.trim().toLowerCase();
return normalized === "opencode.ai" || normalized.endsWith(".opencode.ai");
}

/**
* Match a bare Zen base URL: an opencode.ai URL whose first path segment is
* "zen". The Go catalog URL (https://opencode.ai/zen/go/...) matches Go,
* never Zen — callers must check Go first.
*/
export function isZenURL(value: string | undefined): boolean {
if (!value) return false;
try {
const url = new URL(value);
if (!hostMatchesOpencodeDotAi(url.hostname)) return false;
const segments = url.pathname.split("/").filter((part) => part !== "");
return (
segments[0]?.toLowerCase() === "zen" &&
segments[1]?.toLowerCase() !== "go"
);
} catch {
return false;
}
}

/** Match the Zen provider id or display name (case-insensitive). */
export function isZenProviderId(value: string | undefined): boolean {
if (!value) return false;
const normalized = value.trim().toLowerCase();
return (
normalized === ZEN_PROVIDER_ID ||
normalized === ZEN_DISPLAY_NAME.toLowerCase()
);
}

/** Match either an explicit Zen provider id or a bare Zen base URL. */
export function isZenProvider(ref: {
name?: string;
baseURL?: string;
}): boolean {
return isZenProviderId(ref.name) || isZenURL(ref.baseURL);
}
22 changes: 22 additions & 0 deletions packages/zen/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
export {
ZEN_AUTH_HINT,
ZEN_BASE_URL,
ZEN_DEFAULT_BASE_URL,
ZEN_DISPLAY_NAME,
ZEN_MODELS_PATH,
ZEN_PROVIDER_ID,
} from "./constants.js";
export {
resolveZenEndpoint,
zenProtocolForModel,
type ZenEndpoint,
type ZenEndpointKind,
} from "./endpoint.js";
export { isZenProvider, isZenProviderId, isZenURL } from "./identity.js";
export {
isKnownZenModel,
protocolForZenModel,
ZEN_DEFAULT_MODEL,
ZEN_MODEL_IDS,
type ZenProtocol,
} from "./models.js";
112 changes: 112 additions & 0 deletions packages/zen/src/models.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
// Known OpenCode Zen models and the wire protocol each one requires, from
// https://opencode.ai/docs/zen/#available-models. Zen's /models catalog is
// the live source of truth; this map only pins the protocol assignment per
// known id. Unknown ids default to chat completions (the status quo).

/** Packaged fallback seed: live discovery fallback + picker default, never empty. */
export const ZEN_MODEL_IDS: readonly string[] = [
"gpt-6-astra",
"gpt-5.4",
"gpt-5.4-mini",
"claude-fable-5-1",
"claude-sonnet-4-5",
"claude-opus-4-5",
"gemini-3-flash",
"gemini-3-pro",
];

export const ZEN_DEFAULT_MODEL = "claude-sonnet-4-5";

export type ZenProtocol = "messages" | "responses" | "chat-completions";

interface ZenModel {
id: string;
protocol: ZenProtocol;
}

const ZEN_MODELS: readonly ZenModel[] = [
// Anthropic Messages API
{ id: "claude-fable-5-1", protocol: "messages" },
{ id: "claude-fable-5", protocol: "messages" },
{ id: "claude-opus-5", protocol: "messages" },
{ id: "claude-opus-4-8", protocol: "messages" },
{ id: "claude-opus-4-7", protocol: "messages" },
{ id: "claude-opus-4-6", protocol: "messages" },
{ id: "claude-opus-4-5", protocol: "messages" },
{ id: "claude-sonnet-5", protocol: "messages" },
{ id: "claude-sonnet-4-6", protocol: "messages" },
{ id: "claude-sonnet-4-5", protocol: "messages" },
{ id: "claude-haiku-4-5", protocol: "messages" },
{ id: "qwen3.7-max", protocol: "messages" },
{ id: "qwen3.7-plus", protocol: "messages" },
{ id: "qwen3.6-plus", protocol: "messages" },
{ id: "qwen3.5-plus", protocol: "messages" },
// OpenAI Responses API
{ id: "gpt-6-astra", protocol: "responses" },
{ id: "gpt-5.6-sol", protocol: "responses" },
{ id: "gpt-5.6-terra", protocol: "responses" },
{ id: "gpt-5.6-luna", protocol: "responses" },
{ id: "gpt-5.5", protocol: "responses" },
{ id: "gpt-5.5-pro", protocol: "responses" },
{ id: "gpt-5.4", protocol: "responses" },
{ id: "gpt-5.4-pro", protocol: "responses" },
{ id: "gpt-5.4-mini", protocol: "responses" },
{ id: "gpt-5.4-nano", protocol: "responses" },
{ id: "gpt-5.3-codex", protocol: "responses" },
{ id: "gpt-5.3-codex-spark", protocol: "responses" },
{ id: "gpt-5.2", protocol: "responses" },
{ id: "gpt-5.2-codex", protocol: "responses" },
{ id: "gpt-5.1", protocol: "responses" },
{ id: "gpt-5.1-codex", protocol: "responses" },
{ id: "gpt-5.1-codex-max", protocol: "responses" },
{ id: "gpt-5.1-codex-mini", protocol: "responses" },
{ id: "gpt-5", protocol: "responses" },
{ id: "gpt-5-codex", protocol: "responses" },
{ id: "gpt-5-nano", protocol: "responses" },
{ id: "grok-4.6", protocol: "responses" },
{ id: "grok-4.5", protocol: "responses" },
{ id: "grok-build-0.1", protocol: "responses" },
{ id: "muse-spark-1.3", protocol: "responses" },
{ id: "muse-spark-1.2", protocol: "responses" },
{ id: "muse-spark-1.3-contributor-free", protocol: "responses" },
// OpenAI Chat Completions API (explicit; unknown ids also land here)
{ id: "deepseek-v4-pro", protocol: "chat-completions" },
{ id: "deepseek-v4-flash", protocol: "chat-completions" },
{ id: "deepseek-v4-flash-vision-exp", protocol: "chat-completions" },
{ id: "minimax-m3", protocol: "chat-completions" },
{ id: "minimax-m2.7", protocol: "chat-completions" },
{ id: "minimax-m2.5", protocol: "chat-completions" },
{ id: "glm-5.3-flash", protocol: "chat-completions" },
{ id: "glm-5.3", protocol: "chat-completions" },
{ id: "glm-5.2", protocol: "chat-completions" },
{ id: "glm-5.1", protocol: "chat-completions" },
{ id: "glm-5", protocol: "chat-completions" },
{ id: "kimi-k2.5", protocol: "chat-completions" },
{ id: "kimi-k2.6", protocol: "chat-completions" },
{ id: "kimi-k2.7-code", protocol: "chat-completions" },
{ id: "kimi-k3", protocol: "chat-completions" },
{ id: "big-pickle", protocol: "chat-completions" },
{ id: "mimo-v2.5-free", protocol: "chat-completions" },
{ id: "ling-3.0-flash-fin-free", protocol: "chat-completions" },
{ id: "nemotron-3-ultra-free", protocol: "chat-completions" },
{ id: "nemotron-3.5-lightning-free", protocol: "chat-completions" },
{ id: "gemini-3-flash", protocol: "chat-completions" },
{ id: "gemini-3-pro", protocol: "chat-completions" },
];

const PROTOCOL_BY_ID = new Map<string, ZenProtocol>(
ZEN_MODELS.map((model) => [model.id, model.protocol]),
);

/**
* Return the wire protocol for a known Zen model id. Unknown ids default to
* chat completions — the status quo for an unmapped model, never inferred
* from name prefixes.
*/
export function protocolForZenModel(modelId: string): ZenProtocol {
return PROTOCOL_BY_ID.get(modelId) ?? "chat-completions";
}

export function isKnownZenModel(modelId: string): boolean {
return PROTOCOL_BY_ID.has(modelId);
}
Loading
Loading