From 025c5dd38c6c4131bb67abf578fcea80e0e98f75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Fri, 24 Jul 2026 16:32:12 +0000 Subject: [PATCH] chat: surface OpenCode models in the picker (Phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit list-models.ts now merges the OpenCode catalog (from `opencode models`, cached; ids are providerID/modelID) with the static Claude tiers, so /chat/models returns both. invalidateModelCache clears the OpenCode cache for real now. Adds the 'opencode' → 'OpenCode Zen' provider label in the /models response and the ModelSelector's PROVIDER_DISPLAY. The existing useModels visibility gates already pass non-claude-code providers through, so no gate changes are needed — Super Admin sees all models. Selecting any non-claude-code model routes the turn to the OpenCode harness (Phase 1). Default model stays 'claude-code'. 58 OpenCode Zen models currently list; curating to a flagship subset is an easy follow-up if the full catalog is unwieldy. Co-Authored-By: Claude Opus 4.8 --- src/servers/api/chat/chat.ts | 2 +- src/servers/api/chat/list-models.ts | 50 +++++++++++++++++-- .../apps/Chat/components/ModelSelector.tsx | 1 + 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/src/servers/api/chat/chat.ts b/src/servers/api/chat/chat.ts index 2ac17b68..92aa89ef 100644 --- a/src/servers/api/chat/chat.ts +++ b/src/servers/api/chat/chat.ts @@ -62,7 +62,7 @@ chatRouter.patch('/sessions/:id/title', async (ctx) => { chatRouter.get('/models', async (ctx: Context) => { try { const models = await listChatModels(); - const providerNames: Record = { 'claude-code': 'Claude Code' }; + const providerNames: Record = { 'claude-code': 'Claude Code', opencode: 'OpenCode Zen' }; return ctx.json({ models, providerNames, hostHome: process.env.HOME ?? '' }); } catch (err) { logger.error('Failed to list models', { error: String(err) }); diff --git a/src/servers/api/chat/list-models.ts b/src/servers/api/chat/list-models.ts index 0abc04fa..c742d56d 100644 --- a/src/servers/api/chat/list-models.ts +++ b/src/servers/api/chat/list-models.ts @@ -1,16 +1,60 @@ import type { ModelInfo } from './types'; +import { homedir } from 'os'; +import { join } from 'path'; -// Claude-only. The runner is the `claude` CLI, so the model list is a fixed set of Claude tiers. +// The Claude harness runs the `claude` CLI, so its tiers are a fixed set. const CLAUDE_CODE_MODELS: ModelInfo[] = [ { id: 'claude-code/opus', name: 'opus', provider: 'claude-code', contextWindow: 200000, maxTokens: 16000, reasoning: true, images: true }, { id: 'claude-code/sonnet', name: 'sonnet', provider: 'claude-code', contextWindow: 200000, maxTokens: 16000, reasoning: true, images: true }, { id: 'claude-code/haiku', name: 'haiku', provider: 'claude-code', contextWindow: 200000, maxTokens: 8192, reasoning: false, images: true }, ]; +const OPENCODE_BIN = process.env.OPENCODE_BIN || join(homedir(), '.opencode', 'bin', 'opencode'); + +// Cache the OpenCode catalog; it's stable for a session and `opencode models` costs a subprocess. +let openCodeCache: ModelInfo[] | null = null; + export function invalidateModelCache(): void { - // No-op: the Claude model list is static. Kept for call-site compatibility. + openCodeCache = null; +} + +// Enumerate OpenCode models via `opencode models` (ids are `providerID/modelID`, e.g. +// `opencode/claude-opus-4-8`). Metadata (context/tokens/modalities) is left at neutral defaults for +// now — it can be enriched later from a serve's GET /config/providers. +async function listOpenCodeModels(): Promise { + if (openCodeCache) return openCodeCache; + try { + const proc = Bun.spawn([OPENCODE_BIN, 'models'], { stdout: 'pipe', stderr: 'ignore' }); + const out = await new Response(proc.stdout).text(); + await proc.exited; + + const models: ModelInfo[] = out + // eslint-disable-next-line no-control-regex + .replace(/\x1b\[[0-9;]*m/g, '') + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.includes('/')) + .map((full) => { + const slash = full.indexOf('/'); + return { + id: full, + name: full.slice(slash + 1), + provider: full.slice(0, slash), + contextWindow: 200000, + maxTokens: 8192, + reasoning: false, + images: true, + } satisfies ModelInfo; + }); + + openCodeCache = models; + return models; + } catch { + return []; + } } export async function listChatModels(): Promise { - return [...CLAUDE_CODE_MODELS]; + const openCode = await listOpenCodeModels(); + return [...CLAUDE_CODE_MODELS, ...openCode]; } diff --git a/src/workspaces/officerdev/src/apps/Chat/components/ModelSelector.tsx b/src/workspaces/officerdev/src/apps/Chat/components/ModelSelector.tsx index 4cf61882..be5f042d 100644 --- a/src/workspaces/officerdev/src/apps/Chat/components/ModelSelector.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/components/ModelSelector.tsx @@ -19,6 +19,7 @@ const PROVIDER_DISPLAY: Record = { 'google-vertex': 'Google Vertex AI', 'azure-openai': 'Azure OpenAI', 'claude-code': 'Claude Code', + opencode: 'OpenCode Zen', }; type ModelSelectorProps = {