import { spawn } from 'node:child_process'; import Database from 'better-sqlite3'; import crossSpawn from 'cross-spawn'; import type { IProviderModels } from '@/shared/interfaces.js'; import type { ProviderChangeActiveModelInput, ProviderCurrentActiveModel, ProviderModelOption, ProviderModelsDefinition, ProviderSessionActiveModelChange, } from '@/shared/types.js'; import { buildDefaultProviderCurrentActiveModel, getOpenCodeDatabasePath, readObjectRecord, readOptionalString, writeProviderSessionActiveModelChange, } from '@/shared/utils.js'; export const OPENCODE_FALLBACK_MODELS: ProviderModelsDefinition = { OPTIONS: [ { value: 'anthropic/claude-sonnet-4-5', label: 'Claude Sonnet 4.5', description: 'anthropic - anthropic/claude-sonnet-4-5', }, { value: 'anthropic/claude-opus-4-1', label: 'Claude Opus 4.1', description: 'anthropic - anthropic/claude-opus-4-1', }, { value: 'anthropic/claude-haiku-4-5', label: 'Claude Haiku 4.5', description: 'anthropic - anthropic/claude-haiku-4-5', }, { value: 'openai/gpt-5.1', label: 'GPT-5.1', description: 'openai - openai/gpt-5.1', }, { value: 'openai/gpt-5.1-codex', label: 'GPT-5.1 Codex', description: 'openai - openai/gpt-5.1-codex', }, { value: 'openai/gpt-5.4-mini', label: 'GPT-5.4 Mini', description: 'openai - openai/gpt-5.4-mini', }, { value: 'google/gemini-2.5-pro', label: 'Gemini 2.5 Pro', description: 'google - google/gemini-2.5-pro', }, { value: 'google/gemini-2.5-flash', label: 'Gemini 2.5 Flash', description: 'google - google/gemini-2.5-flash', }, ], DEFAULT: 'anthropic/claude-sonnet-4-5', }; const OPEN_CODE_MODELS_TIMEOUT_MS = 20_000; const MODEL_ID_LINE = /^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/i; const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn; const DATE_TOKEN = /^\d{8}$/; const SIMPLE_NUMBER_TOKEN = /^\d$/; const VERSION_TOKEN = /^[a-z]\d+$/i; const NUMERIC_TOKEN = /^\d+(?:\.\d+)*$/; const SHORT_ACRONYM_TOKEN = /^[a-z]{2,3}$/; export const parseOpenCodeModelsStdout = (stdout: string): string[] => { const ids: string[] = []; for (const rawLine of stdout.split(/\r?\n/)) { const line = rawLine.trim(); if (!line || line.startsWith('{') || line.startsWith('[')) { continue; } if (MODEL_ID_LINE.test(line)) { ids.push(line); } } return [...new Set(ids)]; }; const formatDateToken = (token: string): string => ( `${token.slice(0, 4)}-${token.slice(4, 6)}-${token.slice(6, 8)}` ); const formatModelToken = (token: string, nextToken?: string): string => { const lower = token.toLowerCase(); if (VERSION_TOKEN.test(token)) { return token.toUpperCase(); } if (SHORT_ACRONYM_TOKEN.test(lower) && nextToken && NUMERIC_TOKEN.test(nextToken)) { return token.toUpperCase(); } return lower.charAt(0).toUpperCase() + lower.slice(1); }; const formatOpenCodeModelSlug = (slug: string): string => { const labelParts: string[] = []; const dateParts: string[] = []; const tokens = slug.split('-').filter(Boolean); for (let index = 0; index < tokens.length; index += 1) { const token = tokens[index]; const nextToken = tokens[index + 1]; if (DATE_TOKEN.test(token)) { dateParts.push(formatDateToken(token)); continue; } if (SIMPLE_NUMBER_TOKEN.test(token) && nextToken && SIMPLE_NUMBER_TOKEN.test(nextToken)) { labelParts.push(`${token}.${nextToken}`); index += 1; continue; } labelParts.push(formatModelToken(token, nextToken)); } const label = (labelParts.join(' ').trim() || slug).replace(/^GPT\s+/, 'GPT-'); if (dateParts.length === 0) { return label; } return `${label} (${dateParts.join(', ')})`; }; const readOpenCodeModelParts = (id: string): { upstreamProvider: string; slug: string } => { const separatorIndex = id.indexOf('/'); if (separatorIndex < 0) { return { upstreamProvider: '', slug: id, }; } return { upstreamProvider: id.slice(0, separatorIndex), slug: id.slice(separatorIndex + 1), }; }; const labelForOpenCodeModelId = (id: string): string => { const fallbackLabel = OPENCODE_FALLBACK_MODELS.OPTIONS.find((option) => option.value === id)?.label; if (fallbackLabel) { return fallbackLabel; } const { slug } = readOpenCodeModelParts(id); return formatOpenCodeModelSlug(slug); }; const descriptionForOpenCodeModelId = (id: string): string => { const { upstreamProvider } = readOpenCodeModelParts(id); return upstreamProvider ? `${upstreamProvider} - ${id}` : id; }; export const buildOpenCodeDefinitionFromIds = (ids: string[]): ProviderModelsDefinition => { const options: ProviderModelOption[] = ids.map((value) => ({ value, label: labelForOpenCodeModelId(value), description: descriptionForOpenCodeModelId(value), })); const defaultValue = options.find((option) => option.value === OPENCODE_FALLBACK_MODELS.DEFAULT)?.value ?? options[0]?.value ?? OPENCODE_FALLBACK_MODELS.DEFAULT; return { OPTIONS: options, DEFAULT: defaultValue, }; }; const parseOpenCodeSessionModelValue = (rawModel: unknown): string | null => { if (typeof rawModel === 'string') { const trimmed = rawModel.trim(); if (!trimmed) { return null; } try { return parseOpenCodeSessionModelValue(JSON.parse(trimmed)); } catch { return trimmed; } } const record = readObjectRecord(rawModel); if (!record) { return null; } return readOptionalString(record.id) ?? readOptionalString(record.model) ?? readOptionalString(record.name) ?? readOptionalString(record.value) ?? null; }; const runOpenCodeModelsCommand = (): Promise => new Promise((resolve, reject) => { const openCodeProcess = spawnFunction('opencode', ['models'], { cwd: process.cwd(), env: { ...process.env }, }); let stdout = ''; let stderr = ''; let settled = false; const timer = setTimeout(() => { openCodeProcess.kill('SIGTERM'); if (!settled) { settled = true; reject(new Error('opencode models timed out')); } }, OPEN_CODE_MODELS_TIMEOUT_MS); const finish = (error: Error | null, output: string) => { if (settled) { return; } settled = true; clearTimeout(timer); if (error) { reject(error); return; } resolve(output); }; openCodeProcess.stdout?.on('data', (chunk: Buffer) => { stdout += chunk.toString(); }); openCodeProcess.stderr?.on('data', (chunk: Buffer) => { stderr += chunk.toString(); }); openCodeProcess.on('error', (error) => { finish(error instanceof Error ? error : new Error(String(error)), ''); }); openCodeProcess.on('close', (code) => { if (code !== 0) { finish(new Error(stderr.trim() || `opencode models exited with code ${code}`), ''); return; } finish(null, stdout); }); }); export class OpenCodeProviderModels implements IProviderModels { async getSupportedModels(): Promise { try { const stdout = await runOpenCodeModelsCommand(); const ids = parseOpenCodeModelsStdout(stdout); if (ids.length === 0) { return OPENCODE_FALLBACK_MODELS; } return buildOpenCodeDefinitionFromIds(ids); } catch { return OPENCODE_FALLBACK_MODELS; } } async getCurrentActiveModel(sessionId?: string): Promise { if (!sessionId?.trim()) { return buildDefaultProviderCurrentActiveModel(await this.getSupportedModels()); } try { const dbPath = getOpenCodeDatabasePath(); const db = new Database(dbPath, { readonly: true, fileMustExist: true }); try { const row = db.prepare(` SELECT s.id AS sessionId, s.model AS model, s.agent AS agent, s.directory AS directory, s.time_updated AS timeUpdated, s.time_created AS timeCreated FROM session s WHERE s.id = ? ORDER BY COALESCE(s.time_updated, s.time_created, 0) DESC LIMIT 1 `).get(sessionId) as { sessionId?: string; model?: unknown; agent?: string | null; directory?: string | null; timeUpdated?: number | null; timeCreated?: number | null; } | undefined; const model = parseOpenCodeSessionModelValue(row?.model); if (model) { return { model, }; } } finally { db.close(); } } catch { // Fall through to the provider default when OpenCode session lookup fails. } return buildDefaultProviderCurrentActiveModel(await this.getSupportedModels()); } async changeActiveModel( input: ProviderChangeActiveModelInput, ): Promise { return writeProviderSessionActiveModelChange('opencode', input); } }