From 6c5206daf45fecf700130ebcbf1074d9fd8a6baf Mon Sep 17 00:00:00 2001 From: Stefan de Vogelaere Date: Sun, 11 Jan 2026 20:08:25 +0100 Subject: [PATCH 01/14] feat: add dynamic model discovery and routing for OpenCode provider - Update isOpencodeModel() to detect dynamic models with provider/model format (e.g., github-copilot/gpt-4o, google/gemini-2.5-pro, zai-coding-plan/glm-4.7) - Update resolveModelString() to recognize and pass through OpenCode models - Update enhance route to route OpenCode models to OpenCode provider - Fix OpenCode CLI command format: use --format json (not stream-json) - Remove unsupported -q and - flags from CLI arguments - Update normalizeEvent() to handle actual OpenCode JSON event format - Add dynamic model configuration UI with provider grouping - Cache providers and models in app store for snappier navigation - Show authenticated providers in OpenCode CLI status card Co-Authored-By: Claude Opus 4.5 --- .../server/src/providers/opencode-provider.ts | 884 ++++++++++++++---- .../routes/enhance-prompt/routes/enhance.ts | 16 +- apps/server/src/routes/setup/index.ts | 12 + .../routes/setup/routes/opencode-models.ts | 191 ++++ .../cli-status/opencode-cli-status.tsx | 111 ++- .../model-defaults/phase-model-selector.tsx | 255 ++++- .../providers/opencode-dynamic-providers.tsx | 294 ++++++ .../opencode-model-configuration.tsx | 508 +++++++++- .../providers/opencode-settings-tab.tsx | 110 ++- apps/ui/src/lib/http-api-client.ts | 61 ++ apps/ui/src/store/app-store.ts | 41 +- libs/model-resolver/src/resolver.ts | 8 +- libs/types/src/provider-utils.ts | 21 +- 13 files changed, 2247 insertions(+), 265 deletions(-) create mode 100644 apps/server/src/routes/setup/routes/opencode-models.ts create mode 100644 apps/ui/src/components/views/settings-view/providers/opencode-dynamic-providers.tsx 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; } From 5e4b4223150bd6b732577ff9a7aef9f0beb00b16 Mon Sep 17 00:00:00 2001 From: Stefan de Vogelaere Date: Sun, 11 Jan 2026 20:28:29 +0100 Subject: [PATCH 02/14] fix: improve OpenCode error handling and message extraction - Update error event interface to handle nested error objects with name/data/message structure from OpenCode CLI - Extract meaningful error messages from provider errors in normalizeEvent - Add error type handling in executeWithProvider to throw errors with actual provider messages instead of returning empty response Co-Authored-By: Claude Opus 4.5 --- apps/server/src/routes/enhance-prompt/routes/enhance.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/server/src/routes/enhance-prompt/routes/enhance.ts b/apps/server/src/routes/enhance-prompt/routes/enhance.ts index 6c2a1a43..73043284 100644 --- a/apps/server/src/routes/enhance-prompt/routes/enhance.ts +++ b/apps/server/src/routes/enhance-prompt/routes/enhance.ts @@ -111,7 +111,11 @@ async function executeWithProvider(prompt: string, model: string): Promise Date: Sun, 11 Jan 2026 20:59:16 +0100 Subject: [PATCH 03/14] fix: improve dynamic model icons and fix React reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add icon detection for dynamic OpenCode provider models (provider/model format) - Support zai-coding-plan, github-copilot, google, xai, and other providers - Detect model type from name (glm, claude, gpt, gemini, grok, etc.) - Fix React.useMemo → useMemo to resolve "React is not defined" error Co-Authored-By: Claude Opus 4.5 --- apps/ui/src/components/ui/provider-icon.tsx | 54 +++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/apps/ui/src/components/ui/provider-icon.tsx b/apps/ui/src/components/ui/provider-icon.tsx index e036c597..2f0d9693 100644 --- a/apps/ui/src/components/ui/provider-icon.tsx +++ b/apps/ui/src/components/ui/provider-icon.tsx @@ -442,6 +442,60 @@ function getUnderlyingModelIcon(model?: AgentModel | string): ProviderIconKey { return 'opencode'; } + // Check for dynamic OpenCode provider models (provider/model format) + // e.g., zai-coding-plan/glm-4.5, github-copilot/gpt-4o, google/gemini-2.5-pro + if (modelStr.includes('/')) { + const modelName = modelStr.split('/')[1] || ''; + // Check model name for known patterns + if (modelName.includes('glm')) { + return 'glm'; + } + if ( + modelName.includes('claude') || + modelName.includes('sonnet') || + modelName.includes('opus') + ) { + return 'anthropic'; + } + if (modelName.includes('gpt') || modelName.includes('o1') || modelName.includes('o3')) { + return 'openai'; + } + if (modelName.includes('gemini')) { + return 'gemini'; + } + if (modelName.includes('grok')) { + return 'grok'; + } + if (modelName.includes('deepseek')) { + return 'deepseek'; + } + if (modelName.includes('llama')) { + return 'meta'; + } + if (modelName.includes('qwen')) { + return 'qwen'; + } + if (modelName.includes('mistral')) { + return 'mistral'; + } + // Check provider name for hints + const providerName = modelStr.split('/')[0] || ''; + if (providerName.includes('google')) { + return 'gemini'; + } + if (providerName.includes('anthropic')) { + return 'anthropic'; + } + if (providerName.includes('openai')) { + return 'openai'; + } + if (providerName.includes('xai')) { + return 'grok'; + } + // Default for unknown dynamic models + return 'opencode'; + } + // Check for Cursor-specific models with underlying providers if (modelStr.includes('sonnet') || modelStr.includes('opus') || modelStr.includes('claude')) { return 'anthropic'; From 70204a2d360c68027efed130fb5ae6d69842e2b4 Mon Sep 17 00:00:00 2001 From: Stefan de Vogelaere Date: Sun, 11 Jan 2026 21:53:32 +0100 Subject: [PATCH 04/14] fix: address code review feedback from gemini-code-assist - Convert execFileSync to async execFile in fetchModelsFromCli and fetchAuthenticatedProviders to avoid blocking the event loop - Remove unused opencode-dynamic-providers.tsx component - Use regex for more robust model ID validation in parseModelsOutput Co-Authored-By: Claude Opus 4.5 --- .../providers/opencode-dynamic-providers.tsx | 294 ------------------ 1 file changed, 294 deletions(-) delete mode 100644 apps/ui/src/components/views/settings-view/providers/opencode-dynamic-providers.tsx 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 deleted file mode 100644 index 723fb052..00000000 --- a/apps/ui/src/components/views/settings-view/providers/opencode-dynamic-providers.tsx +++ /dev/null @@ -1,294 +0,0 @@ -/** - * 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. -

-
-
- )} -
-
- ); -} From 20cc401238781bef71d684bec27a334aa7d48602 Mon Sep 17 00:00:00 2001 From: Stefan de Vogelaere Date: Sun, 11 Jan 2026 21:57:12 +0100 Subject: [PATCH 05/14] fix: update enhancement test to include ux-reviewer mode Test expected 4 enhancement modes but there are now 5 after adding the ux-reviewer mode. Co-Authored-By: Claude Opus 4.5 --- libs/prompts/tests/enhancement.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/libs/prompts/tests/enhancement.test.ts b/libs/prompts/tests/enhancement.test.ts index f8f463ef..ee1f16aa 100644 --- a/libs/prompts/tests/enhancement.test.ts +++ b/libs/prompts/tests/enhancement.test.ts @@ -399,7 +399,7 @@ describe('enhancement.ts', () => { const modes = getAvailableEnhancementModes(); expect(Array.isArray(modes)).toBe(true); - expect(modes.length).toBe(4); + expect(modes.length).toBe(5); }); it('should include all valid modes', () => { @@ -409,6 +409,7 @@ describe('enhancement.ts', () => { expect(modes).toContain('technical'); expect(modes).toContain('simplify'); expect(modes).toContain('acceptance'); + expect(modes).toContain('ux-reviewer'); }); it('should return modes in consistent order', () => { From edcc4e789baa0075d18fa02450dd53601626d53a Mon Sep 17 00:00:00 2001 From: Stefan de Vogelaere Date: Sun, 11 Jan 2026 22:03:59 +0100 Subject: [PATCH 06/14] fix: address CodeRabbitAI review feedback - Replace busy-wait loop in refreshModels with Promise-based approach - Remove duplicate error logging in opencode-models.ts handlers - Fix multi-slash parsing in provider-icon.tsx (only handle exactly one slash) - Use dynamic icon resolution for selected OpenCode model in trigger - Fix misleading comment about merge precedence (static takes precedence) - Add enabledOpencodeModels and opencodeDefaultModel to settings sync - Add clarifying comments about session-only dynamic model settings Co-Authored-By: Claude Opus 4.5 --- .../routes/setup/routes/opencode-models.ts | 2 - apps/ui/src/components/ui/provider-icon.tsx | 106 ++++++++++-------- apps/ui/src/hooks/use-settings-sync.ts | 2 + apps/ui/src/store/app-store.ts | 7 +- 4 files changed, 65 insertions(+), 52 deletions(-) diff --git a/apps/server/src/routes/setup/routes/opencode-models.ts b/apps/server/src/routes/setup/routes/opencode-models.ts index 0091b47c..a3b2b7be 100644 --- a/apps/server/src/routes/setup/routes/opencode-models.ts +++ b/apps/server/src/routes/setup/routes/opencode-models.ts @@ -122,7 +122,6 @@ export function createRefreshOpencodeModelsHandler() { res.json(response); } catch (error) { - logger.error('Refresh OpenCode models failed:', error); logError(error, 'Refresh OpenCode models failed'); res.status(500).json({ success: false, @@ -155,7 +154,6 @@ export function createGetOpencodeProvidersHandler() { res.json(response); } catch (error) { - logger.error('Get OpenCode providers failed:', error); logError(error, 'Get OpenCode providers failed'); res.status(500).json({ success: false, diff --git a/apps/ui/src/components/ui/provider-icon.tsx b/apps/ui/src/components/ui/provider-icon.tsx index 2f0d9693..9deebfa7 100644 --- a/apps/ui/src/components/ui/provider-icon.tsx +++ b/apps/ui/src/components/ui/provider-icon.tsx @@ -444,56 +444,64 @@ function getUnderlyingModelIcon(model?: AgentModel | string): ProviderIconKey { // Check for dynamic OpenCode provider models (provider/model format) // e.g., zai-coding-plan/glm-4.5, github-copilot/gpt-4o, google/gemini-2.5-pro - if (modelStr.includes('/')) { - const modelName = modelStr.split('/')[1] || ''; - // Check model name for known patterns - if (modelName.includes('glm')) { - return 'glm'; + // Only handle strings with exactly one slash (not URLs or paths) + if (!modelStr.includes('://')) { + const slashIndex = modelStr.indexOf('/'); + if (slashIndex !== -1 && slashIndex === modelStr.lastIndexOf('/')) { + const providerName = modelStr.slice(0, slashIndex); + const modelName = modelStr.slice(slashIndex + 1); + + // Skip if either part is empty + if (providerName && modelName) { + // Check model name for known patterns + if (modelName.includes('glm')) { + return 'glm'; + } + if ( + modelName.includes('claude') || + modelName.includes('sonnet') || + modelName.includes('opus') + ) { + return 'anthropic'; + } + if (modelName.includes('gpt') || modelName.includes('o1') || modelName.includes('o3')) { + return 'openai'; + } + if (modelName.includes('gemini')) { + return 'gemini'; + } + if (modelName.includes('grok')) { + return 'grok'; + } + if (modelName.includes('deepseek')) { + return 'deepseek'; + } + if (modelName.includes('llama')) { + return 'meta'; + } + if (modelName.includes('qwen')) { + return 'qwen'; + } + if (modelName.includes('mistral')) { + return 'mistral'; + } + // Check provider name for hints + if (providerName.includes('google')) { + return 'gemini'; + } + if (providerName.includes('anthropic')) { + return 'anthropic'; + } + if (providerName.includes('openai')) { + return 'openai'; + } + if (providerName.includes('xai')) { + return 'grok'; + } + // Default for unknown dynamic models + return 'opencode'; + } } - if ( - modelName.includes('claude') || - modelName.includes('sonnet') || - modelName.includes('opus') - ) { - return 'anthropic'; - } - if (modelName.includes('gpt') || modelName.includes('o1') || modelName.includes('o3')) { - return 'openai'; - } - if (modelName.includes('gemini')) { - return 'gemini'; - } - if (modelName.includes('grok')) { - return 'grok'; - } - if (modelName.includes('deepseek')) { - return 'deepseek'; - } - if (modelName.includes('llama')) { - return 'meta'; - } - if (modelName.includes('qwen')) { - return 'qwen'; - } - if (modelName.includes('mistral')) { - return 'mistral'; - } - // Check provider name for hints - const providerName = modelStr.split('/')[0] || ''; - if (providerName.includes('google')) { - return 'gemini'; - } - if (providerName.includes('anthropic')) { - return 'anthropic'; - } - if (providerName.includes('openai')) { - return 'openai'; - } - if (providerName.includes('xai')) { - return 'grok'; - } - // Default for unknown dynamic models - return 'opencode'; } // Check for Cursor-specific models with underlying providers diff --git a/apps/ui/src/hooks/use-settings-sync.ts b/apps/ui/src/hooks/use-settings-sync.ts index b87fa9d3..e55ec1b6 100644 --- a/apps/ui/src/hooks/use-settings-sync.ts +++ b/apps/ui/src/hooks/use-settings-sync.ts @@ -44,6 +44,8 @@ const SETTINGS_FIELDS_TO_SYNC = [ 'phaseModels', 'enabledCursorModels', 'cursorDefaultModel', + 'enabledOpencodeModels', + 'opencodeDefaultModel', 'autoLoadClaudeMd', 'keyboardShortcuts', 'mcpServers', diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index 197b99ea..2076221b 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -584,10 +584,13 @@ export interface AppState { codexEnableImages: boolean; // Enable image processing // OpenCode CLI Settings (global) + // Static OpenCode settings are persisted via SETTINGS_FIELDS_TO_SYNC enabledOpencodeModels: OpencodeModelId[]; // Which static OpenCode models are available opencodeDefaultModel: OpencodeModelId; // Default OpenCode model selection + // Dynamic models are session-only (not persisted) because they're discovered at runtime + // from `opencode models` CLI and depend on current provider authentication state dynamicOpencodeModels: ModelDefinition[]; // Dynamically discovered models from OpenCode CLI - enabledDynamicModelIds: string[]; // Which dynamic models are enabled (model IDs) + enabledDynamicModelIds: string[]; // Which dynamic models are enabled (session-only) cachedOpencodeProviders: Array<{ id: string; name: string; @@ -2036,6 +2039,8 @@ export const useAppStore = create()((set, get) => ({ : state.enabledOpencodeModels.filter((m) => m !== model), })), setDynamicOpencodeModels: (models) => { + // Dynamic models are session-only (not persisted to server) because they depend on + // current CLI authentication state and are re-discovered each session // When setting dynamic models, auto-enable all of them if enabledDynamicModelIds is empty const currentEnabled = get().enabledDynamicModelIds; const newModelIds = models.map((m) => m.id); From b8531cf7e821309de64cec19d33a316b3a58974f Mon Sep 17 00:00:00 2001 From: Stefan de Vogelaere Date: Sun, 11 Jan 2026 22:08:28 +0100 Subject: [PATCH 07/14] fix: add OpenCode settings to migration for persistence Add enabledOpencodeModels and opencodeDefaultModel to the settings migration to ensure they are properly persisted like Cursor settings. Co-Authored-By: Claude Opus 4.5 --- apps/ui/src/hooks/use-settings-migration.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/ui/src/hooks/use-settings-migration.ts b/apps/ui/src/hooks/use-settings-migration.ts index 4b61b4a4..2e271d51 100644 --- a/apps/ui/src/hooks/use-settings-migration.ts +++ b/apps/ui/src/hooks/use-settings-migration.ts @@ -152,6 +152,8 @@ export function parseLocalStorageSettings(): Partial | null { phaseModels: state.phaseModels as GlobalSettings['phaseModels'], enabledCursorModels: state.enabledCursorModels as GlobalSettings['enabledCursorModels'], cursorDefaultModel: state.cursorDefaultModel as GlobalSettings['cursorDefaultModel'], + enabledOpencodeModels: state.enabledOpencodeModels as GlobalSettings['enabledOpencodeModels'], + opencodeDefaultModel: state.opencodeDefaultModel as GlobalSettings['opencodeDefaultModel'], autoLoadClaudeMd: state.autoLoadClaudeMd as boolean, keyboardShortcuts: state.keyboardShortcuts as GlobalSettings['keyboardShortcuts'], mcpServers: state.mcpServers as GlobalSettings['mcpServers'], @@ -539,6 +541,8 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void { phaseModels: settings.phaseModels ?? current.phaseModels, enabledCursorModels: settings.enabledCursorModels ?? current.enabledCursorModels, cursorDefaultModel: settings.cursorDefaultModel ?? 'auto', + enabledOpencodeModels: settings.enabledOpencodeModels ?? current.enabledOpencodeModels, + opencodeDefaultModel: settings.opencodeDefaultModel ?? current.opencodeDefaultModel, autoLoadClaudeMd: settings.autoLoadClaudeMd ?? false, skipSandboxWarning: settings.skipSandboxWarning ?? false, keyboardShortcuts: { From 9f936c696816bded1cbe678060abfdeb0aa2f353 Mon Sep 17 00:00:00 2001 From: DhanushSantosh Date: Mon, 12 Jan 2026 20:20:56 +0530 Subject: [PATCH 08/14] fix(opencode): parse api-key provider models --- .../unit/providers/opencode-provider.test.ts | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/apps/server/tests/unit/providers/opencode-provider.test.ts b/apps/server/tests/unit/providers/opencode-provider.test.ts index ad8dfb1e..fc3791e7 100644 --- a/apps/server/tests/unit/providers/opencode-provider.test.ts +++ b/apps/server/tests/unit/providers/opencode-provider.test.ts @@ -3,7 +3,7 @@ import { OpencodeProvider, resetToolUseIdCounter, } from '../../../src/providers/opencode-provider.js'; -import type { ProviderMessage } from '@automaker/types'; +import type { ProviderMessage, ModelDefinition } from '@automaker/types'; import { collectAsyncGenerator } from '../../utils/helpers.js'; import { spawnJSONLProcess, getOpenCodeAuthIndicators } from '@automaker/platform'; @@ -128,6 +128,28 @@ describe('opencode-provider.ts', () => { }); }); + describe('parseModelsOutput', () => { + it('should parse nested provider model IDs', () => { + const output = [ + 'openrouter/anthropic/claude-3.5-sonnet', + 'openai/gpt-4o', + 'amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0', + ].join('\n'); + + const parseModelsOutput = ( + provider as unknown as { parseModelsOutput: (output: string) => ModelDefinition[] } + ).parseModelsOutput; + const models = parseModelsOutput(output); + + expect(models).toHaveLength(3); + const openrouterModel = models.find((model) => model.id.startsWith('openrouter/')); + + expect(openrouterModel).toBeDefined(); + expect(openrouterModel?.provider).toBe('openrouter'); + expect(openrouterModel?.modelString).toBe('openrouter/anthropic/claude-3.5-sonnet'); + }); + }); + describe('supportsFeature', () => { it("should support 'tools' feature", () => { expect(provider.supportsFeature('tools')).toBe(true); From b152f119c5d96c284252e24f428b014209734b0b Mon Sep 17 00:00:00 2001 From: DhanushSantosh Date: Mon, 12 Jan 2026 20:30:29 +0530 Subject: [PATCH 09/14] fix(ui): refresh OpenCode models on new providers --- .../providers/opencode-settings-tab.tsx | 67 ++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) 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 568789ad..60267afd 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,4 +1,4 @@ -import { useState, useCallback, useEffect } from 'react'; +import { useState, useCallback, useEffect, useRef } from 'react'; import { toast } from 'sonner'; import { useAppStore } from '@/store/app-store'; import { OpencodeCliStatus, OpencodeCliStatusSkeleton } from '../cli-status/opencode-cli-status'; @@ -10,6 +10,13 @@ import type { OpencodeModelId } from '@automaker/types'; import type { OpencodeAuthStatus, OpenCodeProviderInfo } from '../cli-status/opencode-cli-status'; const logger = createLogger('OpencodeSettings'); +const OPENCODE_PROVIDER_ID = 'opencode'; +const OPENCODE_BEDROCK_PROVIDER_ID = 'amazon-bedrock'; +const OPENCODE_PROVIDER_SIGNATURE_SEPARATOR = '|'; +const OPENCODE_STATIC_MODEL_PROVIDERS = new Set([ + OPENCODE_PROVIDER_ID, + OPENCODE_BEDROCK_PROVIDER_ID, +]); export function OpencodeSettingsTab() { const { @@ -30,6 +37,7 @@ export function OpencodeSettingsTab() { const [cliStatus, setCliStatus] = useState(null); const [authStatus, setAuthStatus] = useState(null); const [isSaving, setIsSaving] = useState(false); + const providerRefreshSignatureRef = useRef(''); // Phase 1: Load CLI status quickly on mount useEffect(() => { @@ -118,6 +126,63 @@ export function OpencodeSettingsTab() { loadDynamicContent(); }, [cliStatus?.success, cliStatus?.status]); // eslint-disable-line react-hooks/exhaustive-deps + useEffect(() => { + const refreshModelsForNewProviders = async () => { + const api = getElectronAPI(); + const isInstalled = cliStatus?.success && cliStatus?.status === 'installed'; + + if (!isInstalled || !api?.setup?.refreshOpencodeModels) return; + if (isLoadingDynamicModels) return; + + const authenticatedProviders = cachedOpencodeProviders + .filter((provider) => provider.authenticated) + .map((provider) => provider.id) + .filter((providerId) => !OPENCODE_STATIC_MODEL_PROVIDERS.has(providerId)); + + if (authenticatedProviders.length === 0) { + providerRefreshSignatureRef.current = ''; + return; + } + + const dynamicProviderIds = new Set( + dynamicOpencodeModels.map((model) => model.provider).filter(Boolean) + ); + const missingProviders = authenticatedProviders.filter( + (providerId) => !dynamicProviderIds.has(providerId) + ); + + if (missingProviders.length === 0) { + providerRefreshSignatureRef.current = ''; + return; + } + + const signature = [...missingProviders].sort().join(OPENCODE_PROVIDER_SIGNATURE_SEPARATOR); + if (providerRefreshSignatureRef.current === signature) return; + providerRefreshSignatureRef.current = signature; + + setIsLoadingDynamicModels(true); + try { + const modelsResult = await api.setup.refreshOpencodeModels(); + if (modelsResult.success && modelsResult.models) { + setDynamicOpencodeModels(modelsResult.models); + } + } catch (error) { + logger.error('Failed to refresh OpenCode models for new providers:', error); + } finally { + setIsLoadingDynamicModels(false); + } + }; + + refreshModelsForNewProviders(); + }, [ + cachedOpencodeProviders, + dynamicOpencodeModels, + cliStatus?.success, + cliStatus?.status, + isLoadingDynamicModels, + setDynamicOpencodeModels, + ]); + const handleRefreshOpencodeCli = useCallback(async () => { setIsCheckingOpencodeCli(true); setIsLoadingDynamicModels(true); From 0cff4cf510007e38bd988878f6ee21fb61022639 Mon Sep 17 00:00:00 2001 From: DhanushSantosh Date: Mon, 12 Jan 2026 20:34:50 +0530 Subject: [PATCH 10/14] feat(ui): add OpenRouter icon --- apps/ui/src/components/ui/provider-icon.tsx | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/apps/ui/src/components/ui/provider-icon.tsx b/apps/ui/src/components/ui/provider-icon.tsx index 9deebfa7..a62254c7 100644 --- a/apps/ui/src/components/ui/provider-icon.tsx +++ b/apps/ui/src/components/ui/provider-icon.tsx @@ -6,6 +6,7 @@ import { getProviderFromModel } from '@/lib/utils'; const PROVIDER_ICON_KEYS = { anthropic: 'anthropic', openai: 'openai', + openrouter: 'openrouter', cursor: 'cursor', gemini: 'gemini', grok: 'grok', @@ -41,6 +42,12 @@ const PROVIDER_ICON_DEFINITIONS: Record path: 'M60.8734,57.2556v-14.9432c0-1.2586.4722-2.2029,1.5728-2.8314l30.0443-17.3023c4.0899-2.3593,8.9662-3.4599,13.9988-3.4599,18.8759,0,30.8307,14.6289,30.8307,30.2006,0,1.1007,0,2.3593-.158,3.6178l-31.1446-18.2467c-1.8872-1.1006-3.7754-1.1006-5.6629,0l-39.4812,22.9651ZM131.0276,115.4561v-35.7074c0-2.2028-.9446-3.7756-2.8318-4.8763l-39.481-22.9651,12.8982-7.3934c1.1007-.6285,2.0453-.6285,3.1458,0l30.0441,17.3024c8.6523,5.0341,14.4708,15.7296,14.4708,26.1107,0,11.9539-7.0769,22.965-18.2461,27.527v.0021ZM51.593,83.9964l-12.8982-7.5497c-1.1007-.6285-1.5728-1.5728-1.5728-2.8314v-34.6048c0-16.8303,12.8982-29.5722,30.3585-29.5722,6.607,0,12.7403,2.2029,17.9324,6.1349l-30.987,17.9324c-1.8871,1.1007-2.8314,2.6735-2.8314,4.8764v45.6159l-.0014-.0015ZM79.3562,100.0403l-18.4829-10.3811v-22.0209l18.4829-10.3811,18.4812,10.3811v22.0209l-18.4812,10.3811ZM91.2319,147.8591c-6.607,0-12.7403-2.2031-17.9324-6.1344l30.9866-17.9333c1.8872-1.1005,2.8318-2.6728,2.8318-4.8759v-45.616l13.0564,7.5498c1.1005.6285,1.5723,1.5728,1.5723,2.8314v34.6051c0,16.8297-13.0564,29.5723-30.5147,29.5723v.001ZM53.9522,112.7822l-30.0443-17.3024c-8.652-5.0343-14.471-15.7296-14.471-26.1107,0-12.1119,7.2356-22.9652,18.403-27.5272v35.8634c0,2.2028.9443,3.7756,2.8314,4.8763l39.3248,22.8068-12.8982,7.3938c-1.1007.6287-2.045.6287-3.1456,0ZM52.2229,138.5791c-17.7745,0-30.8306-13.3713-30.8306-29.8871,0-1.2585.1578-2.5169.3143-3.7754l30.987,17.9323c1.8871,1.1005,3.7757,1.1005,5.6628,0l39.4811-22.807v14.9435c0,1.2585-.4721,2.2021-1.5728,2.8308l-30.0443,17.3025c-4.0898,2.359-8.9662,3.4605-13.9989,3.4605h.0014ZM91.2319,157.296c19.0327,0,34.9188-13.5272,38.5383-31.4594,17.6164-4.562,28.9425-21.0779,28.9425-37.908,0-11.0112-4.719-21.7066-13.2133-29.4143.7867-3.3035,1.2595-6.607,1.2595-9.909,0-22.4929-18.2471-39.3247-39.3251-39.3247-4.2461,0-8.3363.6285-12.4262,2.045-7.0792-6.9213-16.8318-11.3254-27.5271-11.3254-19.0331,0-34.9191,13.5268-38.5384,31.4591C11.3255,36.0212,0,52.5373,0,69.3675c0,11.0112,4.7184,21.7065,13.2125,29.4142-.7865,3.3035-1.2586,6.6067-1.2586,9.9092,0,22.4923,18.2466,39.3241,39.3248,39.3241,4.2462,0,8.3362-.6277,12.426-2.0441,7.0776,6.921,16.8302,11.3251,27.5271,11.3251Z', fill: '#74aa9c', }, + openrouter: { + viewBox: '0 0 24 24', + // OpenRouter logo from Simple Icons + path: 'M16.778 1.844v1.919q-.569-.026-1.138-.032-.708-.008-1.415.037c-1.93.126-4.023.728-6.149 2.237-2.911 2.066-2.731 1.95-4.14 2.75-.396.223-1.342.574-2.185.798-.841.225-1.753.333-1.751.333v4.229s.768.108 1.61.333c.842.224 1.789.575 2.185.799 1.41.798 1.228.683 4.14 2.75 2.126 1.509 4.22 2.11 6.148 2.236.88.058 1.716.041 2.555.005v1.918l7.222-4.168-7.222-4.17v2.176c-.86.038-1.611.065-2.278.021-1.364-.09-2.417-.357-3.979-1.465-2.244-1.593-2.866-2.027-3.68-2.508.889-.518 1.449-.906 3.822-2.59 1.56-1.109 2.614-1.377 3.978-1.466.667-.044 1.418-.017 2.278.02v2.176L24 6.014Z', + fill: '#94A3B8', + }, cursor: { viewBox: '0 0 512 512', // Official Cursor logo - hexagonal shape with triangular wedge @@ -151,6 +158,10 @@ export function OpenAIIcon(props: Omit) { return ; } +export function OpenRouterIcon(props: Omit) { + return ; +} + export function CursorIcon(props: Omit) { return ; } @@ -395,6 +406,11 @@ function getUnderlyingModelIcon(model?: AgentModel | string): ProviderIconKey { const modelStr = typeof model === 'string' ? model.toLowerCase() : model; + // Check for Amazon Bedrock models first (amazon-bedrock/...) + if (modelStr.startsWith('openrouter/')) { + return 'openrouter'; + } + // Check for Amazon Bedrock models first (amazon-bedrock/...) if (modelStr.startsWith('amazon-bedrock/')) { // Bedrock-hosted models - detect the specific provider @@ -495,6 +511,9 @@ function getUnderlyingModelIcon(model?: AgentModel | string): ProviderIconKey { if (providerName.includes('openai')) { return 'openai'; } + if (providerName.includes('openrouter')) { + return 'openrouter'; + } if (providerName.includes('xai')) { return 'grok'; } @@ -537,6 +556,7 @@ export function getProviderIconForModel( const iconMap: Record> = { anthropic: AnthropicIcon, openai: OpenAIIcon, + openrouter: OpenRouterIcon, cursor: CursorIcon, gemini: GeminiIcon, grok: GrokIcon, From 6184440441ddbd7d76d242cfad5127fda7c04c64 Mon Sep 17 00:00:00 2001 From: DhanushSantosh Date: Mon, 12 Jan 2026 20:46:53 +0530 Subject: [PATCH 11/14] fix(ui): tie dynamic models to connected providers --- .../views/settings-view/providers/opencode-settings-tab.tsx | 1 + 1 file changed, 1 insertion(+) 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 60267afd..97adc645 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 @@ -310,6 +310,7 @@ export function OpencodeSettingsTab() { isSaving={isSaving} onDefaultModelChange={handleDefaultModelChange} onModelToggle={handleModelToggle} + providers={cachedOpencodeProviders as OpenCodeProviderInfo[]} dynamicModels={dynamicOpencodeModels} enabledDynamicModelIds={enabledDynamicModelIds} onDynamicModelToggle={handleDynamicModelToggle} From 9ce3cfee7d5e133541fd6c5c41575f0c42ab27dc Mon Sep 17 00:00:00 2001 From: DhanushSantosh Date: Mon, 12 Jan 2026 22:47:25 +0530 Subject: [PATCH 12/14] feat(opencode): drop bedrock defaults --- .../unit/providers/opencode-provider.test.ts | 73 ++---- .../providers/opencode-settings-tab.tsx | 6 +- .../setup-view/steps/opencode-setup-step.tsx | 4 +- .../setup-view/steps/providers-setup-step.tsx | 2 +- apps/ui/src/hooks/use-settings-migration.ts | 25 +- apps/ui/src/store/app-store.ts | 22 +- libs/types/src/opencode-models.ts | 226 +----------------- 7 files changed, 69 insertions(+), 289 deletions(-) diff --git a/apps/server/tests/unit/providers/opencode-provider.test.ts b/apps/server/tests/unit/providers/opencode-provider.test.ts index fc3791e7..aeecd83b 100644 --- a/apps/server/tests/unit/providers/opencode-provider.test.ts +++ b/apps/server/tests/unit/providers/opencode-provider.test.ts @@ -51,63 +51,38 @@ describe('opencode-provider.ts', () => { }); describe('getAvailableModels', () => { - it('should return 10 models', () => { + it('should return 5 models', () => { const models = provider.getAvailableModels(); - expect(models).toHaveLength(10); + expect(models).toHaveLength(5); }); - it('should include Claude Sonnet 4.5 (Bedrock) as default', () => { - const models = provider.getAvailableModels(); - const sonnet = models.find( - (m) => m.id === 'amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0' - ); - - expect(sonnet).toBeDefined(); - expect(sonnet?.name).toBe('Claude Sonnet 4.5 (Bedrock)'); - expect(sonnet?.provider).toBe('opencode'); - expect(sonnet?.default).toBe(true); - expect(sonnet?.modelString).toBe('amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0'); - }); - - it('should include Claude Opus 4.5 (Bedrock)', () => { - const models = provider.getAvailableModels(); - const opus = models.find( - (m) => m.id === 'amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0' - ); - - expect(opus).toBeDefined(); - expect(opus?.name).toBe('Claude Opus 4.5 (Bedrock)'); - expect(opus?.modelString).toBe('amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0'); - }); - - it('should include Claude Haiku 4.5 (Bedrock)', () => { - const models = provider.getAvailableModels(); - const haiku = models.find( - (m) => m.id === 'amazon-bedrock/anthropic.claude-haiku-4-5-20251001-v1:0' - ); - - expect(haiku).toBeDefined(); - expect(haiku?.name).toBe('Claude Haiku 4.5 (Bedrock)'); - expect(haiku?.tier).toBe('standard'); - }); - - it('should include free tier Big Pickle model', () => { + it('should include Big Pickle as default', () => { const models = provider.getAvailableModels(); const bigPickle = models.find((m) => m.id === 'opencode/big-pickle'); expect(bigPickle).toBeDefined(); expect(bigPickle?.name).toBe('Big Pickle (Free)'); + expect(bigPickle?.provider).toBe('opencode'); + expect(bigPickle?.default).toBe(true); expect(bigPickle?.modelString).toBe('opencode/big-pickle'); - expect(bigPickle?.tier).toBe('basic'); }); - it('should include DeepSeek R1 (Bedrock)', () => { + it('should include free tier GLM model', () => { const models = provider.getAvailableModels(); - const deepseek = models.find((m) => m.id === 'amazon-bedrock/deepseek.r1-v1:0'); + const glm = models.find((m) => m.id === 'opencode/glm-4.7-free'); - expect(deepseek).toBeDefined(); - expect(deepseek?.name).toBe('DeepSeek R1 (Bedrock)'); - expect(deepseek?.tier).toBe('premium'); + expect(glm).toBeDefined(); + expect(glm?.name).toBe('GLM 4.7 Free'); + expect(glm?.tier).toBe('basic'); + }); + + it('should include free tier MiniMax model', () => { + const models = provider.getAvailableModels(); + const minimax = models.find((m) => m.id === 'opencode/minimax-m2.1-free'); + + expect(minimax).toBeDefined(); + expect(minimax?.name).toBe('MiniMax M2.1 Free'); + expect(minimax?.tier).toBe('basic'); }); it('should have all models support tools', () => { @@ -130,18 +105,14 @@ describe('opencode-provider.ts', () => { describe('parseModelsOutput', () => { it('should parse nested provider model IDs', () => { - const output = [ - 'openrouter/anthropic/claude-3.5-sonnet', - 'openai/gpt-4o', - 'amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0', - ].join('\n'); + const output = ['openrouter/anthropic/claude-3.5-sonnet', 'openai/gpt-4o'].join('\n'); const parseModelsOutput = ( provider as unknown as { parseModelsOutput: (output: string) => ModelDefinition[] } ).parseModelsOutput; const models = parseModelsOutput(output); - expect(models).toHaveLength(3); + expect(models).toHaveLength(2); const openrouterModel = models.find((model) => model.id.startsWith('openrouter/')); expect(openrouterModel).toBeDefined(); @@ -1265,7 +1236,7 @@ describe('opencode-provider.ts', () => { const defaultModels = models.filter((m) => m.default === true); expect(defaultModels).toHaveLength(1); - expect(defaultModels[0].id).toBe('amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0'); + expect(defaultModels[0].id).toBe('opencode/big-pickle'); }); it('should have valid tier values for all models', () => { 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 97adc645..2bf20d82 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 @@ -11,12 +11,8 @@ import type { OpencodeAuthStatus, OpenCodeProviderInfo } from '../cli-status/ope const logger = createLogger('OpencodeSettings'); const OPENCODE_PROVIDER_ID = 'opencode'; -const OPENCODE_BEDROCK_PROVIDER_ID = 'amazon-bedrock'; const OPENCODE_PROVIDER_SIGNATURE_SEPARATOR = '|'; -const OPENCODE_STATIC_MODEL_PROVIDERS = new Set([ - OPENCODE_PROVIDER_ID, - OPENCODE_BEDROCK_PROVIDER_ID, -]); +const OPENCODE_STATIC_MODEL_PROVIDERS = new Set([OPENCODE_PROVIDER_ID]); export function OpencodeSettingsTab() { const { diff --git a/apps/ui/src/components/views/setup-view/steps/opencode-setup-step.tsx b/apps/ui/src/components/views/setup-view/steps/opencode-setup-step.tsx index afb40b6d..5e7e29c0 100644 --- a/apps/ui/src/components/views/setup-view/steps/opencode-setup-step.tsx +++ b/apps/ui/src/components/views/setup-view/steps/opencode-setup-step.tsx @@ -182,7 +182,7 @@ export function OpencodeSetupStep({ onNext, onBack, onSkip }: OpencodeSetupStepP

This step is optional

- Configure OpenCode CLI for access to free tier models and AWS Bedrock models. You + Configure OpenCode CLI for access to free tier models and connected providers. You can skip this and use other providers, or configure it later in Settings.

@@ -241,7 +241,7 @@ export function OpencodeSetupStep({ onNext, onBack, onSkip }: OpencodeSetupStepP

OpenCode CLI not found

- Install the OpenCode CLI to use free tier and AWS Bedrock models. + Install the OpenCode CLI to use free tier models and connected providers.

diff --git a/apps/ui/src/components/views/setup-view/steps/providers-setup-step.tsx b/apps/ui/src/components/views/setup-view/steps/providers-setup-step.tsx index ca2b1759..b9ad3263 100644 --- a/apps/ui/src/components/views/setup-view/steps/providers-setup-step.tsx +++ b/apps/ui/src/components/views/setup-view/steps/providers-setup-step.tsx @@ -1119,7 +1119,7 @@ function OpencodeContent() {

OpenCode CLI not found

- Install the OpenCode CLI for free tier and AWS Bedrock models. + Install the OpenCode CLI for free tier models and connected providers.

diff --git a/apps/ui/src/hooks/use-settings-migration.ts b/apps/ui/src/hooks/use-settings-migration.ts index 2e271d51..ed236de8 100644 --- a/apps/ui/src/hooks/use-settings-migration.ts +++ b/apps/ui/src/hooks/use-settings-migration.ts @@ -28,7 +28,11 @@ import { getHttpApiClient, waitForApiKeyInit } from '@/lib/http-api-client'; import { getItem, setItem } from '@/lib/storage'; import { useAppStore, THEME_STORAGE_KEY } from '@/store/app-store'; import { useSetupStore } from '@/store/setup-store'; -import type { GlobalSettings } from '@automaker/types'; +import { + DEFAULT_OPENCODE_MODEL, + getAllOpencodeModelIds, + type GlobalSettings, +} from '@automaker/types'; const logger = createLogger('SettingsMigration'); @@ -497,6 +501,21 @@ export function useSettingsMigration(): MigrationState { */ export function hydrateStoreFromSettings(settings: GlobalSettings): void { const current = useAppStore.getState(); + const validOpencodeModelIds = new Set(getAllOpencodeModelIds()); + const incomingEnabledOpencodeModels = + settings.enabledOpencodeModels ?? current.enabledOpencodeModels; + const sanitizedOpencodeDefaultModel = validOpencodeModelIds.has( + settings.opencodeDefaultModel ?? current.opencodeDefaultModel + ) + ? (settings.opencodeDefaultModel ?? current.opencodeDefaultModel) + : DEFAULT_OPENCODE_MODEL; + const sanitizedEnabledOpencodeModels = Array.from( + new Set(incomingEnabledOpencodeModels.filter((modelId) => validOpencodeModelIds.has(modelId))) + ); + + if (!sanitizedEnabledOpencodeModels.includes(sanitizedOpencodeDefaultModel)) { + sanitizedEnabledOpencodeModels.push(sanitizedOpencodeDefaultModel); + } // Convert ProjectRef[] to Project[] (minimal data, features will be loaded separately) const projects = (settings.projects ?? []).map((ref) => ({ @@ -541,8 +560,8 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void { phaseModels: settings.phaseModels ?? current.phaseModels, enabledCursorModels: settings.enabledCursorModels ?? current.enabledCursorModels, cursorDefaultModel: settings.cursorDefaultModel ?? 'auto', - enabledOpencodeModels: settings.enabledOpencodeModels ?? current.enabledOpencodeModels, - opencodeDefaultModel: settings.opencodeDefaultModel ?? current.opencodeDefaultModel, + enabledOpencodeModels: sanitizedEnabledOpencodeModels, + opencodeDefaultModel: sanitizedOpencodeDefaultModel, autoLoadClaudeMd: settings.autoLoadClaudeMd ?? false, skipSandboxWarning: settings.skipSandboxWarning ?? false, keyboardShortcuts: { diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index 2076221b..0490738e 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -34,6 +34,8 @@ import { } from '@automaker/types'; const logger = createLogger('AppStore'); +const OPENCODE_BEDROCK_PROVIDER_ID = 'amazon-bedrock'; +const OPENCODE_BEDROCK_MODEL_PREFIX = `${OPENCODE_BEDROCK_PROVIDER_ID}/`; // Re-export types for convenience export type { @@ -1237,7 +1239,7 @@ const initialState: AppState = { codexEnableWebSearch: false, // Default to disabled codexEnableImages: false, // Default to disabled enabledOpencodeModels: getAllOpencodeModelIds(), // All OpenCode models enabled by default - opencodeDefaultModel: DEFAULT_OPENCODE_MODEL, // Default to Claude Sonnet 4.5 + opencodeDefaultModel: DEFAULT_OPENCODE_MODEL, // Default to OpenCode free tier 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 @@ -2042,15 +2044,20 @@ export const useAppStore = create()((set, get) => ({ // Dynamic models are session-only (not persisted to server) because they depend on // current CLI authentication state and are re-discovered each session // When setting dynamic models, auto-enable all of them if enabledDynamicModelIds is empty + const filteredModels = models.filter( + (model) => + model.provider !== OPENCODE_BEDROCK_PROVIDER_ID && + !model.id.startsWith(OPENCODE_BEDROCK_MODEL_PREFIX) + ); const currentEnabled = get().enabledDynamicModelIds; - const newModelIds = models.map((m) => m.id); + const newModelIds = filteredModels.map((m) => m.id); // If no models were previously enabled, enable all new ones if (currentEnabled.length === 0) { - set({ dynamicOpencodeModels: models, enabledDynamicModelIds: newModelIds }); + set({ dynamicOpencodeModels: filteredModels, enabledDynamicModelIds: newModelIds }); } else { // Keep existing enabled state, just update the models list - set({ dynamicOpencodeModels: models }); + set({ dynamicOpencodeModels: filteredModels }); } }, setEnabledDynamicModelIds: (ids) => set({ enabledDynamicModelIds: ids }), @@ -2060,7 +2067,12 @@ export const useAppStore = create()((set, get) => ({ ? [...state.enabledDynamicModelIds, modelId] : state.enabledDynamicModelIds.filter((id) => id !== modelId), })), - setCachedOpencodeProviders: (providers) => set({ cachedOpencodeProviders: providers }), + setCachedOpencodeProviders: (providers) => + set({ + cachedOpencodeProviders: providers.filter( + (provider) => provider.id !== OPENCODE_BEDROCK_PROVIDER_ID + ), + }), // Claude Agent SDK Settings actions setAutoLoadClaudeMd: async (enabled) => { diff --git a/libs/types/src/opencode-models.ts b/libs/types/src/opencode-models.ts index 246f8770..21d5a652 100644 --- a/libs/types/src/opencode-models.ts +++ b/libs/types/src/opencode-models.ts @@ -8,43 +8,12 @@ export type OpencodeModelId = | 'opencode/glm-4.7-free' | 'opencode/gpt-5-nano' | 'opencode/grok-code' - | 'opencode/minimax-m2.1-free' - // Amazon Bedrock - Claude Models - | 'amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0' - | 'amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0' - | 'amazon-bedrock/anthropic.claude-haiku-4-5-20251001-v1:0' - | 'amazon-bedrock/anthropic.claude-sonnet-4-20250514-v1:0' - | 'amazon-bedrock/anthropic.claude-opus-4-20250514-v1:0' - | 'amazon-bedrock/anthropic.claude-3-7-sonnet-20250219-v1:0' - | 'amazon-bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0' - | 'amazon-bedrock/anthropic.claude-3-opus-20240229-v1:0' - // Amazon Bedrock - DeepSeek Models - | 'amazon-bedrock/deepseek.r1-v1:0' - | 'amazon-bedrock/deepseek.v3-v1:0' - // Amazon Bedrock - Amazon Nova Models - | 'amazon-bedrock/amazon.nova-premier-v1:0' - | 'amazon-bedrock/amazon.nova-pro-v1:0' - | 'amazon-bedrock/amazon.nova-lite-v1:0' - // Amazon Bedrock - Meta Llama Models - | 'amazon-bedrock/meta.llama4-maverick-17b-instruct-v1:0' - | 'amazon-bedrock/meta.llama3-3-70b-instruct-v1:0' - // Amazon Bedrock - Mistral Models - | 'amazon-bedrock/mistral.mistral-large-2402-v1:0' - // Amazon Bedrock - Qwen Models - | 'amazon-bedrock/qwen.qwen3-coder-480b-a35b-v1:0' - | 'amazon-bedrock/qwen.qwen3-235b-a22b-2507-v1:0'; + | 'opencode/minimax-m2.1-free'; /** * Provider type for OpenCode models */ -export type OpencodeProvider = - | 'opencode' - | 'amazon-bedrock-anthropic' - | 'amazon-bedrock-deepseek' - | 'amazon-bedrock-amazon' - | 'amazon-bedrock-meta' - | 'amazon-bedrock-mistral' - | 'amazon-bedrock-qwen'; +export type OpencodeProvider = 'opencode'; /** * Friendly aliases mapped to full model IDs @@ -59,36 +28,6 @@ export const OPENCODE_MODEL_MAP: Record = { 'grok-code': 'opencode/grok-code', grok: 'opencode/grok-code', minimax: 'opencode/minimax-m2.1-free', - - // Claude aliases (via Bedrock) - 'claude-sonnet-4.5': 'amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0', - 'sonnet-4.5': 'amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0', - sonnet: 'amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0', - 'claude-opus-4.5': 'amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0', - 'opus-4.5': 'amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0', - opus: 'amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0', - 'claude-haiku-4.5': 'amazon-bedrock/anthropic.claude-haiku-4-5-20251001-v1:0', - 'haiku-4.5': 'amazon-bedrock/anthropic.claude-haiku-4-5-20251001-v1:0', - haiku: 'amazon-bedrock/anthropic.claude-haiku-4-5-20251001-v1:0', - - // DeepSeek aliases - 'deepseek-r1': 'amazon-bedrock/deepseek.r1-v1:0', - r1: 'amazon-bedrock/deepseek.r1-v1:0', - 'deepseek-v3': 'amazon-bedrock/deepseek.v3-v1:0', - - // Nova aliases - 'nova-premier': 'amazon-bedrock/amazon.nova-premier-v1:0', - 'nova-pro': 'amazon-bedrock/amazon.nova-pro-v1:0', - nova: 'amazon-bedrock/amazon.nova-pro-v1:0', - - // Llama aliases - llama4: 'amazon-bedrock/meta.llama4-maverick-17b-instruct-v1:0', - 'llama-4': 'amazon-bedrock/meta.llama4-maverick-17b-instruct-v1:0', - llama3: 'amazon-bedrock/meta.llama3-3-70b-instruct-v1:0', - - // Qwen aliases - qwen: 'amazon-bedrock/qwen.qwen3-coder-480b-a35b-v1:0', - 'qwen-coder': 'amazon-bedrock/qwen.qwen3-coder-480b-a35b-v1:0', } as const; /** @@ -148,162 +87,6 @@ export const OPENCODE_MODELS: OpencodeModelConfig[] = [ provider: 'opencode', tier: 'free', }, - - // Amazon Bedrock - Claude Models - { - id: 'amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0', - label: 'Claude Sonnet 4.5 (Bedrock)', - description: 'Latest Claude Sonnet via AWS Bedrock - fast and intelligent (default)', - supportsVision: true, - provider: 'amazon-bedrock-anthropic', - tier: 'premium', - }, - { - id: 'amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0', - label: 'Claude Opus 4.5 (Bedrock)', - description: 'Most capable Claude model via AWS Bedrock', - supportsVision: true, - provider: 'amazon-bedrock-anthropic', - tier: 'premium', - }, - { - id: 'amazon-bedrock/anthropic.claude-haiku-4-5-20251001-v1:0', - label: 'Claude Haiku 4.5 (Bedrock)', - description: 'Fastest Claude model via AWS Bedrock', - supportsVision: true, - provider: 'amazon-bedrock-anthropic', - tier: 'standard', - }, - { - id: 'amazon-bedrock/anthropic.claude-sonnet-4-20250514-v1:0', - label: 'Claude Sonnet 4 (Bedrock)', - description: 'Claude Sonnet 4 via AWS Bedrock', - supportsVision: true, - provider: 'amazon-bedrock-anthropic', - tier: 'premium', - }, - { - id: 'amazon-bedrock/anthropic.claude-opus-4-20250514-v1:0', - label: 'Claude Opus 4 (Bedrock)', - description: 'Claude Opus 4 via AWS Bedrock', - supportsVision: true, - provider: 'amazon-bedrock-anthropic', - tier: 'premium', - }, - { - id: 'amazon-bedrock/anthropic.claude-3-7-sonnet-20250219-v1:0', - label: 'Claude 3.7 Sonnet (Bedrock)', - description: 'Claude 3.7 Sonnet via AWS Bedrock', - supportsVision: true, - provider: 'amazon-bedrock-anthropic', - tier: 'standard', - }, - { - id: 'amazon-bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0', - label: 'Claude 3.5 Sonnet (Bedrock)', - description: 'Claude 3.5 Sonnet v2 via AWS Bedrock', - supportsVision: true, - provider: 'amazon-bedrock-anthropic', - tier: 'standard', - }, - { - id: 'amazon-bedrock/anthropic.claude-3-opus-20240229-v1:0', - label: 'Claude 3 Opus (Bedrock)', - description: 'Claude 3 Opus via AWS Bedrock', - supportsVision: true, - provider: 'amazon-bedrock-anthropic', - tier: 'premium', - }, - - // Amazon Bedrock - DeepSeek Models - { - id: 'amazon-bedrock/deepseek.r1-v1:0', - label: 'DeepSeek R1 (Bedrock)', - description: 'DeepSeek R1 reasoning model via AWS Bedrock - excellent for coding', - supportsVision: false, - provider: 'amazon-bedrock-deepseek', - tier: 'premium', - }, - { - id: 'amazon-bedrock/deepseek.v3-v1:0', - label: 'DeepSeek V3 (Bedrock)', - description: 'DeepSeek V3 via AWS Bedrock', - supportsVision: false, - provider: 'amazon-bedrock-deepseek', - tier: 'standard', - }, - - // Amazon Bedrock - Amazon Nova Models - { - id: 'amazon-bedrock/amazon.nova-premier-v1:0', - label: 'Amazon Nova Premier (Bedrock)', - description: 'Amazon Nova Premier - most capable Nova model', - supportsVision: true, - provider: 'amazon-bedrock-amazon', - tier: 'premium', - }, - { - id: 'amazon-bedrock/amazon.nova-pro-v1:0', - label: 'Amazon Nova Pro (Bedrock)', - description: 'Amazon Nova Pro - balanced performance', - supportsVision: true, - provider: 'amazon-bedrock-amazon', - tier: 'standard', - }, - { - id: 'amazon-bedrock/amazon.nova-lite-v1:0', - label: 'Amazon Nova Lite (Bedrock)', - description: 'Amazon Nova Lite - fast and efficient', - supportsVision: true, - provider: 'amazon-bedrock-amazon', - tier: 'standard', - }, - - // Amazon Bedrock - Meta Llama Models - { - id: 'amazon-bedrock/meta.llama4-maverick-17b-instruct-v1:0', - label: 'Llama 4 Maverick 17B (Bedrock)', - description: 'Meta Llama 4 Maverick via AWS Bedrock', - supportsVision: false, - provider: 'amazon-bedrock-meta', - tier: 'standard', - }, - { - id: 'amazon-bedrock/meta.llama3-3-70b-instruct-v1:0', - label: 'Llama 3.3 70B (Bedrock)', - description: 'Meta Llama 3.3 70B via AWS Bedrock', - supportsVision: false, - provider: 'amazon-bedrock-meta', - tier: 'standard', - }, - - // Amazon Bedrock - Mistral Models - { - id: 'amazon-bedrock/mistral.mistral-large-2402-v1:0', - label: 'Mistral Large (Bedrock)', - description: 'Mistral Large via AWS Bedrock', - supportsVision: false, - provider: 'amazon-bedrock-mistral', - tier: 'standard', - }, - - // Amazon Bedrock - Qwen Models - { - id: 'amazon-bedrock/qwen.qwen3-coder-480b-a35b-v1:0', - label: 'Qwen3 Coder 480B (Bedrock)', - description: 'Qwen3 Coder 480B via AWS Bedrock - excellent for coding', - supportsVision: false, - provider: 'amazon-bedrock-qwen', - tier: 'premium', - }, - { - id: 'amazon-bedrock/qwen.qwen3-235b-a22b-2507-v1:0', - label: 'Qwen3 235B (Bedrock)', - description: 'Qwen3 235B via AWS Bedrock', - supportsVision: false, - provider: 'amazon-bedrock-qwen', - tier: 'premium', - }, ]; /** @@ -319,10 +102,9 @@ export const OPENCODE_MODEL_CONFIG_MAP: Record Date: Mon, 12 Jan 2026 23:07:05 +0530 Subject: [PATCH 13/14] feat(opencode): persist dynamic model selection --- apps/ui/src/hooks/use-settings-migration.ts | 10 ++++++++++ apps/ui/src/hooks/use-settings-sync.ts | 1 + apps/ui/src/store/app-store.ts | 19 +++++++------------ libs/types/src/settings.ts | 3 +++ 4 files changed, 21 insertions(+), 12 deletions(-) diff --git a/apps/ui/src/hooks/use-settings-migration.ts b/apps/ui/src/hooks/use-settings-migration.ts index ed236de8..bb86c10c 100644 --- a/apps/ui/src/hooks/use-settings-migration.ts +++ b/apps/ui/src/hooks/use-settings-migration.ts @@ -158,6 +158,8 @@ export function parseLocalStorageSettings(): Partial | null { cursorDefaultModel: state.cursorDefaultModel as GlobalSettings['cursorDefaultModel'], enabledOpencodeModels: state.enabledOpencodeModels as GlobalSettings['enabledOpencodeModels'], opencodeDefaultModel: state.opencodeDefaultModel as GlobalSettings['opencodeDefaultModel'], + enabledDynamicModelIds: + state.enabledDynamicModelIds as GlobalSettings['enabledDynamicModelIds'], autoLoadClaudeMd: state.autoLoadClaudeMd as boolean, keyboardShortcuts: state.keyboardShortcuts as GlobalSettings['keyboardShortcuts'], mcpServers: state.mcpServers as GlobalSettings['mcpServers'], @@ -517,6 +519,12 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void { sanitizedEnabledOpencodeModels.push(sanitizedOpencodeDefaultModel); } + const persistedDynamicModelIds = + settings.enabledDynamicModelIds ?? current.enabledDynamicModelIds; + const sanitizedDynamicModelIds = persistedDynamicModelIds.filter( + (modelId) => !modelId.startsWith('amazon-bedrock/') + ); + // Convert ProjectRef[] to Project[] (minimal data, features will be loaded separately) const projects = (settings.projects ?? []).map((ref) => ({ id: ref.id, @@ -562,6 +570,7 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void { cursorDefaultModel: settings.cursorDefaultModel ?? 'auto', enabledOpencodeModels: sanitizedEnabledOpencodeModels, opencodeDefaultModel: sanitizedOpencodeDefaultModel, + enabledDynamicModelIds: sanitizedDynamicModelIds, autoLoadClaudeMd: settings.autoLoadClaudeMd ?? false, skipSandboxWarning: settings.skipSandboxWarning ?? false, keyboardShortcuts: { @@ -615,6 +624,7 @@ function buildSettingsUpdateFromStore(): Record { enhancementModel: state.enhancementModel, validationModel: state.validationModel, phaseModels: state.phaseModels, + enabledDynamicModelIds: state.enabledDynamicModelIds, autoLoadClaudeMd: state.autoLoadClaudeMd, skipSandboxWarning: state.skipSandboxWarning, keyboardShortcuts: state.keyboardShortcuts, diff --git a/apps/ui/src/hooks/use-settings-sync.ts b/apps/ui/src/hooks/use-settings-sync.ts index e55ec1b6..a12daf92 100644 --- a/apps/ui/src/hooks/use-settings-sync.ts +++ b/apps/ui/src/hooks/use-settings-sync.ts @@ -46,6 +46,7 @@ const SETTINGS_FIELDS_TO_SYNC = [ 'cursorDefaultModel', 'enabledOpencodeModels', 'opencodeDefaultModel', + 'enabledDynamicModelIds', 'autoLoadClaudeMd', 'keyboardShortcuts', 'mcpServers', diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index 0490738e..ca415ed6 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -592,7 +592,7 @@ export interface AppState { // Dynamic models are session-only (not persisted) because they're discovered at runtime // from `opencode models` CLI and depend on current provider authentication state dynamicOpencodeModels: ModelDefinition[]; // Dynamically discovered models from OpenCode CLI - enabledDynamicModelIds: string[]; // Which dynamic models are enabled (session-only) + enabledDynamicModelIds: string[]; // Which dynamic models are enabled cachedOpencodeProviders: Array<{ id: string; name: string; @@ -1241,7 +1241,7 @@ const initialState: AppState = { enabledOpencodeModels: getAllOpencodeModelIds(), // All OpenCode models enabled by default opencodeDefaultModel: DEFAULT_OPENCODE_MODEL, // Default to OpenCode free tier dynamicOpencodeModels: [], // Empty until fetched from OpenCode CLI - enabledDynamicModelIds: [], // All dynamic models enabled by default (populated when models are fetched) + enabledDynamicModelIds: [], // Empty until user enables dynamic models 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) @@ -2041,9 +2041,8 @@ export const useAppStore = create()((set, get) => ({ : state.enabledOpencodeModels.filter((m) => m !== model), })), setDynamicOpencodeModels: (models) => { - // Dynamic models are session-only (not persisted to server) because they depend on - // current CLI authentication state and are re-discovered each session - // When setting dynamic models, auto-enable all of them if enabledDynamicModelIds is empty + // Dynamic models depend on CLI authentication state and are re-discovered each session. + // Persist enabled model IDs, but do not auto-enable new models. const filteredModels = models.filter( (model) => model.provider !== OPENCODE_BEDROCK_PROVIDER_ID && @@ -2051,14 +2050,10 @@ export const useAppStore = create()((set, get) => ({ ); const currentEnabled = get().enabledDynamicModelIds; const newModelIds = filteredModels.map((m) => m.id); + const filteredEnabled = currentEnabled.filter((modelId) => newModelIds.includes(modelId)); - // If no models were previously enabled, enable all new ones - if (currentEnabled.length === 0) { - set({ dynamicOpencodeModels: filteredModels, enabledDynamicModelIds: newModelIds }); - } else { - // Keep existing enabled state, just update the models list - set({ dynamicOpencodeModels: filteredModels }); - } + const nextEnabled = currentEnabled.length === 0 ? [] : filteredEnabled; + set({ dynamicOpencodeModels: filteredModels, enabledDynamicModelIds: nextEnabled }); }, setEnabledDynamicModelIds: (ids) => set({ enabledDynamicModelIds: ids }), toggleDynamicModel: (modelId, enabled) => diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index 8ec4ef6c..38402c24 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -401,6 +401,8 @@ export interface GlobalSettings { enabledOpencodeModels?: OpencodeModelId[]; /** Default OpenCode model selection when switching to OpenCode CLI */ opencodeDefaultModel?: OpencodeModelId; + /** Which dynamic OpenCode models are enabled (empty = all discovered) */ + enabledDynamicModelIds?: string[]; // Input Configuration /** User's keyboard shortcut bindings */ @@ -704,6 +706,7 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = { cursorDefaultModel: 'auto', enabledOpencodeModels: getAllOpencodeModelIds(), opencodeDefaultModel: DEFAULT_OPENCODE_MODEL, + enabledDynamicModelIds: [], keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS, projects: [], trashedProjects: [], From 6020219fdace4d7f8a573eed2e39816ebf863a6a Mon Sep 17 00:00:00 2001 From: DhanushSantosh Date: Mon, 12 Jan 2026 23:21:23 +0530 Subject: [PATCH 14/14] fix(opencode): address review feedback --- .../unit/providers/opencode-provider.test.ts | 2 +- apps/ui/src/hooks/use-settings-sync.ts | 30 ++++++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/apps/server/tests/unit/providers/opencode-provider.test.ts b/apps/server/tests/unit/providers/opencode-provider.test.ts index aeecd83b..57e2fc38 100644 --- a/apps/server/tests/unit/providers/opencode-provider.test.ts +++ b/apps/server/tests/unit/providers/opencode-provider.test.ts @@ -109,7 +109,7 @@ describe('opencode-provider.ts', () => { const parseModelsOutput = ( provider as unknown as { parseModelsOutput: (output: string) => ModelDefinition[] } - ).parseModelsOutput; + ).parseModelsOutput.bind(provider); const models = parseModelsOutput(output); expect(models).toHaveLength(2); diff --git a/apps/ui/src/hooks/use-settings-sync.ts b/apps/ui/src/hooks/use-settings-sync.ts index a12daf92..41ef6693 100644 --- a/apps/ui/src/hooks/use-settings-sync.ts +++ b/apps/ui/src/hooks/use-settings-sync.ts @@ -19,7 +19,11 @@ import { useAppStore, type ThemeMode, THEME_STORAGE_KEY } from '@/store/app-stor import { useSetupStore } from '@/store/setup-store'; import { useAuthStore } from '@/store/auth-store'; import { waitForMigrationComplete, resetMigrationState } from './use-settings-migration'; -import type { GlobalSettings } from '@automaker/types'; +import { + DEFAULT_OPENCODE_MODEL, + getAllOpencodeModelIds, + type GlobalSettings, +} from '@automaker/types'; const logger = createLogger('SettingsSync'); @@ -424,6 +428,27 @@ export async function refreshSettingsFromServer(): Promise { const serverSettings = result.settings as unknown as GlobalSettings; const currentAppState = useAppStore.getState(); + const validOpencodeModelIds = new Set(getAllOpencodeModelIds()); + const incomingEnabledOpencodeModels = + serverSettings.enabledOpencodeModels ?? currentAppState.enabledOpencodeModels; + const sanitizedOpencodeDefaultModel = validOpencodeModelIds.has( + serverSettings.opencodeDefaultModel ?? currentAppState.opencodeDefaultModel + ) + ? (serverSettings.opencodeDefaultModel ?? currentAppState.opencodeDefaultModel) + : DEFAULT_OPENCODE_MODEL; + const sanitizedEnabledOpencodeModels = Array.from( + new Set(incomingEnabledOpencodeModels.filter((modelId) => validOpencodeModelIds.has(modelId))) + ); + + if (!sanitizedEnabledOpencodeModels.includes(sanitizedOpencodeDefaultModel)) { + sanitizedEnabledOpencodeModels.push(sanitizedOpencodeDefaultModel); + } + + const persistedDynamicModelIds = + serverSettings.enabledDynamicModelIds ?? currentAppState.enabledDynamicModelIds; + const sanitizedDynamicModelIds = persistedDynamicModelIds.filter( + (modelId) => !modelId.startsWith('amazon-bedrock/') + ); // Save theme to localStorage for fallback when server settings aren't available if (serverSettings.theme) { @@ -447,6 +472,9 @@ export async function refreshSettingsFromServer(): Promise { phaseModels: serverSettings.phaseModels, enabledCursorModels: serverSettings.enabledCursorModels, cursorDefaultModel: serverSettings.cursorDefaultModel, + enabledOpencodeModels: sanitizedEnabledOpencodeModels, + opencodeDefaultModel: sanitizedOpencodeDefaultModel, + enabledDynamicModelIds: sanitizedDynamicModelIds, autoLoadClaudeMd: serverSettings.autoLoadClaudeMd ?? false, keyboardShortcuts: { ...currentAppState.keyboardShortcuts,