diff --git a/apps/server/src/providers/opencode-provider.ts b/apps/server/src/providers/opencode-provider.ts index ecc7fc85..a5b3bae2 100644 --- a/apps/server/src/providers/opencode-provider.ts +++ b/apps/server/src/providers/opencode-provider.ts @@ -3,7 +3,7 @@ * * Extends CliProvider with OpenCode-specific configuration: * - Event normalization for OpenCode's stream-json format - * - Model definitions for anthropic, openai, and google models + * - Dynamic model discovery via `opencode models` CLI command * - NPX-based Windows execution strategy * - Platform-specific npm global installation paths * @@ -12,7 +12,11 @@ import * as path from 'path'; import * as os from 'os'; +import { execFile } from 'child_process'; +import { promisify } from 'util'; import { CliProvider, type CliSpawnConfig } from './cli-provider.js'; + +const execFileAsync = promisify(execFile); import type { ProviderConfig, ExecuteOptions, @@ -23,6 +27,10 @@ import type { } from '@automaker/types'; import { stripProviderPrefix } from '@automaker/types'; import { type SubprocessOptions, getOpenCodeAuthIndicators } from '@automaker/platform'; +import { createLogger } from '@automaker/utils'; + +// Create logger for OpenCode operations +const opencodeLogger = createLogger('OpencodeProvider'); // ============================================================================= // OpenCode Auth Types @@ -35,36 +43,108 @@ export interface OpenCodeAuthStatus { hasApiKey?: boolean; } +// ============================================================================= +// OpenCode Dynamic Model Types +// ============================================================================= + +/** + * Model information from `opencode models` CLI output + */ +export interface OpenCodeModelInfo { + /** Full model ID (e.g., "copilot/claude-sonnet-4-5") */ + id: string; + /** Provider name (e.g., "copilot", "anthropic", "openai") */ + provider: string; + /** Model name without provider prefix */ + name: string; + /** Display name for UI */ + displayName?: string; +} + +/** + * Provider information from `opencode auth list` CLI output + */ +export interface OpenCodeProviderInfo { + /** Provider ID (e.g., "copilot", "anthropic") */ + id: string; + /** Human-readable name */ + name: string; + /** Whether the provider is authenticated */ + authenticated: boolean; + /** Authentication method if authenticated */ + authMethod?: 'oauth' | 'api_key'; +} + +/** Cache duration for dynamic model fetching (5 minutes) */ +const MODEL_CACHE_DURATION_MS = 5 * 60 * 1000; +const OPENCODE_MODEL_ID_SEPARATOR = '/'; +const OPENCODE_MODEL_ID_PATTERN = /^[a-z0-9.-]+\/\S+$/; +const OPENCODE_PROVIDER_PATTERN = /^[a-z0-9.-]+$/; +const OPENCODE_MODEL_NAME_PATTERN = /^[a-zA-Z0-9._:/-]+$/; + // ============================================================================= // OpenCode Stream Event Types // ============================================================================= +/** + * Part object within OpenCode events + */ +interface OpenCodePart { + id?: string; + sessionID?: string; + messageID?: string; + type: string; + text?: string; + reason?: string; + error?: string; + name?: string; + args?: unknown; + call_id?: string; + output?: string; + tokens?: { + input?: number; + output?: number; + reasoning?: number; + }; +} + /** * Base interface for all OpenCode stream events - * OpenCode uses underscore format: step_start, step_finish, text + * Format: {"type":"event_type","timestamp":...,"sessionID":"...","part":{...}} */ interface OpenCodeBaseEvent { - /** Event type identifier */ + /** Event type identifier (step_start, text, step_finish, tool_call, etc.) */ type: string; - /** Timestamp of the event */ + /** Unix timestamp */ timestamp?: number; - /** Session ID */ + /** Session identifier */ sessionID?: string; - /** Part object containing the actual event data */ - part?: Record; + /** Event details */ + part?: OpenCodePart; } /** * Text event - Text output from the model - * Format: {"type":"text","part":{"text":"content",...}} */ export interface OpenCodeTextEvent extends OpenCodeBaseEvent { type: 'text'; - part: { - type: 'text'; - text: string; - [key: string]: unknown; - }; + part: OpenCodePart & { type: 'text'; text: string }; +} + +/** + * Step start event - Begins an agentic loop iteration + */ +export interface OpenCodeStepStartEvent extends OpenCodeBaseEvent { + type: 'step_start'; + part: OpenCodePart & { type: 'step-start' }; +} + +/** + * Step finish event - Completes an agentic loop iteration + */ +export interface OpenCodeStepFinishEvent extends OpenCodeBaseEvent { + type: 'step_finish'; + part: OpenCodePart & { type: 'step-finish'; reason?: string }; } /** @@ -72,13 +152,7 @@ export interface OpenCodeTextEvent extends OpenCodeBaseEvent { */ export interface OpenCodeToolCallEvent extends OpenCodeBaseEvent { type: 'tool_call'; - part: { - type: 'tool-call'; - name: string; - call_id?: string; - args: unknown; - [key: string]: unknown; - }; + part: OpenCodePart & { type: 'tool-call'; name: string; args?: unknown }; } /** @@ -86,51 +160,37 @@ export interface OpenCodeToolCallEvent extends OpenCodeBaseEvent { */ export interface OpenCodeToolResultEvent extends OpenCodeBaseEvent { type: 'tool_result'; - part: { - type: 'tool-result'; - call_id?: string; - output: string; - [key: string]: unknown; + part: OpenCodePart & { type: 'tool-result'; output: string }; +} + +/** + * Error details object in error events + */ +interface OpenCodeErrorDetails { + name?: string; + message?: string; + data?: { + message?: string; + statusCode?: number; + isRetryable?: boolean; }; } /** - * Tool error event - Tool execution failed + * Error event - An error occurred + */ +export interface OpenCodeErrorEvent extends OpenCodeBaseEvent { + type: 'error'; + part?: OpenCodePart & { error: string }; + error?: string | OpenCodeErrorDetails; +} + +/** + * Tool error event - A tool execution failed */ export interface OpenCodeToolErrorEvent extends OpenCodeBaseEvent { type: 'tool_error'; - part: { - type: 'tool-error'; - call_id?: string; - error: string; - [key: string]: unknown; - }; -} - -/** - * Start step event - Begins an agentic loop iteration - * Format: {"type":"step_start","part":{...}} - */ -export interface OpenCodeStartStepEvent extends OpenCodeBaseEvent { - type: 'step_start'; - part?: { - type: 'step-start'; - [key: string]: unknown; - }; -} - -/** - * Finish step event - Completes an agentic loop iteration - * Format: {"type":"step_finish","part":{"reason":"stop",...}} - */ -export interface OpenCodeFinishStepEvent extends OpenCodeBaseEvent { - type: 'step_finish'; - part?: { - type: 'step-finish'; - reason?: string; - error?: string; - [key: string]: unknown; - }; + part?: OpenCodePart & { error: string }; } /** @@ -138,11 +198,12 @@ export interface OpenCodeFinishStepEvent extends OpenCodeBaseEvent { */ export type OpenCodeStreamEvent = | OpenCodeTextEvent + | OpenCodeStepStartEvent + | OpenCodeStepFinishEvent | OpenCodeToolCallEvent | OpenCodeToolResultEvent - | OpenCodeToolErrorEvent - | OpenCodeStartStepEvent - | OpenCodeFinishStepEvent; + | OpenCodeErrorEvent + | OpenCodeToolErrorEvent; // ============================================================================= // Tool Use ID Generation @@ -175,8 +236,31 @@ export function resetToolUseIdCounter(): void { * * OpenCode is an npm-distributed CLI tool that provides access to * multiple AI model providers through a unified interface. + * + * Supports dynamic model discovery via `opencode models` CLI command, + * enabling access to 75+ providers including GitHub Copilot, Google, + * Anthropic, OpenAI, and more based on user authentication. */ export class OpencodeProvider extends CliProvider { + // ========================================================================== + // Dynamic Model Cache + // ========================================================================== + + /** Cached model definitions */ + private cachedModels: ModelDefinition[] | null = null; + + /** Timestamp when cache expires */ + private modelsCacheExpiry: number = 0; + + /** Cached authenticated providers */ + private cachedProviders: OpenCodeProviderInfo[] | null = null; + + /** Whether model refresh is in progress */ + private isRefreshing: boolean = false; + + /** Promise that resolves when current refresh completes */ + private refreshPromise: Promise | null = null; + constructor(config: ProviderConfig = {}) { super(config); } @@ -227,12 +311,12 @@ export class OpencodeProvider extends CliProvider { * * Arguments built: * - 'run' subcommand for executing queries - * - '--format', 'json' for JSON streaming output + * - '--format', 'json' for JSONL streaming output + * - '-c', '' for working directory (using opencode's -c flag) * - '--model', '' for model selection (if specified) - * - Message passed via stdin (no positional args needed) * - * The prompt is passed via stdin to avoid shell escaping issues. - * OpenCode will read from stdin when no positional message arguments are provided. + * The prompt is passed via stdin (piped) to avoid shell escaping issues. + * OpenCode CLI automatically reads from stdin when input is piped. * * @param options - Execution options containing model, cwd, etc. * @returns Array of CLI arguments for opencode run @@ -240,18 +324,18 @@ export class OpencodeProvider extends CliProvider { buildCliArgs(options: ExecuteOptions): string[] { const args: string[] = ['run']; - // Add JSON output format for streaming + // Add JSON output format for JSONL parsing (not 'stream-json') args.push('--format', 'json'); // Handle model selection - // Strip 'opencode-' prefix if present, OpenCode uses native format + // Strip 'opencode-' prefix if present, OpenCode uses format like 'anthropic/claude-sonnet-4-5' if (options.model) { const model = stripProviderPrefix(options.model); args.push('--model', model); } - // Note: Working directory is set via subprocess cwd option, not CLI args - // Note: Message is passed via stdin, OpenCode reads from stdin automatically + // Note: OpenCode reads from stdin automatically when input is piped + // No '-' argument needed return args; } @@ -313,10 +397,11 @@ export class OpencodeProvider extends CliProvider { * Maps OpenCode event types to the standard ProviderMessage structure: * - text -> type: 'assistant', content with type: 'text' * - step_start -> null (informational, no message needed) - * - step_finish -> type: 'result', subtype: 'success' (or error if failed) + * - step_finish with reason 'stop' -> type: 'result', subtype: 'success' + * - step_finish with error -> type: 'error' * - tool_call -> type: 'assistant', content with type: 'tool_use' * - tool_result -> type: 'assistant', content with type: 'tool_result' - * - tool_error -> type: 'error' + * - error -> type: 'error' * * @param event - Raw event from OpenCode CLI JSONL output * @returns Normalized ProviderMessage or null to skip the event @@ -332,7 +417,7 @@ export class OpencodeProvider extends CliProvider { case 'text': { const textEvent = openCodeEvent as OpenCodeTextEvent; - // Skip if no text content + // Skip empty text if (!textEvent.part?.text) { return null; } @@ -355,59 +440,65 @@ export class OpencodeProvider extends CliProvider { } case 'step_start': { - // Start step is informational - no message needed + // Step start is informational - no message needed return null; } case 'step_finish': { - const finishEvent = openCodeEvent as OpenCodeFinishStepEvent; + const finishEvent = openCodeEvent as OpenCodeStepFinishEvent; - // Check if the step failed (either has error field or reason is 'error') - if (finishEvent.part?.error || finishEvent.part?.reason === 'error') { + // Check if the step failed - either by error property or reason='error' + if (finishEvent.part?.error) { return { type: 'error', session_id: finishEvent.sessionID, - error: finishEvent.part?.error || 'Step execution failed', + error: finishEvent.part.error, }; } - // Successful completion - const result: { type: 'result'; subtype: 'success'; session_id?: string; result?: string } = - { - type: 'result', - subtype: 'success', + // Check if reason indicates error (even without explicit error text) + if (finishEvent.part?.reason === 'error') { + return { + type: 'error', + session_id: finishEvent.sessionID, + error: 'Step execution failed', }; - - if (finishEvent.sessionID) { - result.session_id = finishEvent.sessionID; } - // Safely handle arbitrary result payloads from CLI: ensure we assign a string. - const rawResult = - (finishEvent.part && (finishEvent.part as Record).result) ?? undefined; - if (rawResult !== undefined) { - result.result = typeof rawResult === 'string' ? rawResult : JSON.stringify(rawResult); - } + // Successful completion (reason: 'stop' or 'end_turn') + return { + type: 'result', + subtype: 'success', + session_id: finishEvent.sessionID, + result: (finishEvent.part as OpenCodePart & { result?: string })?.result, + }; + } - return result; + case 'tool_error': { + const toolErrorEvent = openCodeEvent as OpenCodeBaseEvent; + + // Extract error message from part.error + const errorMessage = toolErrorEvent.part?.error || 'Tool execution failed'; + + return { + type: 'error', + session_id: toolErrorEvent.sessionID, + error: errorMessage, + }; } case 'tool_call': { const toolEvent = openCodeEvent as OpenCodeToolCallEvent; - if (!toolEvent.part) { - return null; - } - // Generate a tool use ID if not provided - const toolUseId = toolEvent.part.call_id || generateToolUseId(); + const toolUseId = toolEvent.part?.call_id || generateToolUseId(); const content: ContentBlock[] = [ { type: 'tool_use', - name: toolEvent.part.name, + name: toolEvent.part?.name || 'unknown', tool_use_id: toolUseId, - input: toolEvent.part.args, + input: toolEvent.part?.args, }, ]; @@ -424,15 +515,11 @@ export class OpencodeProvider extends CliProvider { case 'tool_result': { const resultEvent = openCodeEvent as OpenCodeToolResultEvent; - if (!resultEvent.part) { - return null; - } - const content: ContentBlock[] = [ { type: 'tool_result', - tool_use_id: resultEvent.part.call_id, - content: resultEvent.part.output, + tool_use_id: resultEvent.part?.call_id, + content: resultEvent.part?.output || '', }, ]; @@ -446,13 +533,30 @@ export class OpencodeProvider extends CliProvider { }; } - case 'tool_error': { - const errorEvent = openCodeEvent as OpenCodeToolErrorEvent; + case 'error': { + const errorEvent = openCodeEvent as OpenCodeErrorEvent; + + // Extract error message from various formats + let errorMessage = 'Unknown error'; + if (errorEvent.error) { + if (typeof errorEvent.error === 'string') { + errorMessage = errorEvent.error; + } else { + // Error is an object with name/data structure + errorMessage = + errorEvent.error.data?.message || + errorEvent.error.message || + errorEvent.error.name || + 'Unknown error'; + } + } else if (errorEvent.part?.error) { + errorMessage = errorEvent.part.error; + } return { type: 'error', session_id: errorEvent.sessionID, - error: errorEvent.part?.error || 'Tool execution failed', + error: errorMessage, }; } @@ -470,12 +574,34 @@ export class OpencodeProvider extends CliProvider { /** * Get available models for OpenCode * - * Returns model definitions for supported AI providers: - * - Anthropic Claude models (Sonnet, Opus, Haiku) - * - OpenAI GPT-4o - * - Google Gemini 2.5 Pro + * Returns cached models if available and not expired. + * Falls back to default models if cache is empty or CLI is unavailable. + * + * Use `refreshModels()` to force a fresh fetch from the CLI. */ getAvailableModels(): ModelDefinition[] { + // Return cached models if available and not expired + if (this.cachedModels && Date.now() < this.modelsCacheExpiry) { + return this.cachedModels; + } + + // Return cached models even if expired (better than nothing) + if (this.cachedModels) { + // Trigger background refresh + this.refreshModels().catch((err) => { + opencodeLogger.debug(`Background model refresh failed: ${err}`); + }); + return this.cachedModels; + } + + // Return default models while cache is empty + return this.getDefaultModels(); + } + + /** + * Get default hardcoded models (fallback when CLI is unavailable) + */ + private getDefaultModels(): ModelDefinition[] { return [ // OpenCode Free Tier Models { @@ -487,6 +613,17 @@ export class OpencodeProvider extends CliProvider { supportsTools: true, supportsVision: false, tier: 'basic', + default: true, + }, + { + id: 'opencode/glm-4.7-free', + name: 'GLM 4.7 Free', + modelString: 'opencode/glm-4.7-free', + provider: 'opencode', + description: 'OpenCode free tier GLM model', + supportsTools: true, + supportsVision: false, + tier: 'basic', }, { id: 'opencode/gpt-5-nano', @@ -508,85 +645,466 @@ export class OpencodeProvider extends CliProvider { supportsVision: false, tier: 'basic', }, - // Amazon Bedrock - Claude Models { - id: 'amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0', - name: 'Claude Sonnet 4.5 (Bedrock)', - modelString: 'amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0', + id: 'opencode/minimax-m2.1-free', + name: 'MiniMax M2.1 Free', + modelString: 'opencode/minimax-m2.1-free', provider: 'opencode', - description: 'Latest Claude Sonnet via AWS Bedrock - fast and intelligent', - supportsTools: true, - supportsVision: true, - tier: 'premium', - default: true, - }, - { - id: 'amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0', - name: 'Claude Opus 4.5 (Bedrock)', - modelString: 'amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0', - provider: 'opencode', - description: 'Most capable Claude model via AWS Bedrock', - supportsTools: true, - supportsVision: true, - tier: 'premium', - }, - { - id: 'amazon-bedrock/anthropic.claude-haiku-4-5-20251001-v1:0', - name: 'Claude Haiku 4.5 (Bedrock)', - modelString: 'amazon-bedrock/anthropic.claude-haiku-4-5-20251001-v1:0', - provider: 'opencode', - description: 'Fastest Claude model via AWS Bedrock', - supportsTools: true, - supportsVision: true, - tier: 'standard', - }, - // Amazon Bedrock - DeepSeek Models - { - id: 'amazon-bedrock/deepseek.r1-v1:0', - name: 'DeepSeek R1 (Bedrock)', - modelString: 'amazon-bedrock/deepseek.r1-v1:0', - provider: 'opencode', - description: 'DeepSeek R1 reasoning model - excellent for coding', + description: 'OpenCode free tier MiniMax model', supportsTools: true, supportsVision: false, - tier: 'premium', - }, - // Amazon Bedrock - Amazon Nova Models - { - id: 'amazon-bedrock/amazon.nova-pro-v1:0', - name: 'Amazon Nova Pro (Bedrock)', - modelString: 'amazon-bedrock/amazon.nova-pro-v1:0', - provider: 'opencode', - description: 'Amazon Nova Pro - balanced performance', - supportsTools: true, - supportsVision: true, - tier: 'standard', - }, - // Amazon Bedrock - Meta Llama Models - { - id: 'amazon-bedrock/meta.llama4-maverick-17b-instruct-v1:0', - name: 'Llama 4 Maverick 17B (Bedrock)', - modelString: 'amazon-bedrock/meta.llama4-maverick-17b-instruct-v1:0', - provider: 'opencode', - description: 'Meta Llama 4 Maverick via AWS Bedrock', - supportsTools: true, - supportsVision: false, - tier: 'standard', - }, - // Amazon Bedrock - Qwen Models - { - id: 'amazon-bedrock/qwen.qwen3-coder-480b-a35b-v1:0', - name: 'Qwen3 Coder 480B (Bedrock)', - modelString: 'amazon-bedrock/qwen.qwen3-coder-480b-a35b-v1:0', - provider: 'opencode', - description: 'Qwen3 Coder 480B - excellent for coding', - supportsTools: true, - supportsVision: false, - tier: 'premium', + tier: 'basic', }, ]; } + // ========================================================================== + // Dynamic Model Discovery + // ========================================================================== + + /** + * Refresh models from OpenCode CLI + * + * Fetches available models using `opencode models` command and updates cache. + * Returns the updated model definitions. + */ + async refreshModels(): Promise { + // If refresh is in progress, wait for existing promise instead of busy-waiting + if (this.isRefreshing && this.refreshPromise) { + opencodeLogger.debug('Model refresh already in progress, waiting for completion...'); + return this.refreshPromise; + } + + this.isRefreshing = true; + opencodeLogger.debug('Starting model refresh from OpenCode CLI'); + + this.refreshPromise = this.doRefreshModels(); + try { + return await this.refreshPromise; + } finally { + this.refreshPromise = null; + this.isRefreshing = false; + } + } + + /** + * Internal method that performs the actual model refresh + */ + private async doRefreshModels(): Promise { + try { + const models = await this.fetchModelsFromCli(); + + if (models.length > 0) { + this.cachedModels = models; + this.modelsCacheExpiry = Date.now() + MODEL_CACHE_DURATION_MS; + opencodeLogger.debug(`Cached ${models.length} models from OpenCode CLI`); + } else { + // Keep existing cache if fetch returned nothing + opencodeLogger.debug('No models returned from CLI, keeping existing cache'); + } + + return this.cachedModels || this.getDefaultModels(); + } catch (error) { + opencodeLogger.debug(`Model refresh failed: ${error}`); + // Return existing cache or defaults on error + return this.cachedModels || this.getDefaultModels(); + } + } + + /** + * Fetch models from OpenCode CLI using `opencode models` command + * + * Uses async execFile to avoid blocking the event loop. + */ + private async fetchModelsFromCli(): Promise { + this.ensureCliDetected(); + + if (!this.cliPath) { + opencodeLogger.debug('OpenCode CLI not available for model fetch'); + return []; + } + + try { + let command: string; + let args: string[]; + + if (this.detectedStrategy === 'npx') { + // NPX strategy: execute npx with opencode-ai package + command = 'npx'; + args = ['opencode-ai@latest', 'models']; + opencodeLogger.debug(`Executing: ${command} ${args.join(' ')}`); + } else if (this.useWsl && this.wslCliPath) { + // WSL strategy: execute via wsl.exe + command = 'wsl.exe'; + args = this.wslDistribution + ? ['-d', this.wslDistribution, this.wslCliPath, 'models'] + : [this.wslCliPath, 'models']; + opencodeLogger.debug(`Executing: ${command} ${args.join(' ')}`); + } else { + // Direct CLI execution + command = this.cliPath; + args = ['models']; + opencodeLogger.debug(`Executing: ${command} ${args.join(' ')}`); + } + + const { stdout } = await execFileAsync(command, args, { + encoding: 'utf-8', + timeout: 30000, + windowsHide: true, + }); + + opencodeLogger.debug( + `Models output (${stdout.length} chars): ${stdout.substring(0, 200)}...` + ); + return this.parseModelsOutput(stdout); + } catch (error) { + opencodeLogger.error(`Failed to fetch models from CLI: ${error}`); + return []; + } + } + + /** + * Parse the output of `opencode models` command + * + * OpenCode CLI output format (one model per line): + * opencode/big-pickle + * opencode/glm-4.7-free + * anthropic/claude-3-5-haiku-20241022 + * github-copilot/claude-3.5-sonnet + * ... + */ + private parseModelsOutput(output: string): ModelDefinition[] { + // Parse line-based format (one model ID per line) + const lines = output.split('\n'); + const models: ModelDefinition[] = []; + + // Regex to validate "provider/model-name" format + // Provider: lowercase letters, numbers, dots, hyphens + // Model name: non-whitespace (supports nested paths like openrouter/anthropic/claude) + const modelIdRegex = OPENCODE_MODEL_ID_PATTERN; + + for (const line of lines) { + // Remove ANSI escape codes if any + const cleanLine = line.replace(/\x1b\[[0-9;]*m/g, '').trim(); + + // Skip empty lines + if (!cleanLine) continue; + + // Validate format using regex for robustness + if (modelIdRegex.test(cleanLine)) { + const separatorIndex = cleanLine.indexOf(OPENCODE_MODEL_ID_SEPARATOR); + if (separatorIndex <= 0 || separatorIndex === cleanLine.length - 1) { + continue; + } + + const provider = cleanLine.slice(0, separatorIndex); + const name = cleanLine.slice(separatorIndex + 1); + + if (!OPENCODE_PROVIDER_PATTERN.test(provider) || !OPENCODE_MODEL_NAME_PATTERN.test(name)) { + continue; + } + + models.push( + this.modelInfoToDefinition({ + id: cleanLine, + provider, + name, + }) + ); + } + } + + opencodeLogger.debug(`Parsed ${models.length} models from CLI output`); + return models; + } + + /** + * Convert OpenCodeModelInfo to ModelDefinition + */ + private modelInfoToDefinition(model: OpenCodeModelInfo): ModelDefinition { + const displayName = model.displayName || this.formatModelDisplayName(model); + const tier = this.inferModelTier(model.id); + + return { + id: model.id, + name: displayName, + modelString: model.id, + provider: model.provider, // Use the actual provider (github-copilot, google, etc.) + description: `${model.name} via ${this.formatProviderName(model.provider)}`, + supportsTools: true, + supportsVision: this.modelSupportsVision(model.id), + tier, + // Mark Claude Sonnet as default if available + default: model.id.includes('claude-sonnet-4'), + }; + } + + /** + * Format provider name for display + */ + private formatProviderName(provider: string): string { + const providerNames: Record = { + 'github-copilot': 'GitHub Copilot', + google: 'Google AI', + openai: 'OpenAI', + anthropic: 'Anthropic', + openrouter: 'OpenRouter', + opencode: 'OpenCode', + ollama: 'Ollama', + lmstudio: 'LM Studio', + azure: 'Azure OpenAI', + xai: 'xAI', + deepseek: 'DeepSeek', + }; + return ( + providerNames[provider] || + provider.charAt(0).toUpperCase() + provider.slice(1).replace(/-/g, ' ') + ); + } + + /** + * Format a display name for a model + */ + private formatModelDisplayName(model: OpenCodeModelInfo): string { + // Capitalize and format the model name + const formattedName = model.name + .split('-') + .map((part) => { + // Handle version numbers like "4-5" -> "4.5" + if (/^\d+$/.test(part)) { + return part; + } + return part.charAt(0).toUpperCase() + part.slice(1); + }) + .join(' ') + .replace(/(\d)\s+(\d)/g, '$1.$2'); // "4 5" -> "4.5" + + // Format provider name + const providerNames: Record = { + copilot: 'GitHub Copilot', + anthropic: 'Anthropic', + openai: 'OpenAI', + google: 'Google', + 'amazon-bedrock': 'AWS Bedrock', + bedrock: 'AWS Bedrock', + openrouter: 'OpenRouter', + opencode: 'OpenCode', + azure: 'Azure', + ollama: 'Ollama', + lmstudio: 'LM Studio', + }; + + const providerDisplay = providerNames[model.provider] || model.provider; + return `${formattedName} (${providerDisplay})`; + } + + /** + * Infer model tier based on model ID + */ + private inferModelTier(modelId: string): 'basic' | 'standard' | 'premium' { + const lowerModelId = modelId.toLowerCase(); + + // Premium tier: flagship models + if ( + lowerModelId.includes('opus') || + lowerModelId.includes('gpt-5') || + lowerModelId.includes('o3') || + lowerModelId.includes('o4') || + lowerModelId.includes('gemini-2') || + lowerModelId.includes('deepseek-r1') + ) { + return 'premium'; + } + + // Basic tier: free or lightweight models + if ( + lowerModelId.includes('free') || + lowerModelId.includes('nano') || + lowerModelId.includes('mini') || + lowerModelId.includes('haiku') || + lowerModelId.includes('flash') + ) { + return 'basic'; + } + + // Standard tier: everything else + return 'standard'; + } + + /** + * Check if a model supports vision based on model ID + */ + private modelSupportsVision(modelId: string): boolean { + const lowerModelId = modelId.toLowerCase(); + + // Models known to support vision + const visionModels = ['claude', 'gpt-4', 'gpt-5', 'gemini', 'nova', 'llama-3', 'llama-4']; + + return visionModels.some((vm) => lowerModelId.includes(vm)); + } + + /** + * Fetch authenticated providers from OpenCode CLI + * + * Runs `opencode auth list` to get the list of authenticated providers. + * Uses async execFile to avoid blocking the event loop. + */ + async fetchAuthenticatedProviders(): Promise { + this.ensureCliDetected(); + + if (!this.cliPath) { + opencodeLogger.debug('OpenCode CLI not available for provider fetch'); + return []; + } + + try { + let command: string; + let args: string[]; + + if (this.detectedStrategy === 'npx') { + // NPX strategy + command = 'npx'; + args = ['opencode-ai@latest', 'auth', 'list']; + opencodeLogger.debug(`Executing: ${command} ${args.join(' ')}`); + } else if (this.useWsl && this.wslCliPath) { + // WSL strategy + command = 'wsl.exe'; + args = this.wslDistribution + ? ['-d', this.wslDistribution, this.wslCliPath, 'auth', 'list'] + : [this.wslCliPath, 'auth', 'list']; + opencodeLogger.debug(`Executing: ${command} ${args.join(' ')}`); + } else { + // Direct CLI execution + command = this.cliPath; + args = ['auth', 'list']; + opencodeLogger.debug(`Executing: ${command} ${args.join(' ')}`); + } + + const { stdout } = await execFileAsync(command, args, { + encoding: 'utf-8', + timeout: 15000, + windowsHide: true, + }); + + opencodeLogger.debug( + `Auth list output (${stdout.length} chars): ${stdout.substring(0, 200)}...` + ); + const providers = this.parseProvidersOutput(stdout); + this.cachedProviders = providers; + return providers; + } catch (error) { + opencodeLogger.error(`Failed to fetch providers from CLI: ${error}`); + return this.cachedProviders || []; + } + } + + /** + * Parse the output of `opencode auth list` command + * + * OpenCode CLI output format: + * ┌ Credentials ~/.local/share/opencode/auth.json + * │ + * ● Anthropic oauth + * │ + * ● GitHub Copilot oauth + * │ + * └ 4 credentials + * + * Each line with ● contains: provider name and auth method (oauth/api) + */ + private parseProvidersOutput(output: string): OpenCodeProviderInfo[] { + const lines = output.split('\n'); + const providers: OpenCodeProviderInfo[] = []; + + // Provider name to ID mapping + const providerIdMap: Record = { + anthropic: 'anthropic', + 'github copilot': 'github-copilot', + copilot: 'github-copilot', + google: 'google', + openai: 'openai', + openrouter: 'openrouter', + azure: 'azure', + bedrock: 'amazon-bedrock', + 'amazon bedrock': 'amazon-bedrock', + ollama: 'ollama', + 'lm studio': 'lmstudio', + lmstudio: 'lmstudio', + opencode: 'opencode', + 'z.ai coding plan': 'z-ai', + 'z.ai': 'z-ai', + }; + + for (const line of lines) { + // Look for lines with ● which indicate authenticated providers + // Format: "● Provider Name auth_method" + if (line.includes('●')) { + // Remove ANSI escape codes and the ● symbol + const cleanLine = line + .replace(/\x1b\[[0-9;]*m/g, '') // Remove ANSI codes + .replace(/●/g, '') // Remove ● symbol + .trim(); + + if (!cleanLine) continue; + + // Parse "Provider Name auth_method" format + // Auth method is the last word (oauth, api, etc.) + const parts = cleanLine.split(/\s+/); + if (parts.length >= 2) { + const authMethod = parts[parts.length - 1].toLowerCase(); + const providerName = parts.slice(0, -1).join(' '); + + // Determine auth method type + let authMethodType: 'oauth' | 'api_key' | undefined; + if (authMethod === 'oauth') { + authMethodType = 'oauth'; + } else if (authMethod === 'api' || authMethod === 'api_key') { + authMethodType = 'api_key'; + } + + // Get provider ID from name + const providerNameLower = providerName.toLowerCase(); + const providerId = + providerIdMap[providerNameLower] || providerNameLower.replace(/\s+/g, '-'); + + providers.push({ + id: providerId, + name: providerName, + authenticated: true, // If it's listed with ●, it's authenticated + authMethod: authMethodType, + }); + } + } + } + + opencodeLogger.debug(`Parsed ${providers.length} providers from auth list`); + return providers; + } + + /** + * Get cached authenticated providers + */ + getCachedProviders(): OpenCodeProviderInfo[] | null { + return this.cachedProviders; + } + + /** + * Clear the model cache, forcing a refresh on next access + */ + clearModelCache(): void { + this.cachedModels = null; + this.modelsCacheExpiry = 0; + this.cachedProviders = null; + opencodeLogger.debug('Model cache cleared'); + } + + /** + * Check if we have cached models (not just defaults) + */ + hasCachedModels(): boolean { + return this.cachedModels !== null && this.cachedModels.length > 0; + } + // ========================================================================== // Feature Support // ========================================================================== diff --git a/apps/server/src/routes/enhance-prompt/routes/enhance.ts b/apps/server/src/routes/enhance-prompt/routes/enhance.ts index 2331fdd4..6c2a1a43 100644 --- a/apps/server/src/routes/enhance-prompt/routes/enhance.ts +++ b/apps/server/src/routes/enhance-prompt/routes/enhance.ts @@ -12,6 +12,7 @@ import { resolveModelString } from '@automaker/model-resolver'; import { CLAUDE_MODEL_MAP, isCursorModel, + isOpencodeModel, stripProviderPrefix, ThinkingLevel, getThinkingTokenBudget, @@ -91,13 +92,13 @@ async function extractTextFromStream( } /** - * Execute enhancement using Cursor provider + * Execute enhancement using a provider (Cursor, OpenCode, etc.) * * @param prompt - The enhancement prompt - * @param model - The Cursor model to use + * @param model - The model to use * @returns The enhanced text */ -async function executeWithCursor(prompt: string, model: string): Promise { +async function executeWithProvider(prompt: string, model: string): Promise { const provider = ProviderFactory.getProviderForModel(model); // Strip provider prefix - providers expect bare model IDs const bareModel = stripProviderPrefix(model); @@ -212,7 +213,14 @@ export function createEnhanceHandler( // Cursor doesn't have a separate system prompt concept, so combine them const combinedPrompt = `${systemPrompt}\n\n${userPrompt}`; - enhancedText = await executeWithCursor(combinedPrompt, resolvedModel); + enhancedText = await executeWithProvider(combinedPrompt, resolvedModel); + } else if (isOpencodeModel(resolvedModel)) { + // Use OpenCode provider for OpenCode models (static and dynamic) + logger.info(`Using OpenCode provider for model: ${resolvedModel}`); + + // OpenCode CLI handles the system prompt, so combine them + const combinedPrompt = `${systemPrompt}\n\n${userPrompt}`; + enhancedText = await executeWithProvider(combinedPrompt, resolvedModel); } else { // Use Claude SDK for Claude models logger.info(`Using Claude provider for model: ${resolvedModel}`); diff --git a/apps/server/src/routes/setup/index.ts b/apps/server/src/routes/setup/index.ts index fe38a14e..a35c5e6b 100644 --- a/apps/server/src/routes/setup/index.ts +++ b/apps/server/src/routes/setup/index.ts @@ -24,6 +24,12 @@ import { createDeauthCursorHandler } from './routes/deauth-cursor.js'; import { createAuthOpencodeHandler } from './routes/auth-opencode.js'; import { createDeauthOpencodeHandler } from './routes/deauth-opencode.js'; import { createOpencodeStatusHandler } from './routes/opencode-status.js'; +import { + createGetOpencodeModelsHandler, + createRefreshOpencodeModelsHandler, + createGetOpencodeProvidersHandler, + createClearOpencodeCacheHandler, +} from './routes/opencode-models.js'; import { createGetCursorConfigHandler, createSetCursorDefaultModelHandler, @@ -65,6 +71,12 @@ export function createSetupRoutes(): Router { router.get('/opencode-status', createOpencodeStatusHandler()); router.post('/auth-opencode', createAuthOpencodeHandler()); router.post('/deauth-opencode', createDeauthOpencodeHandler()); + + // OpenCode Dynamic Model Discovery routes + router.get('/opencode/models', createGetOpencodeModelsHandler()); + router.post('/opencode/models/refresh', createRefreshOpencodeModelsHandler()); + router.get('/opencode/providers', createGetOpencodeProvidersHandler()); + router.post('/opencode/cache/clear', createClearOpencodeCacheHandler()); router.get('/cursor-config', createGetCursorConfigHandler()); router.post('/cursor-config/default-model', createSetCursorDefaultModelHandler()); router.post('/cursor-config/models', createSetCursorModelsHandler()); diff --git a/apps/server/src/routes/setup/routes/opencode-models.ts b/apps/server/src/routes/setup/routes/opencode-models.ts new file mode 100644 index 00000000..0091b47c --- /dev/null +++ b/apps/server/src/routes/setup/routes/opencode-models.ts @@ -0,0 +1,191 @@ +/** + * OpenCode Dynamic Models API Routes + * + * Provides endpoints for: + * - GET /api/setup/opencode/models - Get available models (cached or refreshed) + * - POST /api/setup/opencode/models/refresh - Force refresh models from CLI + * - GET /api/setup/opencode/providers - Get authenticated providers + */ + +import type { Request, Response } from 'express'; +import { + OpencodeProvider, + type OpenCodeProviderInfo, +} from '../../../providers/opencode-provider.js'; +import { getErrorMessage, logError } from '../common.js'; +import type { ModelDefinition } from '@automaker/types'; +import { createLogger } from '@automaker/utils'; + +const logger = createLogger('OpenCodeModelsRoute'); + +// Singleton provider instance for caching +let providerInstance: OpencodeProvider | null = null; + +function getProvider(): OpencodeProvider { + if (!providerInstance) { + providerInstance = new OpencodeProvider(); + } + return providerInstance; +} + +/** + * Response type for models endpoint + */ +interface ModelsResponse { + success: boolean; + models?: ModelDefinition[]; + count?: number; + cached?: boolean; + error?: string; +} + +/** + * Response type for providers endpoint + */ +interface ProvidersResponse { + success: boolean; + providers?: OpenCodeProviderInfo[]; + authenticated?: OpenCodeProviderInfo[]; + error?: string; +} + +/** + * Creates handler for GET /api/setup/opencode/models + * + * Returns currently available models (from cache if available). + * Query params: + * - refresh=true: Force refresh from CLI before returning + * + * Note: If cache is empty, this will trigger a refresh to get dynamic models. + */ +export function createGetOpencodeModelsHandler() { + return async (req: Request, res: Response): Promise => { + try { + const provider = getProvider(); + const forceRefresh = req.query.refresh === 'true'; + + let models: ModelDefinition[]; + let cached = true; + + if (forceRefresh) { + models = await provider.refreshModels(); + cached = false; + } else { + // Check if we have cached models + const cachedModels = provider.getAvailableModels(); + + // If cache only has default models (provider.hasCachedModels() would be false), + // trigger a refresh to get dynamic models + if (!provider.hasCachedModels()) { + models = await provider.refreshModels(); + cached = false; + } else { + models = cachedModels; + } + } + + const response: ModelsResponse = { + success: true, + models, + count: models.length, + cached, + }; + + res.json(response); + } catch (error) { + logError(error, 'Get OpenCode models failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + } as ModelsResponse); + } + }; +} + +/** + * Creates handler for POST /api/setup/opencode/models/refresh + * + * Forces a refresh of models from the OpenCode CLI. + */ +export function createRefreshOpencodeModelsHandler() { + return async (_req: Request, res: Response): Promise => { + try { + const provider = getProvider(); + const models = await provider.refreshModels(); + + const response: ModelsResponse = { + success: true, + models, + count: models.length, + cached: false, + }; + + res.json(response); + } catch (error) { + logger.error('Refresh OpenCode models failed:', error); + logError(error, 'Refresh OpenCode models failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + } as ModelsResponse); + } + }; +} + +/** + * Creates handler for GET /api/setup/opencode/providers + * + * Returns authenticated providers from OpenCode CLI. + * This calls `opencode auth list` to get provider status. + */ +export function createGetOpencodeProvidersHandler() { + return async (_req: Request, res: Response): Promise => { + try { + const provider = getProvider(); + const providers = await provider.fetchAuthenticatedProviders(); + + // Filter to only authenticated providers + const authenticated = providers.filter((p) => p.authenticated); + + const response: ProvidersResponse = { + success: true, + providers, + authenticated, + }; + + res.json(response); + } catch (error) { + logger.error('Get OpenCode providers failed:', error); + logError(error, 'Get OpenCode providers failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + } as ProvidersResponse); + } + }; +} + +/** + * Creates handler for POST /api/setup/opencode/cache/clear + * + * Clears the model cache, forcing a fresh fetch on next access. + */ +export function createClearOpencodeCacheHandler() { + return async (_req: Request, res: Response): Promise => { + try { + const provider = getProvider(); + provider.clearModelCache(); + + res.json({ + success: true, + message: 'OpenCode model cache cleared', + }); + } catch (error) { + logError(error, 'Clear OpenCode cache failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + }); + } + }; +} diff --git a/apps/ui/src/components/views/settings-view/cli-status/opencode-cli-status.tsx b/apps/ui/src/components/views/settings-view/cli-status/opencode-cli-status.tsx index 961ff866..bfd9efe6 100644 --- a/apps/ui/src/components/views/settings-view/cli-status/opencode-cli-status.tsx +++ b/apps/ui/src/components/views/settings-view/cli-status/opencode-cli-status.tsx @@ -1,9 +1,40 @@ import { Button } from '@/components/ui/button'; -import { CheckCircle2, AlertCircle, RefreshCw, XCircle } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { CheckCircle2, AlertCircle, RefreshCw, Bot, Cloud } from 'lucide-react'; import { cn } from '@/lib/utils'; import type { CliStatus } from '../shared/types'; import { OpenCodeIcon } from '@/components/ui/provider-icon'; +export interface OpenCodeProviderInfo { + id: string; + name: string; + authenticated: boolean; + authMethod?: 'oauth' | 'api_key'; +} + +/** + * Provider display configuration + */ +const PROVIDER_DISPLAY: Record = { + copilot: 'GitHub Copilot', + 'github-copilot': 'GitHub Copilot', + anthropic: 'Anthropic', + openai: 'OpenAI', + openrouter: 'OpenRouter', + google: 'Google AI', + 'amazon-bedrock': 'AWS Bedrock', + azure: 'Azure OpenAI', + ollama: 'Ollama', + lmstudio: 'LM Studio', + opencode: 'OpenCode', + xai: 'xAI', + deepseek: 'DeepSeek', +}; + +function getProviderDisplayName(provider: OpenCodeProviderInfo): string { + return PROVIDER_DISPLAY[provider.id] || provider.name || provider.id; +} + export type OpencodeAuthMethod = | 'api_key_env' // ANTHROPIC_API_KEY or other provider env vars | 'api_key' // Manually stored API key @@ -38,6 +69,7 @@ function getAuthMethodLabel(method: OpencodeAuthMethod): string { interface OpencodeCliStatusProps { status: CliStatus | null; authStatus?: OpencodeAuthStatus | null; + providers?: OpenCodeProviderInfo[]; isChecking: boolean; onRefresh: () => void; } @@ -152,9 +184,11 @@ export function OpencodeModelConfigSkeleton() { export function OpencodeCliStatus({ status, authStatus, + providers = [], isChecking, onRefresh, }: OpencodeCliStatusProps) { + const authenticatedProviders = providers.filter((p) => p.authenticated); if (!status) return ; return ( @@ -223,8 +257,8 @@ export function OpencodeCliStatus({ - {/* Authentication Status */} - {authStatus?.authenticated ? ( + {/* Authentication Status - consider both direct auth and provider auth */} + {authStatus?.authenticated || authenticatedProviders.length > 0 ? (
@@ -232,24 +266,71 @@ export function OpencodeCliStatus({

Authenticated

-

- Method:{' '} - {getAuthMethodLabel(authStatus.method)} -

+ {authStatus?.authenticated && authStatus.method !== 'none' ? ( +

+ Method:{' '} + {getAuthMethodLabel(authStatus.method)} +

+ ) : authenticatedProviders.length > 0 ? ( +

+ Via {authenticatedProviders.length} connected provider + {authenticatedProviders.length !== 1 ? 's' : ''} +

+ ) : null}
) : ( -
-
- +
+
+
-

Not Authenticated

-

- Run{' '} - opencode auth or - set an API key to authenticate. +

OpenCode Free Tier Ready

+

+ Free OpenCode models work without login. Run{' '} + + opencode auth login + {' '} + to connect providers like GitHub Copilot, Google AI, etc. +

+
+
+ )} + + {/* Dynamic Providers Connected */} + {authenticatedProviders.length > 0 && ( +
+
+ +
+
+

+ {authenticatedProviders.length} Dynamic Provider + {authenticatedProviders.length !== 1 ? 's' : ''} Connected +

+
+ {authenticatedProviders.map((provider) => ( + + {getProviderDisplayName(provider)} + {provider.authMethod && ( + + ({provider.authMethod === 'oauth' ? 'OAuth' : 'Key'}) + + )} + + ))} +
+

+ Use{' '} + + opencode auth login + {' '} + to add more providers.

diff --git a/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx b/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx index e8a93480..db5a4d2f 100644 --- a/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx +++ b/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { Fragment, useEffect, useMemo, useRef, useState } from 'react'; import { cn } from '@/lib/utils'; import { useAppStore } from '@/store/app-store'; import type { @@ -25,18 +25,13 @@ import { THINKING_LEVEL_LABELS, REASONING_EFFORT_LEVELS, REASONING_EFFORT_LABELS, + type ModelOption, } from '@/components/views/board-view/shared/model-constants'; import { Check, ChevronsUpDown, Star, ChevronRight } from 'lucide-react'; import { AnthropicIcon, CursorIcon, OpenAIIcon, - OpenCodeIcon, - DeepSeekIcon, - NovaIcon, - QwenIcon, - MistralIcon, - MetaIcon, getProviderIconForModel, } from '@/components/ui/provider-icon'; import { Button } from '@/components/ui/button'; @@ -51,6 +46,80 @@ import { } from '@/components/ui/command'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +const OPENCODE_CLI_GROUP_LABEL = 'OpenCode CLI'; +const OPENCODE_PROVIDER_FALLBACK = 'opencode'; +const OPENCODE_PROVIDER_WORD_SEPARATOR = '-'; +const OPENCODE_MODEL_ID_SEPARATOR = '/'; +const OPENCODE_SECTION_GROUP_PADDING = 'pt-2'; + +const OPENCODE_STATIC_PROVIDER_LABELS: Record = { + [OPENCODE_PROVIDER_FALLBACK]: 'OpenCode (Free)', +}; + +const OPENCODE_DYNAMIC_PROVIDER_LABELS: Record = { + 'github-copilot': 'GitHub Copilot', + 'zai-coding-plan': 'Z.AI Coding Plan', + google: 'Google AI', + openai: 'OpenAI', + openrouter: 'OpenRouter', + anthropic: 'Anthropic', + xai: 'xAI', + deepseek: 'DeepSeek', + ollama: 'Ollama (Local)', + lmstudio: 'LM Studio (Local)', + azure: 'Azure OpenAI', + [OPENCODE_PROVIDER_FALLBACK]: 'OpenCode (Free)', +}; + +const OPENCODE_DYNAMIC_PROVIDER_ORDER = [ + 'github-copilot', + 'google', + 'openai', + 'openrouter', + 'anthropic', + 'xai', + 'deepseek', + 'ollama', + 'lmstudio', + 'azure', + 'zai-coding-plan', +]; + +const OPENCODE_SECTION_ORDER = ['free', 'dynamic'] as const; + +const OPENCODE_SECTION_LABELS: Record<(typeof OPENCODE_SECTION_ORDER)[number], string> = { + free: 'Free Tier', + dynamic: 'Connected Providers', +}; + +const OPENCODE_STATIC_PROVIDER_BY_ID = new Map( + OPENCODE_MODELS.map((model) => [model.id, model.provider]) +); + +function formatProviderLabel(providerKey: string): string { + return providerKey + .split(OPENCODE_PROVIDER_WORD_SEPARATOR) + .map((word) => (word ? word[0].toUpperCase() + word.slice(1) : word)) + .join(' '); +} + +function getOpencodeSectionKey(providerKey: string): (typeof OPENCODE_SECTION_ORDER)[number] { + if (providerKey === OPENCODE_PROVIDER_FALLBACK) { + return 'free'; + } + return 'dynamic'; +} + +function getOpencodeGroupLabel( + providerKey: string, + sectionKey: (typeof OPENCODE_SECTION_ORDER)[number] +): string { + if (sectionKey === 'free') { + return OPENCODE_STATIC_PROVIDER_LABELS[providerKey] || 'OpenCode Free Tier'; + } + return OPENCODE_DYNAMIC_PROVIDER_LABELS[providerKey] || formatProviderLabel(providerKey); +} + interface PhaseModelSelectorProps { /** Label shown in full mode */ label?: string; @@ -95,6 +164,7 @@ export function PhaseModelSelector({ codexModels, codexModelsLoading, fetchCodexModels, + dynamicOpencodeModels, } = useAppStore(); // Extract model and thinking/reasoning levels from value @@ -235,12 +305,30 @@ export function PhaseModelSelector({ const codexModel = transformedCodexModels.find((m) => m.id === selectedModel); if (codexModel) return { ...codexModel, icon: OpenAIIcon }; - // Check OpenCode models + // Check OpenCode models (static) - use dynamic icon resolution for provider-specific icons const opencodeModel = OPENCODE_MODELS.find((m) => m.id === selectedModel); - if (opencodeModel) return { ...opencodeModel, icon: OpenCodeIcon }; + if (opencodeModel) return { ...opencodeModel, icon: getProviderIconForModel(opencodeModel.id) }; + + // Check dynamic OpenCode models - use dynamic icon resolution for provider-specific icons + const dynamicModel = dynamicOpencodeModels.find((m) => m.id === selectedModel); + if (dynamicModel) { + return { + id: dynamicModel.id, + label: dynamicModel.name, + description: dynamicModel.description, + provider: 'opencode' as const, + icon: getProviderIconForModel(dynamicModel.id), + }; + } return null; - }, [selectedModel, selectedThinkingLevel, availableCursorModels, transformedCodexModels]); + }, [ + selectedModel, + selectedThinkingLevel, + availableCursorModels, + transformedCodexModels, + dynamicOpencodeModels, + ]); // Compute grouped vs standalone Cursor models const { groupedModels, standaloneCursorModels } = useMemo(() => { @@ -275,13 +363,35 @@ export function PhaseModelSelector({ return { groupedModels: grouped, standaloneCursorModels: standalone }; }, [availableCursorModels, enabledCursorModels]); + // Combine static and dynamic OpenCode models + const allOpencodeModels: ModelOption[] = useMemo(() => { + // Start with static models + const staticModels = [...OPENCODE_MODELS]; + + // Add dynamic models (convert ModelDefinition to ModelOption) + const dynamicModelOptions: ModelOption[] = dynamicOpencodeModels.map((model) => ({ + id: model.id, + label: model.name, + description: model.description, + badge: model.tier === 'premium' ? 'Premium' : model.tier === 'basic' ? 'Free' : undefined, + provider: 'opencode' as const, + })); + + // Merge, avoiding duplicates (static models take precedence for same ID) + // In practice, static and dynamic IDs don't overlap + const staticIds = new Set(staticModels.map((m) => m.id)); + const uniqueDynamic = dynamicModelOptions.filter((m) => !staticIds.has(m.id)); + + return [...staticModels, ...uniqueDynamic]; + }, [dynamicOpencodeModels]); + // Group models const { favorites, claude, cursor, codex, opencode } = useMemo(() => { const favs: typeof CLAUDE_MODELS = []; const cModels: typeof CLAUDE_MODELS = []; const curModels: typeof CURSOR_MODELS = []; const codModels: typeof transformedCodexModels = []; - const ocModels: typeof OPENCODE_MODELS = []; + const ocModels: ModelOption[] = []; // Process Claude Models CLAUDE_MODELS.forEach((model) => { @@ -310,8 +420,8 @@ export function PhaseModelSelector({ } }); - // Process OpenCode Models - OPENCODE_MODELS.forEach((model) => { + // Process OpenCode Models (including dynamic) + allOpencodeModels.forEach((model) => { if (favoriteModels.includes(model.id)) { favs.push(model); } else { @@ -326,7 +436,95 @@ export function PhaseModelSelector({ codex: codModels, opencode: ocModels, }; - }, [favoriteModels, availableCursorModels, transformedCodexModels]); + }, [favoriteModels, availableCursorModels, transformedCodexModels, allOpencodeModels]); + + // Group OpenCode models by model type for better organization + const opencodeSections = useMemo(() => { + type OpencodeSectionKey = (typeof OPENCODE_SECTION_ORDER)[number]; + type OpencodeGroup = { key: string; label: string; models: ModelOption[] }; + type OpencodeSection = { + key: OpencodeSectionKey; + label: string; + showGroupLabels: boolean; + groups: OpencodeGroup[]; + }; + + const sections: Record> = { + free: {}, + dynamic: {}, + }; + const dynamicProviderById = new Map( + dynamicOpencodeModels.map((model) => [model.id, model.provider]) + ); + + const resolveProviderKey = (modelId: string): string => { + const staticProvider = OPENCODE_STATIC_PROVIDER_BY_ID.get(modelId); + if (staticProvider) return staticProvider; + + const dynamicProvider = dynamicProviderById.get(modelId); + if (dynamicProvider) return dynamicProvider; + + return modelId.includes(OPENCODE_MODEL_ID_SEPARATOR) + ? modelId.split(OPENCODE_MODEL_ID_SEPARATOR)[0] + : OPENCODE_PROVIDER_FALLBACK; + }; + + const addModelToGroup = ( + sectionKey: OpencodeSectionKey, + providerKey: string, + model: ModelOption + ) => { + if (!sections[sectionKey][providerKey]) { + sections[sectionKey][providerKey] = { + key: providerKey, + label: getOpencodeGroupLabel(providerKey, sectionKey), + models: [], + }; + } + sections[sectionKey][providerKey].models.push(model); + }; + + opencode.forEach((model) => { + const providerKey = resolveProviderKey(model.id); + const sectionKey = getOpencodeSectionKey(providerKey); + addModelToGroup(sectionKey, providerKey, model); + }); + + const buildGroupList = (sectionKey: OpencodeSectionKey): OpencodeGroup[] => { + const groupMap = sections[sectionKey]; + const priorityOrder = sectionKey === 'dynamic' ? OPENCODE_DYNAMIC_PROVIDER_ORDER : []; + const priorityMap = new Map(priorityOrder.map((provider, index) => [provider, index])); + + return Object.keys(groupMap) + .sort((a, b) => { + const aPriority = priorityMap.get(a); + const bPriority = priorityMap.get(b); + + if (aPriority !== undefined && bPriority !== undefined) { + return aPriority - bPriority; + } + if (aPriority !== undefined) return -1; + if (bPriority !== undefined) return 1; + + return groupMap[a].label.localeCompare(groupMap[b].label); + }) + .map((key) => groupMap[key]); + }; + + const builtSections = OPENCODE_SECTION_ORDER.map((sectionKey) => { + const groups = buildGroupList(sectionKey); + if (groups.length === 0) return null; + + return { + key: sectionKey, + label: OPENCODE_SECTION_LABELS[sectionKey], + showGroupLabels: sectionKey !== 'free', + groups, + }; + }).filter(Boolean) as OpencodeSection[]; + + return builtSections; + }, [opencode, dynamicOpencodeModels]); // Render Codex model item with secondary popover for reasoning effort (only for models that support it) const renderCodexModelItem = (model: (typeof transformedCodexModels)[0]) => { @@ -992,9 +1190,32 @@ export function PhaseModelSelector({ )} - {opencode.length > 0 && ( - - {opencode.map((model) => renderOpencodeModelItem(model))} + {opencodeSections.length > 0 && ( + + {opencodeSections.map((section, sectionIndex) => ( + +
+ {section.label} +
+
+ {section.groups.map((group) => ( +
+ {section.showGroupLabels && ( +
+ {group.label} +
+ )} + {group.models.map((model) => renderOpencodeModelItem(model))} +
+ ))} +
+
+ ))}
)} diff --git a/apps/ui/src/components/views/settings-view/providers/opencode-dynamic-providers.tsx b/apps/ui/src/components/views/settings-view/providers/opencode-dynamic-providers.tsx new file mode 100644 index 00000000..723fb052 --- /dev/null +++ b/apps/ui/src/components/views/settings-view/providers/opencode-dynamic-providers.tsx @@ -0,0 +1,294 @@ +/** + * OpenCode Dynamic Providers Component + * + * Shows authenticated providers from OpenCode CLI and allows + * refreshing the model list from the CLI. + */ + +import { useState, useCallback, useEffect } from 'react'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { RefreshCw, CheckCircle2, XCircle, Cloud, Terminal, Loader2 } from 'lucide-react'; +import { toast } from 'sonner'; +import { cn } from '@/lib/utils'; +import { getElectronAPI } from '@/lib/electron'; +import { useAppStore } from '@/store/app-store'; +import { createLogger } from '@automaker/utils/logger'; + +const logger = createLogger('OpenCodeDynamicProviders'); + +interface OpenCodeProviderInfo { + id: string; + name: string; + authenticated: boolean; + authMethod?: 'oauth' | 'api_key'; +} + +interface OpenCodeDynamicProvidersProps { + isCliInstalled: boolean; +} + +/** + * Provider display configuration + */ +const PROVIDER_CONFIG: Record = { + copilot: { icon: '', displayName: 'GitHub Copilot' }, + anthropic: { icon: '', displayName: 'Anthropic' }, + openai: { icon: '', displayName: 'OpenAI' }, + google: { icon: '', displayName: 'Google' }, + 'amazon-bedrock': { icon: '', displayName: 'AWS Bedrock' }, + azure: { icon: '', displayName: 'Azure OpenAI' }, + ollama: { icon: '', displayName: 'Ollama' }, + lmstudio: { icon: '', displayName: 'LM Studio' }, + opencode: { icon: '', displayName: 'OpenCode' }, +}; + +function getProviderDisplay(provider: OpenCodeProviderInfo) { + const config = PROVIDER_CONFIG[provider.id] || { + displayName: provider.name || provider.id, + }; + return config.displayName; +} + +export function OpenCodeDynamicProviders({ isCliInstalled }: OpenCodeDynamicProvidersProps) { + const [providers, setProviders] = useState([]); + const [isLoadingProviders, setIsLoadingProviders] = useState(false); + const [isRefreshingModels, setIsRefreshingModels] = useState(false); + const { dynamicOpencodeModels, setDynamicOpencodeModels } = useAppStore(); + + // Model count derived from store + const modelCount = dynamicOpencodeModels.length; + + // Fetch models from API and store them (only if not already loaded) + const fetchModels = useCallback( + async (force = false) => { + // Skip if already have models and not forcing refresh + if (!force && dynamicOpencodeModels.length > 0) { + logger.debug('Dynamic models already loaded, skipping fetch'); + return; + } + + try { + const api = getElectronAPI(); + if (api?.setup?.getOpencodeModels) { + const data = await api.setup.getOpencodeModels(); + if (data.success && data.models) { + setDynamicOpencodeModels(data.models); + logger.info(`Loaded ${data.models.length} dynamic OpenCode models`); + } + } + } catch (error) { + logger.error('Failed to fetch OpenCode models:', error); + } + }, + [dynamicOpencodeModels.length, setDynamicOpencodeModels] + ); + + // Fetch providers on mount, but only fetch models if not already loaded + useEffect(() => { + if (isCliInstalled) { + fetchProviders(); + // Only fetch models if store is empty + if (dynamicOpencodeModels.length === 0) { + fetchModels(false); + } + } + }, [isCliInstalled]); // Intentionally not including fetchModels to avoid re-fetching + + const fetchProviders = useCallback(async () => { + setIsLoadingProviders(true); + try { + const api = getElectronAPI(); + if (api?.setup?.getOpencodeProviders) { + const data = await api.setup.getOpencodeProviders(); + if (data.success && data.providers) { + setProviders(data.providers); + } + } else { + logger.warn('OpenCode providers API not available'); + } + } catch (error) { + logger.error('Failed to fetch OpenCode providers:', error); + } finally { + setIsLoadingProviders(false); + } + }, []); + + const handleRefreshModels = useCallback(async () => { + setIsRefreshingModels(true); + try { + const api = getElectronAPI(); + if (api?.setup?.refreshOpencodeModels) { + const data = await api.setup.refreshOpencodeModels(); + if (data.success) { + // Store the refreshed models in the app store + if (data.models) { + setDynamicOpencodeModels(data.models); + toast.success(`Refreshed ${data.models.length} models from OpenCode CLI`); + } + // Also refresh providers + await fetchProviders(); + } else { + toast.error(data.error || 'Failed to refresh models'); + } + } else { + logger.warn('OpenCode refresh models API not available'); + toast.error('OpenCode API not available'); + } + } catch (error) { + logger.error('Failed to refresh OpenCode models:', error); + toast.error('Failed to refresh models from OpenCode CLI'); + } finally { + setIsRefreshingModels(false); + } + }, [fetchProviders, setDynamicOpencodeModels]); + + if (!isCliInstalled) { + return null; + } + + const authenticatedProviders = providers.filter((p) => p.authenticated); + const unauthenticatedProviders = providers.filter((p) => !p.authenticated); + + return ( +
+
+
+
+
+ +
+
+

+ Dynamic Providers +

+ {modelCount !== null && ( +

{modelCount} models available

+ )} +
+
+ +
+

+ OpenCode discovers models from your authenticated providers (GitHub Copilot, Google, etc.) +

+
+ +
+ {isLoadingProviders ? ( +
+ +
+ ) : providers.length === 0 ? ( +
+ +

No providers detected yet

+

+ Run opencode and use{' '} + /connect to authenticate + with providers +

+
+ ) : ( +
+ {/* Authenticated Providers */} + {authenticatedProviders.length > 0 && ( +
+

+ + Authenticated +

+
+ {authenticatedProviders.map((provider) => ( +
+
+
+ +
+ + {getProviderDisplay(provider)} + +
+ {provider.authMethod && ( + + {provider.authMethod === 'oauth' ? 'OAuth' : 'API Key'} + + )} +
+ ))} +
+
+ )} + + {/* Available but Not Authenticated */} + {unauthenticatedProviders.length > 0 && ( +
+

+ + Available +

+
+ {unauthenticatedProviders.slice(0, 5).map((provider) => ( +
+
+
+ +
+ + {getProviderDisplay(provider)} + +
+ Not authenticated +
+ ))} + {unauthenticatedProviders.length > 5 && ( +

+ +{unauthenticatedProviders.length - 5} more providers available +

+ )} +
+
+ )} + + {/* Help text */} +
+

+ Use opencode /connect{' '} + to add new providers like GitHub Copilot, Google AI, or local models. +

+
+
+ )} +
+
+ ); +} diff --git a/apps/ui/src/components/views/settings-view/providers/opencode-model-configuration.tsx b/apps/ui/src/components/views/settings-view/providers/opencode-model-configuration.tsx index c74f8830..a2b11381 100644 --- a/apps/ui/src/components/views/settings-view/providers/opencode-model-configuration.tsx +++ b/apps/ui/src/components/views/settings-view/providers/opencode-model-configuration.tsx @@ -1,6 +1,7 @@ import { Label } from '@/components/ui/label'; import { Badge } from '@/components/ui/badge'; import { Checkbox } from '@/components/ui/checkbox'; +import { Button } from '@/components/ui/button'; import { Select, SelectContent, @@ -8,20 +9,32 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; +import { Terminal, Cloud, Cpu, Brain, Github, Loader2, KeyRound, ShieldCheck } from 'lucide-react'; import { cn } from '@/lib/utils'; -import type { OpencodeModelId, OpencodeProvider, OpencodeModelConfig } from '@automaker/types'; +import { Input } from '@/components/ui/input'; +import type { + OpencodeModelId, + OpencodeProvider, + OpencodeModelConfig, + ModelDefinition, +} from '@automaker/types'; import { OPENCODE_MODELS, OPENCODE_MODEL_CONFIG_MAP } from '@automaker/types'; +import type { OpenCodeProviderInfo } from '../cli-status/opencode-cli-status'; import { OpenCodeIcon, DeepSeekIcon, QwenIcon, NovaIcon, AnthropicIcon, + OpenRouterIcon, MistralIcon, MetaIcon, + GeminiIcon, + OpenAIIcon, + GrokIcon, getProviderIconForModel, } from '@/components/ui/provider-icon'; -import type { ComponentType } from 'react'; +import { useEffect, useMemo, useRef, useState, type ComponentType } from 'react'; interface OpencodeModelConfigurationProps { enabledOpencodeModels: OpencodeModelId[]; @@ -29,6 +42,12 @@ interface OpencodeModelConfigurationProps { isSaving: boolean; onDefaultModelChange: (model: OpencodeModelId) => void; onModelToggle: (model: OpencodeModelId, enabled: boolean) => void; + providers?: OpenCodeProviderInfo[]; + // Dynamic models + dynamicModels: ModelDefinition[]; + enabledDynamicModelIds: string[]; + onDynamicModelToggle: (modelId: string, enabled: boolean) => void; + isLoadingDynamicModels?: boolean; } /** @@ -45,31 +64,144 @@ function getProviderLabel(provider: OpencodeProvider): string { switch (provider) { case 'opencode': return 'OpenCode (Free)'; - case 'amazon-bedrock-anthropic': - return 'Claude (Bedrock)'; - case 'amazon-bedrock-deepseek': - return 'DeepSeek (Bedrock)'; - case 'amazon-bedrock-amazon': - return 'Amazon Nova (Bedrock)'; - case 'amazon-bedrock-meta': - return 'Meta Llama (Bedrock)'; - case 'amazon-bedrock-mistral': - return 'Mistral (Bedrock)'; - case 'amazon-bedrock-qwen': - return 'Qwen (Bedrock)'; default: return provider; } } +/** + * Configuration for dynamic provider display + */ +const DYNAMIC_PROVIDER_CONFIG: Record< + string, + { label: string; icon: ComponentType<{ className?: string }> } +> = { + 'github-copilot': { label: 'GitHub Copilot', icon: Github }, + google: { label: 'Google AI', icon: GeminiIcon }, + openai: { label: 'OpenAI', icon: OpenAIIcon }, + openrouter: { label: 'OpenRouter', icon: OpenRouterIcon }, + anthropic: { label: 'Anthropic', icon: AnthropicIcon }, + opencode: { label: 'OpenCode (Free)', icon: Terminal }, + ollama: { label: 'Ollama (Local)', icon: Cpu }, + lmstudio: { label: 'LM Studio (Local)', icon: Cpu }, + azure: { label: 'Azure OpenAI', icon: Cloud }, + 'amazon-bedrock': { label: 'AWS Bedrock', icon: Cloud }, + xai: { label: 'xAI', icon: GrokIcon }, + deepseek: { label: 'DeepSeek', icon: Brain }, +}; + +function getDynamicProviderConfig(providerId: string) { + return ( + DYNAMIC_PROVIDER_CONFIG[providerId] || { + label: providerId.charAt(0).toUpperCase() + providerId.slice(1).replace(/-/g, ' '), + icon: Cloud, + } + ); +} + +const OPENCODE_AUTH_METHOD_LABELS: Record = { + oauth: 'OAuth', + api_key: 'Key', + api: 'Key', + key: 'Key', +}; +const OPENCODE_AUTH_METHOD_ICONS: Record> = { + oauth: ShieldCheck, + api_key: KeyRound, + api: KeyRound, + key: KeyRound, +}; +const OPENCODE_PROVIDER_FILTER_CLEAR_LABEL = 'Clear'; +const OPENCODE_PROVIDER_FILTER_SEARCH_PLACEHOLDER = 'Search models...'; +const OPENCODE_PROVIDER_FILTER_EMPTY_LABEL = 'No models match your filters.'; +const OPENCODE_PROVIDER_FILTER_EMPTY_HINT = 'Try a different search or provider.'; +const OPENCODE_PROVIDER_MODELS_EMPTY_LABEL = 'No models available yet.'; +const OPENCODE_PROVIDER_MODELS_EMPTY_HINT = 'Connect or refresh OpenCode CLI to load models.'; +const OPENCODE_DYNAMIC_MODELS_SECTION_LABEL = 'Dynamic Models (from OpenCode providers)'; +const OPENCODE_SELECT_DYNAMIC_LABEL = 'Select all'; +const OPENCODE_SELECT_STATIC_LABEL = 'Select all'; +const OPENCODE_SELECT_ALL_CONTAINER_CLASS = + 'flex items-center gap-2 rounded-full border border-border/60 bg-card/60 px-2.5 py-1 text-xs text-muted-foreground'; + +function formatProviderAuthLabel(provider?: OpenCodeProviderInfo): string | null { + if (!provider?.authMethod) return null; + return OPENCODE_AUTH_METHOD_LABELS[provider.authMethod] || provider.authMethod; +} + +function getProviderAuthIcon( + provider?: OpenCodeProviderInfo +): ComponentType<{ className?: string }> | null { + if (!provider?.authMethod) return null; + return OPENCODE_AUTH_METHOD_ICONS[provider.authMethod] || null; +} + +function getDynamicProviderBaseLabel( + providerId: string, + providerInfo: OpenCodeProviderInfo | undefined +): string { + const providerConfig = getDynamicProviderConfig(providerId); + return providerInfo?.name || providerConfig.label; +} + +function getDynamicProviderLabel( + providerId: string, + providerInfo: OpenCodeProviderInfo | undefined +): string { + const providerConfig = getDynamicProviderConfig(providerId); + const baseLabel = providerInfo?.name || providerConfig.label; + const authLabel = formatProviderAuthLabel(providerInfo); + return authLabel ? `${baseLabel} (${authLabel})` : baseLabel; +} + +function getSelectionState( + candidateIds: string[], + selectedIds: string[] +): boolean | 'indeterminate' { + if (candidateIds.length === 0) return false; + const allSelected = candidateIds.every((modelId) => selectedIds.includes(modelId)); + if (allSelected) return true; + const anySelected = candidateIds.some((modelId) => selectedIds.includes(modelId)); + return anySelected ? 'indeterminate' : false; +} + +/** + * Group dynamic models by their provider + */ +function groupDynamicModelsByProvider( + models: ModelDefinition[] +): Record { + return models.reduce( + (acc, model) => { + const provider = model.provider || 'unknown'; + if (!acc[provider]) { + acc[provider] = []; + } + acc[provider].push(model); + return acc; + }, + {} as Record + ); +} + +function matchesDynamicModelQuery(model: ModelDefinition, query: string): boolean { + if (!query) return true; + const haystack = `${model.name} ${model.description} ${model.id}`.toLowerCase(); + return haystack.includes(query); +} + export function OpencodeModelConfiguration({ enabledOpencodeModels, opencodeDefaultModel, isSaving, onDefaultModelChange, onModelToggle, + providers, + dynamicModels, + enabledDynamicModelIds, + onDynamicModelToggle, + isLoadingDynamicModels = false, }: OpencodeModelConfigurationProps) { - // Group models by provider for organized display + // Group static models by provider for organized display const modelsByProvider = OPENCODE_MODELS.reduce( (acc, model) => { if (!acc[model.provider]) { @@ -81,17 +213,141 @@ export function OpencodeModelConfiguration({ {} as Record ); + // Group dynamic models by provider + const dynamicModelsByProvider = groupDynamicModelsByProvider(dynamicModels); + const authenticatedProviders = (providers || []).filter((provider) => provider.authenticated); + const [dynamicProviderFilter, setDynamicProviderFilter] = useState(null); + const hasInitializedDynamicProviderFilter = useRef(false); + const [dynamicProviderSearch, setDynamicProviderSearch] = useState(''); + const normalizedDynamicSearch = dynamicProviderSearch.trim().toLowerCase(); + const hasDynamicSearch = normalizedDynamicSearch.length > 0; + const allStaticModelIds = OPENCODE_MODELS.map((model) => model.id); + const selectableStaticModelIds = allStaticModelIds.filter( + (modelId) => modelId !== opencodeDefaultModel + ); + const allDynamicModelIds = dynamicModels.map((model) => model.id); + const hasDynamicModels = allDynamicModelIds.length > 0; + const staticSelectState = getSelectionState(selectableStaticModelIds, enabledOpencodeModels); + // Order: Free tier first, then Claude, then others - const providerOrder: OpencodeProvider[] = [ - 'opencode', - 'amazon-bedrock-anthropic', - 'amazon-bedrock-deepseek', - 'amazon-bedrock-amazon', - 'amazon-bedrock-meta', - 'amazon-bedrock-mistral', - 'amazon-bedrock-qwen', + const providerOrder: OpencodeProvider[] = ['opencode']; + + // Dynamic provider order (prioritize commonly used ones) + const dynamicProviderOrder = [ + 'github-copilot', + 'google', + 'openai', + 'openrouter', + 'anthropic', + 'xai', + 'deepseek', + 'ollama', + 'lmstudio', + 'azure', + 'amazon-bedrock', + 'opencode', // Skip opencode in dynamic since it's in static ]; + const sortedDynamicProviders = useMemo(() => { + const providerIndex = (providerId: string) => dynamicProviderOrder.indexOf(providerId); + const providerIds = new Set([ + ...Object.keys(dynamicModelsByProvider), + ...(providers || []).map((provider) => provider.id), + ]); + + providerIds.delete('opencode'); // Don't show opencode twice + + return Array.from(providerIds).sort((a, b) => { + const aIndex = providerIndex(a); + const bIndex = providerIndex(b); + if (aIndex !== -1 && bIndex !== -1) return aIndex - bIndex; + if (aIndex !== -1) return -1; + if (bIndex !== -1) return 1; + return a.localeCompare(b); + }); + }, [dynamicModelsByProvider, providers]); + + useEffect(() => { + if ( + dynamicProviderFilter && + sortedDynamicProviders.length > 0 && + !sortedDynamicProviders.includes(dynamicProviderFilter) + ) { + setDynamicProviderFilter(sortedDynamicProviders[0]); + return; + } + + if ( + !hasInitializedDynamicProviderFilter.current && + !dynamicProviderFilter && + sortedDynamicProviders.length > 0 + ) { + hasInitializedDynamicProviderFilter.current = true; + setDynamicProviderFilter(sortedDynamicProviders[0]); + } + }, [dynamicProviderFilter, sortedDynamicProviders]); + + const filteredDynamicProviders = useMemo(() => { + const baseProviders = dynamicProviderFilter ? [dynamicProviderFilter] : sortedDynamicProviders; + + if (!hasDynamicSearch) { + return baseProviders; + } + + return baseProviders.filter((providerId) => { + const models = dynamicModelsByProvider[providerId] || []; + return models.some((model) => matchesDynamicModelQuery(model, normalizedDynamicSearch)); + }); + }, [ + dynamicModelsByProvider, + dynamicProviderFilter, + hasDynamicSearch, + normalizedDynamicSearch, + sortedDynamicProviders, + ]); + + const hasDynamicProviders = sortedDynamicProviders.length > 0; + const showDynamicProviderFilters = sortedDynamicProviders.length > 1; + const hasFilteredDynamicProviders = filteredDynamicProviders.length > 0; + + const toggleDynamicProviderFilter = (providerId: string) => { + setDynamicProviderFilter((current) => (current === providerId ? current : providerId)); + }; + + const toggleAllStaticModels = (checked: boolean) => { + if (checked) { + selectableStaticModelIds.forEach((modelId) => { + if (!enabledOpencodeModels.includes(modelId)) { + onModelToggle(modelId, true); + } + }); + return; + } + + selectableStaticModelIds.forEach((modelId) => { + if (enabledOpencodeModels.includes(modelId)) { + onModelToggle(modelId, false); + } + }); + }; + + const toggleProviderDynamicModels = (modelIds: string[], checked: boolean) => { + if (checked) { + modelIds.forEach((modelId) => { + if (!enabledDynamicModelIds.includes(modelId)) { + onDynamicModelToggle(modelId, true); + } + }); + return; + } + + modelIds.forEach((modelId) => { + if (enabledDynamicModelIds.includes(modelId)) { + onDynamicModelToggle(modelId, false); + } + }); + }; + return (
- +
+ + {selectableStaticModelIds.length > 0 && ( +
+ + {OPENCODE_SELECT_STATIC_LABEL} +
+ )} +
+ + {/* Static models grouped by provider (Built-in) */} {providerOrder.map((provider) => { const models = modelsByProvider[provider]; if (!models || models.length === 0) return null; @@ -217,6 +487,196 @@ export function OpencodeModelConfiguration({
); })} + + {/* Dynamic models from OpenCode providers */} + {(hasDynamicProviders || isLoadingDynamicModels) && ( + <> + {/* Separator between static and dynamic models */} +
+
+
+

+ {OPENCODE_DYNAMIC_MODELS_SECTION_LABEL} +

+ {isLoadingDynamicModels && ( +
+ + Discovering... +
+ )} +
+
+ + {showDynamicProviderFilters && ( +
+
+ {sortedDynamicProviders.map((providerId) => { + const providerInfo = authenticatedProviders.find( + (provider) => provider.id === providerId + ); + const providerLabel = getDynamicProviderBaseLabel(providerId, providerInfo); + const providerConfig = getDynamicProviderConfig(providerId); + const ProviderIcon = providerConfig.icon; + const AuthIcon = getProviderAuthIcon(providerInfo); + const authLabel = formatProviderAuthLabel(providerInfo); + const isActive = dynamicProviderFilter === providerId; + const authBadgeClass = cn( + 'inline-flex h-5 w-5 items-center justify-center rounded-full border border-transparent bg-transparent text-muted-foreground/80 transition-colors', + isActive && 'text-accent-foreground' + ); + + return ( + + ); + })} +
+
+ )} + + {hasDynamicProviders && ( +
+ setDynamicProviderSearch(event.target.value)} + placeholder={OPENCODE_PROVIDER_FILTER_SEARCH_PLACEHOLDER} + className="h-8 text-xs" + /> + {dynamicProviderSearch && ( + + )} +
+ )} + + {hasDynamicSearch && !hasFilteredDynamicProviders && ( +
+

{OPENCODE_PROVIDER_FILTER_EMPTY_LABEL}

+

{OPENCODE_PROVIDER_FILTER_EMPTY_HINT}

+
+ )} + + {filteredDynamicProviders.map((providerId) => { + const models = dynamicModelsByProvider[providerId] || []; + const providerConfig = getDynamicProviderConfig(providerId); + const providerInfo = authenticatedProviders.find( + (provider) => provider.id === providerId + ); + const providerLabel = getDynamicProviderLabel(providerId, providerInfo); + const DynamicProviderIcon = providerConfig.icon; + const filteredModels = hasDynamicSearch + ? models.filter((model) => + matchesDynamicModelQuery(model, normalizedDynamicSearch) + ) + : models; + + if (hasDynamicSearch && filteredModels.length === 0) { + return null; + } + + return ( +
+
+
+ + {providerLabel} + + Dynamic + +
+ {models.length > 0 && ( +
+ model.id), + enabledDynamicModelIds + )} + onCheckedChange={(checked) => + toggleProviderDynamicModels( + models.map((model) => model.id), + checked + ) + } + disabled={isSaving} + /> + {OPENCODE_SELECT_DYNAMIC_LABEL} +
+ )} +
+
+ {filteredModels.length === 0 ? ( +
+

{OPENCODE_PROVIDER_MODELS_EMPTY_LABEL}

+

{OPENCODE_PROVIDER_MODELS_EMPTY_HINT}

+
+ ) : ( + filteredModels.map((model) => { + const isEnabled = enabledDynamicModelIds.includes(model.id); + + return ( +
+
+ + onDynamicModelToggle(model.id, !!checked) + } + disabled={isSaving} + /> +
+
+ {model.name} + {model.supportsVision && ( + + Vision + + )} +
+

+ {model.description} +

+
+
+
+ ); + }) + )} +
+
+ ); + })} + + )}
diff --git a/apps/ui/src/components/views/settings-view/providers/opencode-settings-tab.tsx b/apps/ui/src/components/views/settings-view/providers/opencode-settings-tab.tsx index dbcd762c..568789ad 100644 --- a/apps/ui/src/components/views/settings-view/providers/opencode-settings-tab.tsx +++ b/apps/ui/src/components/views/settings-view/providers/opencode-settings-tab.tsx @@ -1,17 +1,13 @@ import { useState, useCallback, useEffect } from 'react'; import { toast } from 'sonner'; import { useAppStore } from '@/store/app-store'; -import { - OpencodeCliStatus, - OpencodeCliStatusSkeleton, - OpencodeModelConfigSkeleton, -} from '../cli-status/opencode-cli-status'; +import { OpencodeCliStatus, OpencodeCliStatusSkeleton } from '../cli-status/opencode-cli-status'; import { OpencodeModelConfiguration } from './opencode-model-configuration'; import { getElectronAPI } from '@/lib/electron'; import { createLogger } from '@automaker/utils/logger'; import type { CliStatus as SharedCliStatus } from '../shared/types'; import type { OpencodeModelId } from '@automaker/types'; -import type { OpencodeAuthStatus } from '../cli-status/opencode-cli-status'; +import type { OpencodeAuthStatus, OpenCodeProviderInfo } from '../cli-status/opencode-cli-status'; const logger = createLogger('OpencodeSettings'); @@ -21,15 +17,21 @@ export function OpencodeSettingsTab() { opencodeDefaultModel, setOpencodeDefaultModel, toggleOpencodeModel, + setDynamicOpencodeModels, + dynamicOpencodeModels, + enabledDynamicModelIds, + toggleDynamicModel, + cachedOpencodeProviders, + setCachedOpencodeProviders, } = useAppStore(); const [isCheckingOpencodeCli, setIsCheckingOpencodeCli] = useState(false); - const [isInitialLoading, setIsInitialLoading] = useState(true); + const [isLoadingDynamicModels, setIsLoadingDynamicModels] = useState(false); const [cliStatus, setCliStatus] = useState(null); const [authStatus, setAuthStatus] = useState(null); const [isSaving, setIsSaving] = useState(false); - // Load OpenCode CLI status on mount + // Phase 1: Load CLI status quickly on mount useEffect(() => { const checkOpencodeStatus = async () => { setIsCheckingOpencodeCli(true); @@ -46,7 +48,6 @@ export function OpencodeSettingsTab() { recommendation: result.recommendation, installCommands: result.installCommands, }); - // Set auth status if available if (result.auth) { setAuthStatus({ authenticated: result.auth.authenticated, @@ -57,7 +58,6 @@ export function OpencodeSettingsTab() { }); } } else { - // Fallback for web mode or when API is not available setCliStatus({ success: false, status: 'not_installed', @@ -73,14 +73,54 @@ export function OpencodeSettingsTab() { }); } finally { setIsCheckingOpencodeCli(false); - setIsInitialLoading(false); } }; checkOpencodeStatus(); }, []); + // Phase 2: Load dynamic models and providers in background (only if not cached) + useEffect(() => { + const loadDynamicContent = async () => { + const api = getElectronAPI(); + const isInstalled = cliStatus?.success && cliStatus?.status === 'installed'; + + if (!isInstalled || !api?.setup) return; + + // Skip if already have cached data + const needsProviders = cachedOpencodeProviders.length === 0; + const needsModels = dynamicOpencodeModels.length === 0; + + if (!needsProviders && !needsModels) return; + + setIsLoadingDynamicModels(true); + try { + // Load providers if needed + if (needsProviders && api.setup.getOpencodeProviders) { + const providersResult = await api.setup.getOpencodeProviders(); + if (providersResult.success && providersResult.providers) { + setCachedOpencodeProviders(providersResult.providers); + } + } + + // Load models if needed + if (needsModels && api.setup.getOpencodeModels) { + const modelsResult = await api.setup.getOpencodeModels(); + if (modelsResult.success && modelsResult.models) { + setDynamicOpencodeModels(modelsResult.models); + } + } + } catch (error) { + logger.error('Failed to load dynamic content:', error); + } finally { + setIsLoadingDynamicModels(false); + } + }; + loadDynamicContent(); + }, [cliStatus?.success, cliStatus?.status]); // eslint-disable-line react-hooks/exhaustive-deps + const handleRefreshOpencodeCli = useCallback(async () => { setIsCheckingOpencodeCli(true); + setIsLoadingDynamicModels(true); try { const api = getElectronAPI(); if (api?.setup?.getOpencodeStatus) { @@ -94,7 +134,6 @@ export function OpencodeSettingsTab() { recommendation: result.recommendation, installCommands: result.installCommands, }); - // Update auth status if available if (result.auth) { setAuthStatus({ authenticated: result.auth.authenticated, @@ -104,14 +143,35 @@ export function OpencodeSettingsTab() { hasOAuthToken: result.auth.hasOAuthToken, }); } + + if (result.installed) { + // Refresh providers + if (api?.setup?.getOpencodeProviders) { + const providersResult = await api.setup.getOpencodeProviders(); + if (providersResult.success && providersResult.providers) { + setCachedOpencodeProviders(providersResult.providers); + } + } + + // Refresh dynamic models + if (api?.setup?.refreshOpencodeModels) { + const modelsResult = await api.setup.refreshOpencodeModels(); + if (modelsResult.success && modelsResult.models) { + setDynamicOpencodeModels(modelsResult.models); + } + } + + toast.success('OpenCode CLI refreshed'); + } } } catch (error) { logger.error('Failed to refresh OpenCode CLI status:', error); toast.error('Failed to refresh OpenCode CLI status'); } finally { setIsCheckingOpencodeCli(false); + setIsLoadingDynamicModels(false); } - }, []); + }, [setDynamicOpencodeModels, setCachedOpencodeProviders]); const handleDefaultModelChange = useCallback( (model: OpencodeModelId) => { @@ -142,12 +202,25 @@ export function OpencodeSettingsTab() { [toggleOpencodeModel] ); - // Show loading skeleton during initial load - if (isInitialLoading) { + const handleDynamicModelToggle = useCallback( + (modelId: string, enabled: boolean) => { + setIsSaving(true); + try { + toggleDynamicModel(modelId, enabled); + } catch (error) { + toast.error('Failed to update dynamic model'); + } finally { + setIsSaving(false); + } + }, + [toggleDynamicModel] + ); + + // Show skeleton only while checking CLI status initially + if (!cliStatus && isCheckingOpencodeCli) { return (
-
); } @@ -159,6 +232,7 @@ export function OpencodeSettingsTab() { @@ -171,6 +245,10 @@ export function OpencodeSettingsTab() { isSaving={isSaving} onDefaultModelChange={handleDefaultModelChange} onModelToggle={handleModelToggle} + dynamicModels={dynamicOpencodeModels} + enabledDynamicModelIds={enabledDynamicModelIds} + onDynamicModelToggle={handleDynamicModelToggle} + isLoadingDynamicModels={isLoadingDynamicModels} /> )}
diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index d3b71a74..e2333520 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -1440,6 +1440,67 @@ export class HttpApiClient implements ElectronAPI { error?: string; }> => this.get('/api/setup/opencode-status'), + // OpenCode Dynamic Model Discovery + getOpencodeModels: ( + refresh?: boolean + ): Promise<{ + success: boolean; + models?: Array<{ + id: string; + name: string; + modelString: string; + provider: string; + description: string; + supportsTools: boolean; + supportsVision: boolean; + tier: string; + default?: boolean; + }>; + count?: number; + cached?: boolean; + error?: string; + }> => this.get(`/api/setup/opencode/models${refresh ? '?refresh=true' : ''}`), + + refreshOpencodeModels: (): Promise<{ + success: boolean; + models?: Array<{ + id: string; + name: string; + modelString: string; + provider: string; + description: string; + supportsTools: boolean; + supportsVision: boolean; + tier: string; + default?: boolean; + }>; + count?: number; + error?: string; + }> => this.post('/api/setup/opencode/models/refresh'), + + getOpencodeProviders: (): Promise<{ + success: boolean; + providers?: Array<{ + id: string; + name: string; + authenticated: boolean; + authMethod?: 'oauth' | 'api_key'; + }>; + authenticated?: Array<{ + id: string; + name: string; + authenticated: boolean; + authMethod?: 'oauth' | 'api_key'; + }>; + error?: string; + }> => this.get('/api/setup/opencode/providers'), + + clearOpencodeCache: (): Promise<{ + success: boolean; + message?: string; + error?: string; + }> => this.post('/api/setup/opencode/cache/clear'), + onInstallProgress: (callback: (progress: unknown) => void) => { return this.subscribeToEvent('agent:stream', callback); }, diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index e9593f3c..197b99ea 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -23,6 +23,7 @@ import type { PipelineConfig, PipelineStep, PromptCustomization, + ModelDefinition, } from '@automaker/types'; import { getAllCursorModelIds, @@ -583,8 +584,16 @@ export interface AppState { codexEnableImages: boolean; // Enable image processing // OpenCode CLI Settings (global) - enabledOpencodeModels: OpencodeModelId[]; // Which OpenCode models are available in feature modal + enabledOpencodeModels: OpencodeModelId[]; // Which static OpenCode models are available opencodeDefaultModel: OpencodeModelId; // Default OpenCode model selection + dynamicOpencodeModels: ModelDefinition[]; // Dynamically discovered models from OpenCode CLI + enabledDynamicModelIds: string[]; // Which dynamic models are enabled (model IDs) + cachedOpencodeProviders: Array<{ + id: string; + name: string; + authenticated: boolean; + authMethod?: string; + }>; // Cached providers // Claude Agent SDK Settings autoLoadClaudeMd: boolean; // Auto-load CLAUDE.md files using SDK's settingSources option @@ -988,6 +997,12 @@ export interface AppActions { setEnabledOpencodeModels: (models: OpencodeModelId[]) => void; setOpencodeDefaultModel: (model: OpencodeModelId) => void; toggleOpencodeModel: (model: OpencodeModelId, enabled: boolean) => void; + setDynamicOpencodeModels: (models: ModelDefinition[]) => void; + setEnabledDynamicModelIds: (ids: string[]) => void; + toggleDynamicModel: (modelId: string, enabled: boolean) => void; + setCachedOpencodeProviders: ( + providers: Array<{ id: string; name: string; authenticated: boolean; authMethod?: string }> + ) => void; // Claude Agent SDK Settings actions setAutoLoadClaudeMd: (enabled: boolean) => Promise; @@ -1220,6 +1235,9 @@ const initialState: AppState = { codexEnableImages: false, // Default to disabled enabledOpencodeModels: getAllOpencodeModelIds(), // All OpenCode models enabled by default opencodeDefaultModel: DEFAULT_OPENCODE_MODEL, // Default to Claude Sonnet 4.5 + dynamicOpencodeModels: [], // Empty until fetched from OpenCode CLI + enabledDynamicModelIds: [], // All dynamic models enabled by default (populated when models are fetched) + cachedOpencodeProviders: [], // Empty until fetched from OpenCode CLI autoLoadClaudeMd: false, // Default to disabled (user must opt-in) skipSandboxWarning: false, // Default to disabled (show sandbox warning dialog) mcpServers: [], // No MCP servers configured by default @@ -2017,6 +2035,27 @@ export const useAppStore = create()((set, get) => ({ ? [...state.enabledOpencodeModels, model] : state.enabledOpencodeModels.filter((m) => m !== model), })), + setDynamicOpencodeModels: (models) => { + // When setting dynamic models, auto-enable all of them if enabledDynamicModelIds is empty + const currentEnabled = get().enabledDynamicModelIds; + const newModelIds = models.map((m) => m.id); + + // If no models were previously enabled, enable all new ones + if (currentEnabled.length === 0) { + set({ dynamicOpencodeModels: models, enabledDynamicModelIds: newModelIds }); + } else { + // Keep existing enabled state, just update the models list + set({ dynamicOpencodeModels: models }); + } + }, + setEnabledDynamicModelIds: (ids) => set({ enabledDynamicModelIds: ids }), + toggleDynamicModel: (modelId, enabled) => + set((state) => ({ + enabledDynamicModelIds: enabled + ? [...state.enabledDynamicModelIds, modelId] + : state.enabledDynamicModelIds.filter((id) => id !== modelId), + })), + setCachedOpencodeProviders: (providers) => set({ cachedOpencodeProviders: providers }), // Claude Agent SDK Settings actions setAutoLoadClaudeMd: async (enabled) => { diff --git a/libs/model-resolver/src/resolver.ts b/libs/model-resolver/src/resolver.ts index 29259f9e..6c636f98 100644 --- a/libs/model-resolver/src/resolver.ts +++ b/libs/model-resolver/src/resolver.ts @@ -69,8 +69,12 @@ export function resolveModelString( return modelKey; } - // OpenCode model - pass through unchanged - // Supports: opencode/big-pickle, opencode-sonnet, amazon-bedrock/anthropic.claude-* + // OpenCode model (static or dynamic) - pass through unchanged + // This handles models like: + // - opencode-* (Automaker routing prefix) + // - opencode/* (free tier models) + // - amazon-bedrock/* (AWS Bedrock models) + // - provider/model-name (dynamic models like github-copilot/gpt-4o, google/gemini-2.5-pro) if (isOpencodeModel(modelKey)) { console.log(`[ModelResolver] Using OpenCode model: ${modelKey}`); return modelKey; diff --git a/libs/types/src/provider-utils.ts b/libs/types/src/provider-utils.ts index af1a2267..3143b336 100644 --- a/libs/types/src/provider-utils.ts +++ b/libs/types/src/provider-utils.ts @@ -95,8 +95,9 @@ export function isCodexModel(model: string | undefined | null): boolean { * - 'opencode/' prefix (OpenCode free tier models) * - 'amazon-bedrock/' prefix (AWS Bedrock models via OpenCode) * - Full model ID from OPENCODE_MODEL_CONFIG_MAP + * - Dynamic models from OpenCode CLI with provider/model format (e.g., "github-copilot/gpt-4o", "google/gemini-2.5-pro") * - * @param model - Model string to check (e.g., "opencode-sonnet", "opencode/big-pickle", "amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0") + * @param model - Model string to check * @returns true if the model is an OpenCode model */ export function isOpencodeModel(model: string | undefined | null): boolean { @@ -113,12 +114,26 @@ export function isOpencodeModel(model: string | undefined | null): boolean { } // Check for OpenCode native model prefixes - // - opencode/ = OpenCode free tier models (e.g., opencode/big-pickle) - // - amazon-bedrock/ = AWS Bedrock models (e.g., amazon-bedrock/anthropic.claude-*) + // - opencode/ = OpenCode free tier models + // - amazon-bedrock/ = AWS Bedrock models if (model.startsWith('opencode/') || model.startsWith('amazon-bedrock/')) { return true; } + // Check for dynamic models from OpenCode CLI with provider/model format + // These are models discovered dynamically from authenticated providers like: + // - github-copilot/gpt-4o + // - google/gemini-2.5-pro + // - xai/grok-3 + // Pattern: provider-id/model-name (must have exactly one / and not be a URL) + if (model.includes('/') && !model.includes('://')) { + const parts = model.split('/'); + // Valid dynamic model format: provider/model-name (exactly 2 parts) + if (parts.length === 2 && parts[0].length > 0 && parts[1].length > 0) { + return true; + } + } + return false; }