From 6ebf1a1044a8297b2e46718c05613f2678d0ee73 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 12 Sep 2026 11:39:47 -0700 Subject: [PATCH 1/2] Add OpenCode Zen model discovery and protocol routing --- bun.lock | 10 +- .../first-class-providers/src/providers.ts | 32 +- packages/zen/package.json | 13 + packages/zen/src/constants.ts | 19 ++ packages/zen/src/endpoint.ts | 34 +++ packages/zen/src/identity.ts | 48 +++ packages/zen/src/index.ts | 22 ++ packages/zen/src/models.ts | 112 +++++++ src/config.test.ts | 51 ++++ src/config/index.ts | 81 ++++- src/config/inference-sources.test.ts | 69 +++++ src/config/inference-sources.ts | 20 ++ src/provider/inference-dependencies.ts | 11 +- src/provider/zen-anthropic-adapter.ts | 32 ++ src/provider/zen-models.test.ts | 282 ++++++++++++++++++ src/provider/zen-models.ts | 192 ++++++++++++ src/tui/provider/choices.ts | 10 +- src/tui/provider/discovery.ts | 49 ++- src/tui/provider/setup.ts | 14 + src/tui/provider/surface.ts | 1 + src/tui/provider/types.ts | 8 + src/tui/runner/settings.ts | 28 ++ src/tui/runner/wiring.ts | 37 +++ 23 files changed, 1150 insertions(+), 25 deletions(-) create mode 100644 packages/zen/package.json create mode 100644 packages/zen/src/constants.ts create mode 100644 packages/zen/src/endpoint.ts create mode 100644 packages/zen/src/identity.ts create mode 100644 packages/zen/src/index.ts create mode 100644 packages/zen/src/models.ts create mode 100644 src/provider/zen-anthropic-adapter.ts create mode 100644 src/provider/zen-models.test.ts create mode 100644 src/provider/zen-models.ts diff --git a/bun.lock b/bun.lock index e4fab04b0..235c3021c 100644 --- a/bun.lock +++ b/bun.lock @@ -58,6 +58,10 @@ "name": "@corbits/provider-opencode-go", "version": "0.1.0", }, + "packages/zen": { + "name": "@corbits/provider-zen", + "version": "0.1.0", + }, "vendor/intx-agent": { "name": "@intx/agent", "version": "0.2.2", @@ -217,6 +221,8 @@ "@corbits/provider-opencode-go": ["@corbits/provider-opencode-go@workspace:packages/opencode-go"], + "@corbits/provider-zen": ["@corbits/provider-zen@workspace:packages/zen"], + "@corbits/xai-provider": ["@corbits/xai-provider@github:corbitsdev/corbits-xai-provider#cadf9b9", { "dependencies": { "@corbits/oauth-core": "github:corbitsdev/corbits-oauth-core", "@corbits/openai-responses": "github:corbitsdev/corbits-openai-responses", "arktype": "2.2.3" }, "peerDependencies": { "@intx/inference": ">=0.3.0", "@intx/types": ">=0.3.0" } }, "corbitsdev-corbits-xai-provider-cadf9b9", "sha512-dVY4lQJWdCVPxwnK4pQNc3JgfgASaNj/GACKTnR22Y/s6CKSwlcZyGzZ0QEjIr21SZRk4+QP6ehOBf/OtHSJRw=="], "@gar/promise-retry": ["@gar/promise-retry@1.0.3", "", {}, "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA=="], @@ -775,10 +781,6 @@ "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], - "@corbits/codex-provider/@corbits/oauth-core": ["@corbits/oauth-core@github:corbitsdev/corbits-oauth-core#e1e69e6", { "dependencies": { "arktype": "2.2.3" } }, "corbitsdev-corbits-oauth-core-e1e69e6", "sha512-wUrD73iVyk/Dtb4yRn3hCh6N8syfsEvAkRz4XYqT0FIB3sYDSMYEWHcDMJx9nq64my/HBOeN1J6PHIOtpjhtPg=="], - - "@corbits/xai-provider/@corbits/oauth-core": ["@corbits/oauth-core@github:corbitsdev/corbits-oauth-core#e1e69e6", { "dependencies": { "arktype": "2.2.3" } }, "corbitsdev-corbits-oauth-core-e1e69e6", "sha512-wUrD73iVyk/Dtb4yRn3hCh6N8syfsEvAkRz4XYqT0FIB3sYDSMYEWHcDMJx9nq64my/HBOeN1J6PHIOtpjhtPg=="], - "@npmcli/agent/lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], "body-parser/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], diff --git a/packages/first-class-providers/src/providers.ts b/packages/first-class-providers/src/providers.ts index 9465bff07..3e26ffcc3 100644 --- a/packages/first-class-providers/src/providers.ts +++ b/packages/first-class-providers/src/providers.ts @@ -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 = [ @@ -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", }, { diff --git a/packages/zen/package.json b/packages/zen/package.json new file mode 100644 index 000000000..fe8eb0b66 --- /dev/null +++ b/packages/zen/package.json @@ -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" + } + } +} diff --git a/packages/zen/src/constants.ts b/packages/zen/src/constants.ts new file mode 100644 index 000000000..5cdc4d788 --- /dev/null +++ b/packages/zen/src/constants.ts @@ -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"; diff --git a/packages/zen/src/endpoint.ts b/packages/zen/src/endpoint.ts new file mode 100644 index 000000000..3866cdab5 --- /dev/null +++ b/packages/zen/src/endpoint.ts @@ -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 = { + 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)]; +} diff --git a/packages/zen/src/identity.ts b/packages/zen/src/identity.ts new file mode 100644 index 000000000..8b4f1c9d3 --- /dev/null +++ b/packages/zen/src/identity.ts @@ -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); +} diff --git a/packages/zen/src/index.ts b/packages/zen/src/index.ts new file mode 100644 index 000000000..6052a9de5 --- /dev/null +++ b/packages/zen/src/index.ts @@ -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"; diff --git a/packages/zen/src/models.ts b/packages/zen/src/models.ts new file mode 100644 index 000000000..41205919a --- /dev/null +++ b/packages/zen/src/models.ts @@ -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( + 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); +} diff --git a/src/config.test.ts b/src/config.test.ts index aaa4050c0..6c68801e1 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -33,10 +33,18 @@ import { OPENCODE_GO_BASE_URL, OPENCODE_GO_MODEL_IDS, } from "../packages/opencode-go/src/index.js"; +import { + ZEN_DEFAULT_BASE_URL, + ZEN_MODEL_IDS, +} from "../packages/zen/src/index.js"; import { prefetchGoModels, resetGoModelDiscoveryForTests, } from "./provider/opencode-go-models.js"; +import { + prefetchZenModels, + resetZenModelDiscoveryForTests, +} from "./provider/zen-models.js"; import { generateSessionId, initSessionDir, @@ -53,11 +61,13 @@ const originalFetch = globalThis.fetch; beforeEach(() => { resetGoModelDiscoveryForTests(); + resetZenModelDiscoveryForTests(); }); afterEach(() => { globalThis.fetch = originalFetch; resetGoModelDiscoveryForTests(); + resetZenModelDiscoveryForTests(); setProviderContextWindowOverrides(undefined); }); @@ -2008,6 +2018,47 @@ describe("refreshLiveProviderCatalog", () => { ?.models, ).toEqual(["go-model"]); }); + + test("overlays Zen row models with selectableZenModelIds without changing non-Zen rows", async () => { + const zenSettings: Settings = { + providers: { + fp: { + baseURL: "https://fp/v1", + apiKey: "fp-key", + models: ["fp-large"], + }, + zen: { + baseURL: ZEN_DEFAULT_BASE_URL, + apiKey: "zen-key", + models: ["zen-model"], + }, + }, + }; + const cold = await refreshLiveProviderCatalog(zenSettings, resolved); + const coldZen = cold.find((c) => c.name === "zen"); + expect(coldZen?.models).toEqual([...ZEN_MODEL_IDS]); + expect(coldZen?.models).not.toContain("zen-model"); + expect(cold.find((c) => c.name === "fp")?.models).toEqual(["fp-large"]); + expect( + buildProviderCatalog(zenSettings, resolved).find((c) => c.name === "zen") + ?.models, + ).toEqual(["zen-model"]); + + globalThis.fetch = (async () => + Response.json({ + data: [{ id: "gpt-6-astra" }, { id: "live-only-fixture-model" }], + })) as unknown as typeof fetch; + await prefetchZenModels(); + + const warm = await refreshLiveProviderCatalog(zenSettings, resolved); + const warmZen = warm.find((c) => c.name === "zen"); + expect(warmZen?.models).toContain("live-only-fixture-model"); + expect(warm.find((c) => c.name === "fp")?.models).toEqual(["fp-large"]); + expect( + buildProviderCatalog(zenSettings, resolved).find((c) => c.name === "zen") + ?.models, + ).toEqual(["zen-model"]); + }); }); describe("mergeProviderIntoSettings", () => { diff --git a/src/config/index.ts b/src/config/index.ts index c05f9199d..43a0a4b59 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -56,6 +56,8 @@ import { import { BIFROST_PROVIDER } from "../provider/bifrost-adapter.js"; import { isOllamaProviderId, ollamaOpenAIBaseURL } from "../provider/ollama.js"; import { selectableGoModelIds } from "../provider/opencode-go-models.js"; +import { ZEN_MESSAGES_PROVIDER } from "../provider/zen-anthropic-adapter.js"; +import { selectableZenModelIds } from "../provider/zen-models.js"; import { OPENAI_RESPONSES_PROVIDER, OPENAI_SESSION_ID_OPTION, @@ -68,6 +70,12 @@ import { isOpenCodeGoProvider, resolveGoEndpoint, } from "../../packages/opencode-go/src/index.js"; +import { + ZEN_DEFAULT_BASE_URL, + ZEN_PROVIDER_ID, + isZenProvider, + resolveZenEndpoint, +} from "../../packages/zen/src/index.js"; import { globalSettingsPath, @@ -452,6 +460,75 @@ export function buildGoSource(fields: { }; } +// OpenCode Zen: per-model protocol routing (chat completions / responses / messages). +// sessionId feeds the Responses-protocol prompt_cache_key (see buildXaiSource). +export function buildZenSource(fields: { + id: string; + apiKey?: string; + model: string; + sessionId: string; + reasoningEffort?: ReasoningEffort; +}): InferenceSource { + const endpoint = resolveZenEndpoint(fields.model); + const apiKey = + fields.apiKey !== undefined && fields.apiKey.length > 0 + ? fields.apiKey + : KEYLESS_API_KEY; + if (endpoint.adapter === "anthropic") { + return { + id: fields.id, + provider: ZEN_MESSAGES_PROVIDER, + baseURL: endpoint.baseURL, + apiKey, + model: fields.model, + defaults: { + maxTokens: SOURCE_MAX_TOKENS, + providerOptions: { + [OPENCODE_SESSION_ID_OPTION]: fields.sessionId, + }, + }, + }; + } + if (endpoint.adapter === "openai-responses") { + return { + id: fields.id, + provider: OPENAI_RESPONSES_PROVIDER, + baseURL: endpoint.baseURL, + apiKey, + model: fields.model, + defaults: { + maxTokens: SOURCE_MAX_TOKENS, + providerOptions: { + [OPENAI_SESSION_ID_OPTION]: fields.sessionId, + [OPENCODE_SESSION_ID_OPTION]: fields.sessionId, + }, + }, + }; + } + // chat-completions (default) + const source = buildOpenAISource({ + id: fields.id, + baseURL: + endpoint.baseURL.length > 0 ? endpoint.baseURL : ZEN_DEFAULT_BASE_URL, + apiKey, + model: fields.model, + ...(fields.reasoningEffort !== undefined + ? { reasoningEffort: fields.reasoningEffort } + : {}), + }); + return { + ...source, + provider: ZEN_PROVIDER_ID, + defaults: { + ...source.defaults, + providerOptions: { + ...(source.defaults?.providerOptions ?? {}), + [OPENCODE_SESSION_ID_OPTION]: fields.sessionId, + }, + }, + }; +} + export interface Config { configured: true; apiKey: string; @@ -1072,7 +1149,9 @@ function mergeOAuthCatalog( ].map((entry) => isOpenCodeGoProvider(entry) ? { ...entry, models: [...selectableGoModelIds()] } - : entry, + : isZenProvider(entry) + ? { ...entry, models: [...selectableZenModelIds()] } + : entry, ); } diff --git a/src/config/inference-sources.test.ts b/src/config/inference-sources.test.ts index 34df84bfa..c639b92b8 100644 --- a/src/config/inference-sources.test.ts +++ b/src/config/inference-sources.test.ts @@ -12,10 +12,43 @@ import { setProviderContextWindowOverrides, } from "../provider/context-window.js"; import { createOpenAICompatibleAdapter } from "../provider/openai-compatible-adapter.js"; +import { OPENAI_RESPONSES_PROVIDER } from "../provider/openai-responses.js"; +import { ZEN_MESSAGES_PROVIDER } from "../provider/zen-anthropic-adapter.js"; import { firstClassProviderById } from "../../packages/first-class-providers/src/index.js"; +import { + ZEN_DEFAULT_BASE_URL, + ZEN_PROVIDER_ID, +} from "../../packages/zen/src/index.js"; const WINDOW = 400_000; +// Routing must stay off the network: any fetch here is a regression. +const originalFetch = globalThis.fetch; + +function zenCatalog(): ProviderCatalogEntry[] { + return [ + { + name: ZEN_PROVIDER_ID, + baseURL: ZEN_DEFAULT_BASE_URL, + apiKey: "zen-key", + models: [ + "muse-spark-1.3-contributor-free", + "claude-sonnet-4-5", + "gemini-3-flash", + "some-future-model", + ], + }, + ]; +} + +function zenSource(model: string) { + return buildInferenceSourceForRef( + { provider: ZEN_PROVIDER_ID, model }, + { sessionId: "sess-zen", catalog: zenCatalog() }, + undefined, + ); +} + function catalog(): ProviderCatalogEntry[] { return [ { @@ -46,6 +79,7 @@ function settingsWithWindow(): Settings { afterEach(() => { setProviderContextWindowOverrides(undefined); + globalThis.fetch = originalFetch; }); describe("contextWindow / maxTokens split (CL-7784)", () => { @@ -171,3 +205,38 @@ describe("OpenAI reasoning max_completion_tokens quirk (CL-7785)", () => { } }); }); + +describe("Zen protocol routing (CL-7811)", () => { + test("routes without touching the network", () => { + globalThis.fetch = (async () => { + throw new Error("routing must not fetch"); + }) as unknown as typeof fetch; + const source = zenSource("muse-spark-1.3-contributor-free"); + expect(source?.provider).toBe(OPENAI_RESPONSES_PROVIDER); + }); + + test("Muse Spark contributor-free build rides the Responses protocol", () => { + const source = zenSource("muse-spark-1.3-contributor-free"); + expect(source?.provider).toBe(OPENAI_RESPONSES_PROVIDER); + expect(source?.baseURL).toBe(ZEN_DEFAULT_BASE_URL); + expect(source?.model).toBe("muse-spark-1.3-contributor-free"); + }); + + test("Claude models ride the Messages protocol on the Zen root", () => { + const source = zenSource("claude-sonnet-4-5"); + expect(source?.provider).toBe(ZEN_MESSAGES_PROVIDER); + expect(source?.baseURL).toBe("https://opencode.ai/zen"); + }); + + test("Gemini models stay on chat completions", () => { + const source = zenSource("gemini-3-flash"); + expect(source?.provider).toBe(ZEN_PROVIDER_ID); + expect(source?.baseURL).toBe(ZEN_DEFAULT_BASE_URL); + }); + + test("unknown ids default to chat completions, never name-prefix inference", () => { + const source = zenSource("some-future-model"); + expect(source?.provider).toBe(ZEN_PROVIDER_ID); + expect(source?.baseURL).toBe(ZEN_DEFAULT_BASE_URL); + }); +}); diff --git a/src/config/inference-sources.ts b/src/config/inference-sources.ts index deb6524cd..a677091ae 100644 --- a/src/config/inference-sources.ts +++ b/src/config/inference-sources.ts @@ -4,6 +4,7 @@ import { buildBifrostSource, buildCodexSource, buildGoSource, + buildZenSource, buildAnthropicSource, buildOpenAISource, buildXaiSource, @@ -19,6 +20,7 @@ import { type ReasoningEffort, } from "../provider/reasoning-effort.js"; import { isOpenCodeGoProvider } from "../../packages/opencode-go/src/index.js"; +import { isZenProvider } from "../../packages/zen/src/index.js"; export interface BuildSourceContext { sessionId: string; @@ -130,6 +132,24 @@ export function buildInferenceSourceForRef( ...(effort !== undefined ? { reasoningEffort: effort } : {}), }); } + if ( + isZenProvider({ + name: ref.provider, + ...(baseURL !== undefined ? { baseURL } : {}), + }) + ) { + return buildZenSource({ + id: ref.provider, + ...(entry?.apiKey !== undefined + ? { apiKey: entry.apiKey } + : providerSettings?.apiKey !== undefined + ? { apiKey: providerSettings.apiKey } + : {}), + model: ref.model, + sessionId: ctx.sessionId, + ...(effort !== undefined ? { reasoningEffort: effort } : {}), + }); + } if (entry?.anthropic === true || providerSettings?.anthropic === true) { return buildAnthropicSource({ id: ref.provider, diff --git a/src/provider/inference-dependencies.ts b/src/provider/inference-dependencies.ts index c447f4ec5..d1be4a7c4 100644 --- a/src/provider/inference-dependencies.ts +++ b/src/provider/inference-dependencies.ts @@ -11,6 +11,7 @@ import * as grokResponses from "./grok-responses.js"; import * as bifrostAdapter from "./bifrost-adapter.js"; import * as openaiResponses from "./openai-responses.js"; import * as opencodeGoAnthropic from "./opencode-go-anthropic-adapter.js"; +import * as zenAnthropic from "./zen-anthropic-adapter.js"; import { CODEX_RESPONSES_PROVIDER, withCodexContentTypeRepair, @@ -22,11 +23,13 @@ import { OPENCODE_GO_PROVIDER_ID } from "../../packages/opencode-go/src/index.js import { BIFROST_PROVIDER } from "./bifrost-adapter.js"; import { OPENAI_RESPONSES_PROVIDER } from "./openai-responses.js"; import { OPENCODE_GO_MESSAGES_PROVIDER } from "./opencode-go-anthropic-adapter.js"; +import { ZEN_MESSAGES_PROVIDER } from "./zen-anthropic-adapter.js"; // Corbits Code ships first-party adapters on top of the built-in provider set: // openai-compatible and OpenCode Go chat-completions adapters, Codex/Grok // responses, Bifrost, generic openai-responses (OpenCode Go gpt-* Luna family), -// and the OpenCode Go Anthropic messages adapter. +// the OpenCode Go Anthropic messages adapter, and the Zen Anthropic messages +// adapter. const manifest: AdapterManifest = [ { provider: "openai-compatible", @@ -63,6 +66,11 @@ const manifest: AdapterManifest = [ specifier: "opencode-go-anthropic-adapter", export: "createOpenCodeGoAnthropicAdapter", }, + { + provider: ZEN_MESSAGES_PROVIDER, + specifier: "zen-anthropic-adapter", + export: "createZenAnthropicAdapter", + }, ]; const localModules: Record = { @@ -73,6 +81,7 @@ const localModules: Record = { "bifrost-adapter": bifrostAdapter, "openai-responses": openaiResponses, "opencode-go-anthropic-adapter": opencodeGoAnthropic, + "zen-anthropic-adapter": zenAnthropic, }; let cached: Promise | undefined; diff --git a/src/provider/zen-anthropic-adapter.ts b/src/provider/zen-anthropic-adapter.ts new file mode 100644 index 000000000..5262a939e --- /dev/null +++ b/src/provider/zen-anthropic-adapter.ts @@ -0,0 +1,32 @@ +import { type BuiltRequest, type ProviderAdapter } from "@intx/inference"; +import { createAnthropicAdapter } from "@intx/inference/providers"; +import { + OPENCODE_SESSION_ID_OPTION, + optionString, +} from "./opencode-session.js"; + +export const ZEN_MESSAGES_PROVIDER = "zen-messages"; + +type AdapterSource = Parameters[0]; + +export function createZenAnthropicAdapter( + source: AdapterSource, + quirks?: unknown, +): ProviderAdapter { + const base = createAnthropicAdapter(source, quirks); + const buildRequest: ProviderAdapter["buildRequest"] = ( + messages, + model, + options, + ) => { + const built = base.buildRequest(messages, model, options); + const sessionId = optionString(options, OPENCODE_SESSION_ID_OPTION); + if (sessionId === undefined) return built; + const headers: BuiltRequest["headers"] = { + ...built.headers, + "x-opencode-session": sessionId, + }; + return { ...built, headers }; + }; + return { ...base, buildRequest }; +} diff --git a/src/provider/zen-models.test.ts b/src/provider/zen-models.test.ts new file mode 100644 index 000000000..0308b78ca --- /dev/null +++ b/src/provider/zen-models.test.ts @@ -0,0 +1,282 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; + +import { ZEN_MODEL_IDS } from "../../packages/zen/src/index.js"; +import { + discoverZenModels, + MAX_ZEN_CATALOG_BYTES, + MAX_ZEN_CATALOG_MODELS, + prefetchZenModels, + resetZenModelDiscoveryForTests, + selectableZenModelIds, + type ZenDiscoveryState, +} from "./zen-models.js"; + +const originalFetch = globalThis.fetch; +const LIVE_ONLY_ID = "live-only-zen-fixture-model"; +const ZEN_MODELS_URL = "https://opencode.ai/zen/v1/models"; + +beforeEach(() => { + resetZenModelDiscoveryForTests(); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + resetZenModelDiscoveryForTests(); +}); + +function oversizedCatalogResponse(byteLength: number): Response { + const chunk = new Uint8Array(64 * 1024).fill(0x61); + let remaining = byteLength; + const stream = new ReadableStream({ + pull(controller) { + if (remaining <= 0) { + controller.close(); + return; + } + const n = Math.min(remaining, chunk.byteLength); + controller.enqueue(n === chunk.byteLength ? chunk : chunk.subarray(0, n)); + remaining -= n; + }, + }); + return new Response(stream, { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +describe("discoverZenModels", () => { + test("GETs the public models URL without auth and does not write the snapshot", async () => { + const fetchMock = async (input: RequestInfo | URL, init?: RequestInit) => { + expect(String(input)).toBe(ZEN_MODELS_URL); + expect(init?.method).toBe("GET"); + const headers = new Headers(init?.headers); + expect(headers.get("Authorization")).toBeNull(); + return Response.json({ + data: [{ id: "gpt-6-astra" }, { id: LIVE_ONLY_ID }], + }); + }; + globalThis.fetch = fetchMock as unknown as typeof fetch; + + await expect(discoverZenModels()).resolves.toEqual({ + status: "models", + models: ["gpt-6-astra", LIVE_ONLY_ID], + }); + expect(selectableZenModelIds()).toEqual(ZEN_MODEL_IDS); + expect(selectableZenModelIds()).not.toContain(LIVE_ONLY_ID); + }); + + test("distinguishes empty, HTTP unavailable, malformed, and transport failures", async () => { + const cases: { + response: () => Promise; + expected: ZenDiscoveryState["status"]; + }[] = [ + { response: async () => Response.json({ data: [] }), expected: "empty" }, + { + response: async () => new Response("no", { status: 503 }), + expected: "unavailable", + }, + { + response: async () => Response.json({ models: [] }), + expected: "malformed", + }, + ]; + + for (const item of cases) { + globalThis.fetch = item.response as unknown as typeof fetch; + expect((await discoverZenModels()).status).toBe(item.expected); + } + + globalThis.fetch = (async () => + new Response("no", { status: 503 })) as unknown as typeof fetch; + await expect(discoverZenModels()).resolves.toEqual({ + status: "unavailable", + message: "OpenCode Zen returned HTTP 503", + }); + + globalThis.fetch = (async () => { + throw new Error("connection refused"); + }) as unknown as typeof fetch; + await expect(discoverZenModels()).resolves.toEqual({ + status: "unavailable", + message: "connection refused", + }); + }); + + test("rejects an oversized catalog body without treating it as models", async () => { + globalThis.fetch = (async () => + oversizedCatalogResponse( + MAX_ZEN_CATALOG_BYTES + 1, + )) as unknown as typeof fetch; + + const state = await discoverZenModels(); + expect(state.status).toBe("malformed"); + if (state.status !== "malformed") throw new Error("expected malformed"); + expect(state.message).toContain(String(MAX_ZEN_CATALOG_BYTES)); + expect(selectableZenModelIds()).toEqual(ZEN_MODEL_IDS); + }); + + test("rejects a declared Content-Length over the byte cap without reading the body as models", async () => { + globalThis.fetch = (async () => + new Response('{"data":[{"id":"gpt-6-astra"}]}', { + status: 200, + headers: { + "Content-Type": "application/json", + "Content-Length": String(MAX_ZEN_CATALOG_BYTES + 1), + }, + })) as unknown as typeof fetch; + + const state = await discoverZenModels(); + expect(state.status).toBe("malformed"); + if (state.status !== "malformed") throw new Error("expected malformed"); + expect(state.message).toContain(String(MAX_ZEN_CATALOG_BYTES)); + expect(state).not.toEqual({ + status: "models", + models: ["gpt-6-astra"], + }); + }); + + test("rejects a parsed catalog over the model-count cap instead of taking a prefix", async () => { + const data = Array.from({ length: MAX_ZEN_CATALOG_MODELS + 1 }, (_, i) => ({ + id: `zen-model-${String(i)}`, + })); + globalThis.fetch = (async () => + Response.json({ data })) as unknown as typeof fetch; + + const state = await discoverZenModels(); + expect(state.status).toBe("malformed"); + if (state.status !== "malformed") throw new Error("expected malformed"); + expect(state.message).toContain(String(MAX_ZEN_CATALOG_MODELS)); + expect(selectableZenModelIds()).toEqual(ZEN_MODEL_IDS); + }); +}); + +describe("prefetchZenModels", () => { + test("writes the live snapshot; later selectable reads are sync and skip fetch", async () => { + let fetchCount = 0; + globalThis.fetch = (async () => { + fetchCount += 1; + return Response.json({ + data: [{ id: "gpt-6-astra" }, { id: LIVE_ONLY_ID }], + }); + }) as unknown as typeof fetch; + + const ids = await prefetchZenModels(); + expect(ids).toEqual(["gpt-6-astra", LIVE_ONLY_ID]); + expect(ids).toContain(LIVE_ONLY_ID); + expect(fetchCount).toBe(1); + + expect(selectableZenModelIds()).toEqual(["gpt-6-astra", LIVE_ONLY_ID]); + expect(fetchCount).toBe(1); + }); + + test("keeps the live snapshot when a later prefetch fails", async () => { + globalThis.fetch = (async () => + Response.json({ + data: [{ id: LIVE_ONLY_ID }], + })) as unknown as typeof fetch; + await prefetchZenModels(); + expect(selectableZenModelIds()).toEqual([LIVE_ONLY_ID]); + + globalThis.fetch = (async () => { + throw new Error("connection refused"); + }) as unknown as typeof fetch; + const ids = await prefetchZenModels(); + expect(ids).toEqual([LIVE_ONLY_ID]); + expect(selectableZenModelIds()).toEqual([LIVE_ONLY_ID]); + }); + + test("cold failing prefetch falls back to the packaged seed", async () => { + globalThis.fetch = (async () => { + throw new Error("connection refused"); + }) as unknown as typeof fetch; + + const ids = await prefetchZenModels(); + expect(ids).toEqual(ZEN_MODEL_IDS); + expect(ids.length).toBeGreaterThan(0); + expect(selectableZenModelIds()).toEqual(ZEN_MODEL_IDS); + }); + + test("oversized live catalog does not replace the seed with a truncated prefix", async () => { + globalThis.fetch = (async () => + oversizedCatalogResponse( + MAX_ZEN_CATALOG_BYTES + 1, + )) as unknown as typeof fetch; + + const ids = await prefetchZenModels(); + expect(ids).toEqual(ZEN_MODEL_IDS); + expect(selectableZenModelIds()).toEqual(ZEN_MODEL_IDS); + }); + + test("overlapping prefetches share one GET; a later prefetch may GET again", async () => { + let fetchCount = 0; + let release!: (response: Response) => void; + const held = new Promise((resolve) => { + release = resolve; + }); + + globalThis.fetch = (async () => { + fetchCount += 1; + if (fetchCount === 1) { + return held; + } + return Response.json({ data: [{ id: LIVE_ONLY_ID }] }); + }) as unknown as typeof fetch; + + const first = prefetchZenModels(); + const second = prefetchZenModels(); + expect(fetchCount).toBe(1); + + release(Response.json({ data: [{ id: LIVE_ONLY_ID }] })); + await expect(Promise.all([first, second])).resolves.toEqual([ + [LIVE_ONLY_ID], + [LIVE_ONLY_ID], + ]); + expect(fetchCount).toBe(1); + + await prefetchZenModels(); + expect(fetchCount).toBe(2); + }); + + test("an aborted discoverZenModels does not coalesce with prefetchZenModels", async () => { + let fetchCount = 0; + globalThis.fetch = (async ( + _input: RequestInfo | URL, + init?: RequestInit, + ) => { + fetchCount += 1; + if (init?.signal?.aborted) { + throw new DOMException("Aborted", "AbortError"); + } + return Response.json({ data: [{ id: LIVE_ONLY_ID }] }); + }) as unknown as typeof fetch; + + const controller = new AbortController(); + controller.abort(); + const [discoverState, prefetched] = await Promise.all([ + discoverZenModels({ signal: controller.signal }), + prefetchZenModels(), + ]); + + expect(discoverState.status).toBe("unavailable"); + expect(prefetched).toEqual([LIVE_ONLY_ID]); + expect(selectableZenModelIds()).toEqual([LIVE_ONLY_ID]); + expect(fetchCount).toBe(2); + }); + + test("resetZenModelDiscoveryForTests isolates the snapshot between tests", async () => { + globalThis.fetch = (async () => + Response.json({ + data: [{ id: LIVE_ONLY_ID }], + })) as unknown as typeof fetch; + await prefetchZenModels(); + expect(selectableZenModelIds()).toEqual([LIVE_ONLY_ID]); + + resetZenModelDiscoveryForTests(); + expect(selectableZenModelIds()).toEqual(ZEN_MODEL_IDS); + + globalThis.fetch = (async () => { + throw new Error("connection refused"); + }) as unknown as typeof fetch; + expect(await prefetchZenModels()).toEqual(ZEN_MODEL_IDS); + }); +}); diff --git a/src/provider/zen-models.ts b/src/provider/zen-models.ts new file mode 100644 index 000000000..4e637908e --- /dev/null +++ b/src/provider/zen-models.ts @@ -0,0 +1,192 @@ +import { type } from "arktype"; + +import { + ZEN_DEFAULT_BASE_URL, + ZEN_MODEL_IDS, +} from "../../packages/zen/src/index.js"; +import { requestModelsEndpoint } from "./models-endpoint.js"; + +const ZenModelsResponse = type({ + data: type({ id: "string" }).array(), +}); + +// Bound live /models so a huge or hostile catalog cannot blow process memory. +export const MAX_ZEN_CATALOG_BYTES = 256 * 1024; +export const MAX_ZEN_CATALOG_MODELS = 1024; + +export type ZenDiscoveryState = + | { readonly status: "models"; readonly models: readonly string[] } + | { readonly status: "empty" } + | { readonly status: "unavailable"; readonly message: string } + | { readonly status: "malformed"; readonly message: string }; + +let inflight: Promise | undefined; +let snapshot: readonly string[] | undefined; + +function declaredCatalogBytes(response: Response): number | undefined { + const raw = response.headers.get("content-length"); + if (raw === null || raw.length === 0) return undefined; + const n = Number(raw); + if (!Number.isFinite(n) || n < 0) return undefined; + return n; +} + +function oversizeMessage(kind: "bytes" | "models"): string { + if (kind === "bytes") { + return `OpenCode Zen catalog exceeds ${String(MAX_ZEN_CATALOG_BYTES)} bytes`; + } + return `OpenCode Zen catalog exceeds ${String(MAX_ZEN_CATALOG_MODELS)} models`; +} + +async function readCatalogJson( + response: Response, +): Promise< + | { readonly ok: true; readonly value: unknown } + | { readonly ok: false; readonly message: string } +> { + const declared = declaredCatalogBytes(response); + if (declared !== undefined && declared > MAX_ZEN_CATALOG_BYTES) { + await response.body?.cancel().catch(() => undefined); + return { ok: false, message: oversizeMessage("bytes") }; + } + + const body = response.body; + if (body === null) { + try { + const text = await response.text(); + if (new TextEncoder().encode(text).byteLength > MAX_ZEN_CATALOG_BYTES) { + return { ok: false, message: oversizeMessage("bytes") }; + } + const value: unknown = JSON.parse(text); + return { ok: true, value }; + } catch (error) { + return { + ok: false, + message: error instanceof Error ? error.message : String(error), + }; + } + } + + const reader = body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (value === undefined) continue; + total += value.byteLength; + if (total > MAX_ZEN_CATALOG_BYTES) { + await reader.cancel().catch(() => undefined); + return { ok: false, message: oversizeMessage("bytes") }; + } + chunks.push(value); + } + } catch (error) { + return { + ok: false, + message: error instanceof Error ? error.message : String(error), + }; + } + + const buffer = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + buffer.set(chunk, offset); + offset += chunk.byteLength; + } + + try { + const value: unknown = JSON.parse(new TextDecoder().decode(buffer)); + return { ok: true, value }; + } catch (error) { + return { + ok: false, + message: error instanceof Error ? error.message : String(error), + }; + } +} + +/** Discover public OpenCode Zen models without leaking transport or parsing failures. */ +export async function discoverZenModels(args?: { + timeoutMs?: number; + signal?: AbortSignal; +}): Promise { + let response: Response; + try { + response = await requestModelsEndpoint({ + baseURL: ZEN_DEFAULT_BASE_URL, + ...(args?.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {}), + ...(args?.signal !== undefined ? { signal: args.signal } : {}), + }); + } catch (error) { + return { + status: "unavailable", + message: error instanceof Error ? error.message : String(error), + }; + } + + if (!response.ok) { + return { + status: "unavailable", + message: `OpenCode Zen returned HTTP ${String(response.status)}`, + }; + } + + const body = await readCatalogJson(response); + if (!body.ok) { + return { status: "malformed", message: body.message }; + } + const parsed = ZenModelsResponse(body.value); + if (parsed instanceof type.errors) { + return { status: "malformed", message: parsed.summary }; + } + if (parsed.data.length > MAX_ZEN_CATALOG_MODELS) { + return { status: "malformed", message: oversizeMessage("models") }; + } + const models = [ + ...new Set( + parsed.data.map(({ id }) => id.trim()).filter((id) => id.length > 0), + ), + ]; + return models.length > 0 ? { status: "models", models } : { status: "empty" }; +} + +/** Sync picker ids: last successful live list, else the packaged seed. Never empty. */ +export function selectableZenModelIds(): readonly string[] { + return snapshot ?? ZEN_MODEL_IDS; +} + +async function runPrefetch(): Promise { + const state = await discoverZenModels(); + // Empty/unavailable/malformed leave a successful snapshot in place: stale-but-live + // beats empty, and a cold failure still falls through to the packaged seed. + if (state.status === "models") { + snapshot = state.models; + } + return selectableZenModelIds(); +} + +/** Join or start a live fetch; the snapshot is the cache, inflight is only a mutex. */ +export function prefetchZenModels(): Promise { + if (inflight !== undefined) return inflight; + + const pending = runPrefetch(); + inflight = pending; + // Clear inflight on settle so a later prefetch can recover instead of replaying + // the first settlement forever. .then(cleanup, cleanup) instead of .finally() + // avoids an abandoned promise chain whose pass-through rejection could become + // an unhandled rejection — callers await the original pending promise. + const cleanup = (): void => { + if (inflight === pending) { + inflight = undefined; + } + }; + pending.then(cleanup, cleanup); + return pending; +} + +export function resetZenModelDiscoveryForTests(): void { + inflight = undefined; + snapshot = undefined; +} diff --git a/src/tui/provider/choices.ts b/src/tui/provider/choices.ts index cba0d134b..beae03699 100644 --- a/src/tui/provider/choices.ts +++ b/src/tui/provider/choices.ts @@ -17,6 +17,8 @@ import { XAI_BASE_URL, XAI_DEFAULT_MODELS } from "../../auth/xai/constants.js"; import { codexProviderName } from "../../config/codex-providers.js"; import { xaiProviderName } from "../../config/xai-providers.js"; import { selectableGoModelIds } from "../../provider/opencode-go-models.js"; +import { selectableZenModelIds } from "../../provider/zen-models.js"; +import { isZenProviderId } from "../../../packages/zen/src/index.js"; import { buildModelsFirstCatalog } from "../model-catalog.js"; import type { ResidualCatalogEntry } from "../residuals.js"; import type { CliRenderer } from "@opentui/core"; @@ -124,7 +126,12 @@ const CUSTOM_CHOICE: ProviderChoice = { function choiceFromDef(def: FirstClassProviderDef): ProviderChoice | null { if (def.auth !== "api-key" && def.auth !== "keyless") return null; if (def.baseURL === undefined || def.models === undefined) return null; - const models = def.opencodeGo === true ? selectableGoModelIds() : def.models; + const models = + def.opencodeGo === true + ? selectableGoModelIds() + : isZenProviderId(def.id) + ? selectableZenModelIds() + : def.models; const defaultModel = def.defaultModel ?? models[0]; if (defaultModel === undefined) return null; return { @@ -365,6 +372,7 @@ export function enterModelListRows( activeIndex: active, }); discovery.beginGoPrefetch(); + discovery.beginZenPrefetch(); } /** Rebuild the pick-list rows for the provider step, keeping the prior pick focused. */ diff --git a/src/tui/provider/discovery.ts b/src/tui/provider/discovery.ts index 77f831b17..467cf73b1 100644 --- a/src/tui/provider/discovery.ts +++ b/src/tui/provider/discovery.ts @@ -1,8 +1,8 @@ /** * Background model discovery for the setup surface: Ollama's installed-model * list (which replaces the seeded pick-list once it resolves) and the OpenCode - * Go catalog prefetch. Both mutate the shared state and repaint; both ignore - * resolutions from superseded attempts. + * Go and Zen catalog prefetches. All mutate the shared state and repaint; all + * ignore resolutions from superseded attempts. */ import { createOverlayList } from "../shell/overlay-list.js"; @@ -35,6 +35,10 @@ export function createDiscoveryFlows( state.goPrefetchAttempt += 1; }; + const abandonZenPrefetch = (): void => { + state.zenPrefetchAttempt += 1; + }; + const beginOllamaDiscovery = (): void => { if (!selectors.isOllamaModelStep()) return; abandonOllamaDiscovery(); @@ -137,10 +141,51 @@ export function createDiscoveryFlows( }); }; + const beginZenPrefetch = (): void => { + if (!selectors.isZenModelListStep()) return; + abandonZenPrefetch(); + const attempt = state.zenPrefetchAttempt; + void state + .prefetchZenModels() + .then((ids) => { + if ( + state.settled || + attempt !== state.zenPrefetchAttempt || + !selectors.isZenModelListStep() || + state.choice === null + ) { + return; + } + const listed = state.choice.models; + const same = + ids.length === listed.length && + ids.every((id, i) => id === listed[i]); + if (same) return; + const focusedId = state.listRows[state.list.activeIndex]?.id; + state.choice = { ...state.choice, models: [...ids] }; + state.listRows = modelChoiceRows(state.choice); + const found = + focusedId === undefined + ? -1 + : state.listRows.findIndex((row) => row.id === focusedId); + state.list = createOverlayList(state.renderer as CliRenderer, { + count: state.listRows.length, + items: providerListHeight(state.renderer), + activeIndex: found >= 0 ? found : 0, + }); + surface.paint(); + }) + .catch(() => { + // Seed list is already on screen; a failed prefetch must not surface. + }); + }; + return { beginOllamaDiscovery, abandonOllamaDiscovery, beginGoPrefetch, abandonGoPrefetch, + beginZenPrefetch, + abandonZenPrefetch, }; } diff --git a/src/tui/provider/setup.ts b/src/tui/provider/setup.ts index 1d34bd7af..b8fffa745 100644 --- a/src/tui/provider/setup.ts +++ b/src/tui/provider/setup.ts @@ -25,6 +25,8 @@ import { isOllamaProviderId, } from "../../provider/ollama.js"; import { prefetchGoModels as prefetchGoModelsRequest } from "../../provider/opencode-go-models.js"; +import { prefetchZenModels as prefetchZenModelsRequest } from "../../provider/zen-models.js"; +import { isZenProviderId } from "../../../packages/zen/src/index.js"; import { resolveSideMargin } from "../geometry/margins.js"; import { residualIdFromSelection, @@ -139,6 +141,8 @@ export async function runProviderSetup( ollamaDiscoveryAbort: null, prefetchGoModels: config.prefetchGoModels ?? prefetchGoModelsRequest, goPrefetchAttempt: 0, + prefetchZenModels: config.prefetchZenModels ?? prefetchZenModelsRequest, + zenPrefetchAttempt: 0, listRows: initialRows, list: createOverlayList(renderer as CliRenderer, { count: initialRows.length, @@ -196,6 +200,14 @@ export async function runProviderSetup( state.choice.opencodeGo && !state.choice.custom && !state.typedModel; + // The Zen first-class row carries no settings flag of its own, so the step + // matches on the provider id — the same id choiceFromDef passes through. + const isZenModelListStep = (): boolean => + currentStep() === "model" && + state.choice !== null && + isZenProviderId(state.choice.id) && + !state.choice.custom && + !state.typedModel; const selectors: SetupSelectors = { steps, currentStep, @@ -203,6 +215,7 @@ export async function runProviderSetup( isListStep, isAccountNameStep, isGoModelListStep, + isZenModelListStep, }; const isLoginStep = (): boolean => currentStep() === "login"; @@ -393,6 +406,7 @@ export async function runProviderSetup( state.ollamaDiscovery = "idle"; } if (isGoModelListStep()) discovery.abandonGoPrefetch(); + if (isZenModelListStep()) discovery.abandonZenPrefetch(); state.stepIndex -= 1; clearError(); if (currentStep() === "provider") enterProviderList(); diff --git a/src/tui/provider/surface.ts b/src/tui/provider/surface.ts index 1dc3d6d05..ecaeac6a1 100644 --- a/src/tui/provider/surface.ts +++ b/src/tui/provider/surface.ts @@ -89,6 +89,7 @@ export function teardownSurface( login.abandonLogin(); discovery.abandonOllamaDiscovery(); discovery.abandonGoPrefetch(); + discovery.abandonZenPrefetch(); state.renderer.keyInput.off("keypress", onKey); surface.input.off(InputRenderableEvents.ENTER, onEnter); surface.input.off(InputRenderableEvents.INPUT, onInput); diff --git a/src/tui/provider/types.ts b/src/tui/provider/types.ts index 057b57453..65bb65f99 100644 --- a/src/tui/provider/types.ts +++ b/src/tui/provider/types.ts @@ -21,6 +21,7 @@ import type { OllamaDiscoveryState, } from "../../provider/ollama.js"; import type { prefetchGoModels as prefetchGoModelsRequest } from "../../provider/opencode-go-models.js"; +import type { prefetchZenModels as prefetchZenModelsRequest } from "../../provider/zen-models.js"; import type { ResidualCatalogEntry } from "../residuals.js"; import type { OverlayList } from "../shell/internals.js"; import type { SetupStep } from "./steps.js"; @@ -144,6 +145,8 @@ export interface ProviderSetupConfig { readonly discoverOllamaModels?: typeof discoverOllamaModelsRequest; /** Go catalog prefetch override so setup tests stay off the network. */ readonly prefetchGoModels?: typeof prefetchGoModelsRequest; + /** Zen catalog prefetch override so setup tests stay off the network. */ + readonly prefetchZenModels?: typeof prefetchZenModelsRequest; /** * Skip the provider pick-list and start directly on that provider's first * form step (account name for multi-instance kinds, or the custom name @@ -217,6 +220,8 @@ export interface SetupState { ollamaDiscoveryAbort: AbortController | null; readonly prefetchGoModels: typeof prefetchGoModelsRequest; goPrefetchAttempt: number; + readonly prefetchZenModels: typeof prefetchZenModelsRequest; + zenPrefetchAttempt: number; listRows: readonly ResidualCatalogEntry[]; list: OverlayList; settled: boolean; @@ -245,6 +250,8 @@ export interface DiscoveryFlows { abandonOllamaDiscovery(): void; beginGoPrefetch(): void; abandonGoPrefetch(): void; + beginZenPrefetch(): void; + abandonZenPrefetch(): void; } /** Multi-instance "name" step (OAuth accounts and API-key instances). */ @@ -270,4 +277,5 @@ export interface SetupSelectors { isListStep(): boolean; isAccountNameStep(): boolean; isGoModelListStep(): boolean; + isZenModelListStep(): boolean; } diff --git a/src/tui/runner/settings.ts b/src/tui/runner/settings.ts index 1d6c3cbef..5dcf250bc 100644 --- a/src/tui/runner/settings.ts +++ b/src/tui/runner/settings.ts @@ -39,7 +39,9 @@ import { connectProviderInline } from "../provider/connect.js"; import { persistConnectedSelection } from "../provider/submit.js"; import { modelOptionId } from "../model-catalog.js"; import { prefetchGoModels } from "../../provider/opencode-go-models.js"; +import { prefetchZenModels } from "../../provider/zen-models.js"; import { isOpenCodeGoProvider } from "../../../packages/opencode-go/src/index.js"; +import { isZenProvider } from "../../../packages/zen/src/index.js"; import { applyLiveModelSwitch } from "../../session/live-model-switch.js"; import { applyFocus } from "../shell/chrome.js"; import { setShellInputSuspended } from "../shell/prompt.js"; @@ -347,6 +349,32 @@ export async function wireSettings( }); }); } + if (isZenProvider({ name: providerName })) { + void prefetchZenModels() + .then(async () => { + if (services.hostHolder.instance === undefined) return; + const nextDisk = await loadSettings(trueGlobalSettingsPath); + const nextProviders = await refreshLiveProviderCatalog( + nextDisk, + resolvedForCatalog, + ); + state.config = { + ...state.config, + providers: nextProviders, + ...(nextDisk !== null ? { settings: nextDisk } : {}), + }; + services.hostHolder.instance.refreshModels( + listRecentModels(state.config.settings ?? { providers: {} }), + listFavoriteModels(state.config.settings ?? { providers: {} }), + nextProviders, + ); + }) + .catch((err: unknown) => { + tuiLogger.debug("zen model prefetch failed: {error}", { + error: err instanceof Error ? err.message : String(err), + }); + }); + } })().catch((err: unknown) => { tuiLogger.debug("provider connect failed: {error}", { error: err instanceof Error ? err.message : String(err), diff --git a/src/tui/runner/wiring.ts b/src/tui/runner/wiring.ts index aca682194..c907e347a 100644 --- a/src/tui/runner/wiring.ts +++ b/src/tui/runner/wiring.ts @@ -15,7 +15,9 @@ import { import { refreshLiveProviderCatalog } from "../../config/index.js"; import type { ResolvedProvider } from "../../config/settings.js"; import { prefetchGoModels } from "../../provider/opencode-go-models.js"; +import { prefetchZenModels } from "../../provider/zen-models.js"; import { isOpenCodeGoProvider } from "../../../packages/opencode-go/src/index.js"; +import { isZenProvider } from "../../../packages/zen/src/index.js"; import { loadRecentTurns } from "../../session/optimized-context-store.js"; import { loadSentMessages } from "../../session/sent-messages.js"; import { setActiveDisposeHost } from "../../session/active-host.js"; @@ -169,6 +171,41 @@ export function wirePostStartup( }); }); } + if (state.config.providers.some((p) => isZenProvider(p))) { + void prefetchZenModels() + .then(async () => { + if (services.hostHolder.instance === undefined) return; + const onDisk = await loadSettings(state.trueGlobalSettingsPath); + const resolvedForCatalog: ResolvedProvider = { + apiKey: state.config.apiKey, + baseURL: state.config.baseURL, + model: state.config.model, + providerName: state.config.providerName, + ...(state.config.keyless !== undefined + ? { keyless: state.config.keyless } + : {}), + }; + const providers = await refreshLiveProviderCatalog( + onDisk, + resolvedForCatalog, + ); + state.config = { + ...state.config, + providers, + ...(onDisk !== null ? { settings: onDisk } : {}), + }; + services.hostHolder.instance.refreshModels( + listRecentModels(state.config.settings ?? { providers: {} }), + listFavoriteModels(state.config.settings ?? { providers: {} }), + providers, + ); + }) + .catch((err: unknown) => { + tuiLogger.debug("zen model prefetch failed: {error}", { + error: err instanceof Error ? err.message : String(err), + }); + }); + } const shutdownRuntime = createRuntimeShutdown({ disposeHost: hostOf(state).dispose, From 411996c5605640f01fd40b53f4f58b09ca8a22ba Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 12 Sep 2026 11:49:31 -0700 Subject: [PATCH 2/2] Keep Zen chat-completions sources on the openai-compatible provider --- src/config/index.ts | 2 -- src/config/inference-sources.test.ts | 25 +++++++++++++++++++++++-- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/src/config/index.ts b/src/config/index.ts index 43a0a4b59..5abb8ebd4 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -72,7 +72,6 @@ import { } from "../../packages/opencode-go/src/index.js"; import { ZEN_DEFAULT_BASE_URL, - ZEN_PROVIDER_ID, isZenProvider, resolveZenEndpoint, } from "../../packages/zen/src/index.js"; @@ -518,7 +517,6 @@ export function buildZenSource(fields: { }); return { ...source, - provider: ZEN_PROVIDER_ID, defaults: { ...source.defaults, providerOptions: { diff --git a/src/config/inference-sources.test.ts b/src/config/inference-sources.test.ts index c639b92b8..f9efc58e9 100644 --- a/src/config/inference-sources.test.ts +++ b/src/config/inference-sources.test.ts @@ -12,6 +12,7 @@ import { setProviderContextWindowOverrides, } from "../provider/context-window.js"; import { createOpenAICompatibleAdapter } from "../provider/openai-compatible-adapter.js"; +import { createInferenceDependencies } from "../provider/inference-dependencies.js"; import { OPENAI_RESPONSES_PROVIDER } from "../provider/openai-responses.js"; import { ZEN_MESSAGES_PROVIDER } from "../provider/zen-anthropic-adapter.js"; import { firstClassProviderById } from "../../packages/first-class-providers/src/index.js"; @@ -230,13 +231,33 @@ describe("Zen protocol routing (CL-7811)", () => { test("Gemini models stay on chat completions", () => { const source = zenSource("gemini-3-flash"); - expect(source?.provider).toBe(ZEN_PROVIDER_ID); + expect(source?.provider).toBe("openai-compatible"); expect(source?.baseURL).toBe(ZEN_DEFAULT_BASE_URL); }); test("unknown ids default to chat completions, never name-prefix inference", () => { const source = zenSource("some-future-model"); - expect(source?.provider).toBe(ZEN_PROVIDER_ID); + expect(source?.provider).toBe("openai-compatible"); expect(source?.baseURL).toBe(ZEN_DEFAULT_BASE_URL); }); + + test("chat-completions Zen sources resolve to a registered adapter", async () => { + const deps = await createInferenceDependencies(); + for (const model of ["gemini-3-flash", "some-future-model"]) { + const source = zenSource(model); + expect(source?.provider).toBe("openai-compatible"); + expect(source).toBeDefined(); + if (source === undefined || source === null) continue; + expect(() => + deps.adapters.resolve( + { + sourceId: source.id, + provider: source.provider, + model: source.model, + }, + source.quirks, + ), + ).not.toThrow(); + } + }); });