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 = {