From fe7b1d3b1b1b4674bca2e9219077e658d0bf7446 Mon Sep 17 00:00:00 2001 From: Zachary Date: Fri, 30 Jan 2026 09:58:12 -0500 Subject: [PATCH] refactor(agents): Dynamically determine available OpenCode Zen models Wires up the previously unused discoverOpencodeZenModels() function to actually fetch models dynamically from the OpenCode Zen API, matching the pattern used by other providers (Venice, Bedrock). The codebase already had infrastructure for dynamic OpenCode Zen model discovery (discoverOpencodeZenModels() in opencode-zen-models.ts), but it was never actually called from the provider configuration. The system was falling back to a hard-coded static catalog that was already out of date. --- src/agents/models-config.providers.ts | 33 ++- src/agents/opencode-zen-models.test.ts | 19 +- src/agents/opencode-zen-models.ts | 345 +++++++++++++++---------- 3 files changed, 248 insertions(+), 149 deletions(-) diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index 0cd034c82..ee14f71db 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -13,6 +13,7 @@ import { SYNTHETIC_MODEL_CATALOG, } from "./synthetic-models.js"; import { discoverVeniceModels, VENICE_BASE_URL } from "./venice-models.js"; +import { discoverOpencodeZenModels, OPENCODE_ZEN_API_BASE_URL } from "./opencode-zen-models.js"; type ModelsConfig = NonNullable; export type ProviderConfig = NonNullable[string]; @@ -388,6 +389,16 @@ async function buildOllamaProvider(): Promise { }; } +async function buildOpencodeZenProvider(apiKey: string): Promise { + const models = await discoverOpencodeZenModels(apiKey); + return { + baseUrl: OPENCODE_ZEN_API_BASE_URL, + api: "openai-completions", + models, + apiKey, + }; +} + export async function resolveImplicitProviders(params: { agentDir: string; }): Promise { @@ -414,7 +425,10 @@ export async function resolveImplicitProviders(params: { resolveEnvApiKeyVarName("kimi-code") ?? resolveApiKeyFromProfiles({ provider: "kimi-code", store: authStore }); if (kimiCodeKey) { - providers["kimi-code"] = { ...buildKimiCodeProvider(), apiKey: kimiCodeKey }; + providers["kimi-code"] = { + ...buildKimiCodeProvider(), + apiKey: kimiCodeKey, + }; } const syntheticKey = @@ -454,6 +468,14 @@ export async function resolveImplicitProviders(params: { providers.ollama = { ...(await buildOllamaProvider()), apiKey: ollamaKey }; } + // OpenCode Zen provider - dynamically fetches models from API + const opencodeKey = + resolveEnvApiKeyVarName("opencode") ?? + resolveApiKeyFromProfiles({ provider: "opencode", store: authStore }); + if (opencodeKey) { + providers.opencode = await buildOpencodeZenProvider(opencodeKey); + } + return providers; } @@ -462,7 +484,9 @@ export async function resolveImplicitCopilotProvider(params: { env?: NodeJS.ProcessEnv; }): Promise { const env = params.env ?? process.env; - const authStore = ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false }); + const authStore = ensureAuthProfileStore(params.agentDir, { + allowKeychainPrompt: false, + }); const hasProfile = listProfilesForProvider(authStore, "github-copilot").length > 0; const envToken = env.COPILOT_GITHUB_TOKEN ?? env.GH_TOKEN ?? env.GITHUB_TOKEN; const githubToken = (envToken ?? "").trim(); @@ -527,7 +551,10 @@ export async function resolveImplicitBedrockProvider(params: { if (enabled !== true && !hasAwsCreds) return null; const region = discoveryConfig?.region ?? env.AWS_REGION ?? env.AWS_DEFAULT_REGION ?? "us-east-1"; - const models = await discoverBedrockModels({ region, config: discoveryConfig }); + const models = await discoverBedrockModels({ + region, + config: discoveryConfig, + }); if (models.length === 0) return null; return { diff --git a/src/agents/opencode-zen-models.test.ts b/src/agents/opencode-zen-models.test.ts index 19734a78d..84247b314 100644 --- a/src/agents/opencode-zen-models.test.ts +++ b/src/agents/opencode-zen-models.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "vitest"; import { - getOpencodeZenStaticFallbackModels, OPENCODE_ZEN_MODEL_ALIASES, + OPENCODE_ZEN_STATIC_MODEL_DEFINITIONS, resolveOpencodeZenAlias, resolveOpencodeZenModelApi, } from "./opencode-zen-models.js"; @@ -50,16 +50,14 @@ describe("resolveOpencodeZenModelApi", () => { }); }); -describe("getOpencodeZenStaticFallbackModels", () => { - it("returns an array of models", () => { - const models = getOpencodeZenStaticFallbackModels(); - expect(Array.isArray(models)).toBe(true); - expect(models.length).toBe(9); +describe("OPENCODE_ZEN_STATIC_MODEL_DEFINITIONS", () => { + it("is an array of model definitions", () => { + expect(Array.isArray(OPENCODE_ZEN_STATIC_MODEL_DEFINITIONS)).toBe(true); + expect(OPENCODE_ZEN_STATIC_MODEL_DEFINITIONS.length).toBe(9); }); it("includes Claude, GPT, Gemini, and GLM models", () => { - const models = getOpencodeZenStaticFallbackModels(); - const ids = models.map((m) => m.id); + const ids = OPENCODE_ZEN_STATIC_MODEL_DEFINITIONS.map((m) => m.id); expect(ids).toContain("claude-opus-4-5"); expect(ids).toContain("gpt-5.2"); @@ -68,9 +66,8 @@ describe("getOpencodeZenStaticFallbackModels", () => { expect(ids).toContain("glm-4.7"); }); - it("returns valid ModelDefinitionConfig objects", () => { - const models = getOpencodeZenStaticFallbackModels(); - for (const model of models) { + it("contains valid ModelDefinitionConfig objects", () => { + for (const model of OPENCODE_ZEN_STATIC_MODEL_DEFINITIONS) { expect(model.id).toBeDefined(); expect(model.name).toBeDefined(); expect(typeof model.reasoning).toBe("boolean"); diff --git a/src/agents/opencode-zen-models.ts b/src/agents/opencode-zen-models.ts index efe7e98bb..f59598db7 100644 --- a/src/agents/opencode-zen-models.ts +++ b/src/agents/opencode-zen-models.ts @@ -8,16 +8,136 @@ * Auth URL: https://opencode.ai/auth */ +import crypto from "node:crypto"; + import type { ModelApi, ModelDefinitionConfig } from "../config/types.js"; export const OPENCODE_ZEN_API_BASE_URL = "https://opencode.ai/zen/v1"; export const OPENCODE_ZEN_DEFAULT_MODEL = "claude-opus-4-5"; export const OPENCODE_ZEN_DEFAULT_MODEL_REF = `opencode/${OPENCODE_ZEN_DEFAULT_MODEL}`; -// Cache for fetched models (1 hour TTL) -let cachedModels: ModelDefinitionConfig[] | null = null; -let cacheTimestamp = 0; -const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour +export const OPENCODE_ZEN_DEFAULT_COST = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, +}; + +/** + * Static catalog of known OpenCode Zen models with metadata. + * Serves as fallback when API is unreachable and for enriching + * API-discovered models with known metadata. + */ +export const OPENCODE_ZEN_STATIC_CATALOG = [ + { + id: "gpt-5.1-codex", + name: "GPT-5.1 Codex", + reasoning: true, + input: ["text"] as Array<"text" | "image">, + cost: { input: 1.07, output: 8.5, cacheRead: 0.107, cacheWrite: 0 }, + contextWindow: 400000, + maxTokens: 128000, + }, + { + id: "claude-opus-4-5", + name: "Claude Opus 4.5", + reasoning: true, + input: ["text", "image"] as Array<"text" | "image">, + cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + contextWindow: 200000, + maxTokens: 64000, + }, + { + id: "gemini-3-pro", + name: "Gemini 3 Pro", + reasoning: true, + input: ["text", "image"] as Array<"text" | "image">, + cost: { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 0 }, + contextWindow: 1048576, + maxTokens: 65536, + }, + { + id: "gpt-5.1-codex-mini", + name: "GPT-5.1 Codex Mini", + reasoning: true, + input: ["text"] as Array<"text" | "image">, + cost: { input: 0.25, output: 2, cacheRead: 0.025, cacheWrite: 0 }, + contextWindow: 400000, + maxTokens: 128000, + }, + { + id: "gpt-5.1", + name: "GPT-5.1", + reasoning: true, + input: ["text", "image"] as Array<"text" | "image">, + cost: { input: 1.07, output: 8.5, cacheRead: 0.107, cacheWrite: 0 }, + contextWindow: 400000, + maxTokens: 128000, + }, + { + id: "glm-4.7", + name: "GLM-4.7", + reasoning: true, + input: ["text"] as Array<"text" | "image">, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 204800, + maxTokens: 131072, + }, + { + id: "gemini-3-flash", + name: "Gemini 3 Flash", + reasoning: true, + input: ["text", "image"] as Array<"text" | "image">, + cost: { input: 0.5, output: 3, cacheRead: 0.05, cacheWrite: 0 }, + contextWindow: 1048576, + maxTokens: 65536, + }, + { + id: "gpt-5.1-codex-max", + name: "GPT-5.1 Codex Max", + reasoning: true, + input: ["text"] as Array<"text" | "image">, + cost: { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0 }, + contextWindow: 400000, + maxTokens: 128000, + }, + { + id: "gpt-5.2", + name: "GPT-5.2", + reasoning: true, + input: ["text", "image"] as Array<"text" | "image">, + cost: { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 }, + contextWindow: 400000, + maxTokens: 128000, + }, +] as const; + +export const OPENCODE_ZEN_STATIC_MODEL_DEFINITIONS: ModelDefinitionConfig[] = + OPENCODE_ZEN_STATIC_CATALOG.map(buildOpencodeZenModelDefinition); + +const OPENCODE_ZEN_CATALOG_BY_ID = new Map( + OPENCODE_ZEN_STATIC_CATALOG.map((entry) => [entry.id, entry]), +); + +export type OpencodeZenCatalogEntry = (typeof OPENCODE_ZEN_STATIC_CATALOG)[number]; + +/** + * Build a ModelDefinitionConfig from a catalog entry. + */ +export function buildOpencodeZenModelDefinition( + entry: OpencodeZenCatalogEntry, +): ModelDefinitionConfig { + return { + id: entry.id, + name: entry.name, + api: resolveOpencodeZenModelApi(entry.id), + reasoning: entry.reasoning, + input: [...entry.input], + cost: entry.cost, + contextWindow: entry.contextWindow, + maxTokens: entry.maxTokens, + }; +} /** * Model aliases for convenient shortcuts. @@ -100,6 +220,7 @@ export function resolveOpencodeZenModelApi(modelId: string): ModelApi { /** * Check if a model supports image input. + * Used as fallback for unknown models from the API. */ function supportsImageInput(modelId: string): boolean { const lower = modelId.toLowerCase(); @@ -109,105 +230,14 @@ function supportsImageInput(modelId: string): boolean { return true; } -const MODEL_COSTS: Record< - string, - { input: number; output: number; cacheRead: number; cacheWrite: number } -> = { - "gpt-5.1-codex": { - input: 1.07, - output: 8.5, - cacheRead: 0.107, - cacheWrite: 0, - }, - "claude-opus-4-5": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - "gemini-3-pro": { input: 2, output: 12, cacheRead: 0.2, cacheWrite: 0 }, - "gpt-5.1-codex-mini": { - input: 0.25, - output: 2, - cacheRead: 0.025, - cacheWrite: 0, - }, - "gpt-5.1": { input: 1.07, output: 8.5, cacheRead: 0.107, cacheWrite: 0 }, - "glm-4.7": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - "gemini-3-flash": { input: 0.5, output: 3, cacheRead: 0.05, cacheWrite: 0 }, - "gpt-5.1-codex-max": { - input: 1.25, - output: 10, - cacheRead: 0.125, - cacheWrite: 0, - }, - "gpt-5.2": { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 }, -}; - -const DEFAULT_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; - -const MODEL_CONTEXT_WINDOWS: Record = { - "gpt-5.1-codex": 400000, - "claude-opus-4-5": 200000, - "gemini-3-pro": 1048576, - "gpt-5.1-codex-mini": 400000, - "gpt-5.1": 400000, - "glm-4.7": 204800, - "gemini-3-flash": 1048576, - "gpt-5.1-codex-max": 400000, - "gpt-5.2": 400000, -}; - -function getDefaultContextWindow(modelId: string): number { - return MODEL_CONTEXT_WINDOWS[modelId] ?? 128000; -} - -const MODEL_MAX_TOKENS: Record = { - "gpt-5.1-codex": 128000, - "claude-opus-4-5": 64000, - "gemini-3-pro": 65536, - "gpt-5.1-codex-mini": 128000, - "gpt-5.1": 128000, - "glm-4.7": 131072, - "gemini-3-flash": 65536, - "gpt-5.1-codex-max": 128000, - "gpt-5.2": 128000, -}; - -function getDefaultMaxTokens(modelId: string): number { - return MODEL_MAX_TOKENS[modelId] ?? 8192; -} - -/** - * Build a ModelDefinitionConfig from a model ID. - */ -function buildModelDefinition(modelId: string): ModelDefinitionConfig { - return { - id: modelId, - name: formatModelName(modelId), - api: resolveOpencodeZenModelApi(modelId), - // Treat Zen models as reasoning-capable so defaults pick thinkLevel="low" unless users opt out. - reasoning: true, - input: supportsImageInput(modelId) ? ["text", "image"] : ["text"], - cost: MODEL_COSTS[modelId] ?? DEFAULT_COST, - contextWindow: getDefaultContextWindow(modelId), - maxTokens: getDefaultMaxTokens(modelId), - }; -} - /** * Format a model ID into a human-readable name. + * Used as fallback for unknown models from the API. */ -const MODEL_NAMES: Record = { - "gpt-5.1-codex": "GPT-5.1 Codex", - "claude-opus-4-5": "Claude Opus 4.5", - "gemini-3-pro": "Gemini 3 Pro", - "gpt-5.1-codex-mini": "GPT-5.1 Codex Mini", - "gpt-5.1": "GPT-5.1", - "glm-4.7": "GLM-4.7", - "gemini-3-flash": "Gemini 3 Flash", - "gpt-5.1-codex-max": "GPT-5.1 Codex Max", - "gpt-5.2": "GPT-5.2", -}; - function formatModelName(modelId: string): string { - if (MODEL_NAMES[modelId]) { - return MODEL_NAMES[modelId]; + const catalogEntry = OPENCODE_ZEN_CATALOG_BY_ID.get(modelId); + if (catalogEntry) { + return catalogEntry.name; } return modelId @@ -216,25 +246,6 @@ function formatModelName(modelId: string): string { .join(" "); } -/** - * Static fallback models when API is unreachable. - */ -export function getOpencodeZenStaticFallbackModels(): ModelDefinitionConfig[] { - const modelIds = [ - "gpt-5.1-codex", - "claude-opus-4-5", - "gemini-3-pro", - "gpt-5.1-codex-mini", - "gpt-5.1", - "glm-4.7", - "gemini-3-flash", - "gpt-5.1-codex-max", - "gpt-5.2", - ]; - - return modelIds.map(buildModelDefinition); -} - /** * Response shape from OpenCode Zen /models endpoint. * Returns OpenAI-compatible format. @@ -248,18 +259,46 @@ interface ZenModelsResponse { }>; } +type OpencodeZenCacheEntry = { + models: ModelDefinitionConfig[]; + timestamp: number; +}; + /** - * Fetch models from the OpenCode Zen API. - * Uses caching with 1-hour TTL. + * Cache for fetched models (1 hour TTL). + * Scoped by a hashed API key to avoid cross-key leakage. + */ +const cachedModelsByKey = new Map(); +const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour + +function hashApiKey(apiKey: string): string { + return crypto.createHash("sha256").update(apiKey).digest("hex"); +} + +function resolveCacheKey(apiKey?: string): string { + if (!apiKey) return "public"; + return `key:${hashApiKey(apiKey)}`; +} + +/** + * Discover models from the OpenCode Zen API. + * Fetches dynamically and merges with static catalog metadata. * - * @param apiKey - OpenCode Zen API key for authentication + * @param apiKey - OpenCode Zen API key for authentication (optional for discovery) * @returns Array of model definitions, or static fallback on failure */ -export async function fetchOpencodeZenModels(apiKey?: string): Promise { +export async function discoverOpencodeZenModels(apiKey?: string): Promise { + // Skip API discovery in test environment + if (process.env.NODE_ENV === "test" || process.env.VITEST) { + return OPENCODE_ZEN_STATIC_MODEL_DEFINITIONS; + } + // Return cached models if still valid const now = Date.now(); - if (cachedModels && now - cacheTimestamp < CACHE_TTL_MS) { - return cachedModels; + const cacheKey = resolveCacheKey(apiKey); + const cachedEntry = cachedModelsByKey.get(cacheKey); + if (cachedEntry && now - cachedEntry.timestamp < CACHE_TTL_MS) { + return cachedEntry.models; } try { @@ -273,35 +312,71 @@ export async function fetchOpencodeZenModels(apiKey?: string): Promise buildModelDefinition(model.id)); + const models: ModelDefinitionConfig[] = []; - cachedModels = models; - cacheTimestamp = now; + for (const apiModel of data.data) { + const catalogEntry = OPENCODE_ZEN_CATALOG_BY_ID.get(apiModel.id); + + if (catalogEntry) { + // Use rich catalog metadata for known models + models.push(buildOpencodeZenModelDefinition(catalogEntry)); + } else { + // Create definition for newly discovered models not in catalog + // This allows new models (like kimi-k2.5-free) to appear automatically + const hasVision = supportsImageInput(apiModel.id); + models.push({ + id: apiModel.id, + name: formatModelName(apiModel.id), + api: resolveOpencodeZenModelApi(apiModel.id), + // Treat Zen models as reasoning-capable by default + reasoning: true, + input: hasVision ? ["text", "image"] : ["text"], + cost: OPENCODE_ZEN_DEFAULT_COST, + contextWindow: 128000, + maxTokens: 8192, + }); + } + } + + // Cache the results + cachedModelsByKey.set(cacheKey, { models, timestamp: now }); return models; } catch (error) { - console.warn(`[opencode-zen] Failed to fetch models, using static fallback: ${String(error)}`); - return getOpencodeZenStaticFallbackModels(); + console.warn(`[opencode-zen] Discovery failed: ${String(error)}, using static catalog`); + return OPENCODE_ZEN_STATIC_MODEL_DEFINITIONS; } } /** * Clear the model cache (useful for testing or forcing refresh). */ -export function clearOpencodeZenModelCache(): void { - cachedModels = null; - cacheTimestamp = 0; +export async function fetchOpencodeZenModels(apiKey?: string): Promise { + return discoverOpencodeZenModels(apiKey); +} + +/** + * Clear the model cache (useful for testing or forcing refresh). + */ +export function clearOpencodeZenModelCache(): void { + cachedModelsByKey.clear(); }