/** * OpenCode Provider - Executes queries using opencode CLI * * Extends CliProvider with OpenCode-specific configuration: * - Event normalization for OpenCode's stream-json format * - Dynamic model discovery via `opencode models` CLI command * - NPX-based Windows execution strategy * - Platform-specific npm global installation paths * * Spawns the opencode CLI with --output-format stream-json for streaming responses. */ 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, ProviderMessage, ModelDefinition, InstallationStatus, ContentBlock, } 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 // ============================================================================= export interface OpenCodeAuthStatus { authenticated: boolean; method: 'api_key' | 'oauth' | 'none'; hasOAuthToken?: boolean; 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 * Format: {"type":"event_type","timestamp":...,"sessionID":"...","part":{...}} */ interface OpenCodeBaseEvent { /** Event type identifier (step_start, text, step_finish, tool_call, etc.) */ type: string; /** Unix timestamp */ timestamp?: number; /** Session identifier */ sessionID?: string; /** Event details */ part?: OpenCodePart; } /** * Text event - Text output from the model */ export interface OpenCodeTextEvent extends OpenCodeBaseEvent { type: 'text'; 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 }; } /** * Tool call event - Request to execute a tool */ export interface OpenCodeToolCallEvent extends OpenCodeBaseEvent { type: 'tool_call'; part: OpenCodePart & { type: 'tool-call'; name: string; args?: unknown }; } /** * Tool result event - Output from a tool execution */ export interface OpenCodeToolResultEvent extends OpenCodeBaseEvent { type: 'tool_result'; 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; }; } /** * 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?: OpenCodePart & { error: string }; } /** * Tool use event - The actual format emitted by OpenCode CLI when a tool is invoked. * Contains the tool name, call ID, and the complete state (input, output, status). * Note: OpenCode CLI emits 'tool_use' (not 'tool_call') as the event type. */ export interface OpenCodeToolUseEvent extends OpenCodeBaseEvent { type: 'tool_use'; part: OpenCodePart & { type: 'tool'; callID?: string; tool?: string; state?: { status?: string; input?: unknown; output?: string; title?: string; metadata?: unknown; time?: { start: number; end: number }; }; }; } /** * Union type of all OpenCode stream events */ export type OpenCodeStreamEvent = | OpenCodeTextEvent | OpenCodeStepStartEvent | OpenCodeStepFinishEvent | OpenCodeToolCallEvent | OpenCodeToolUseEvent | OpenCodeToolResultEvent | OpenCodeErrorEvent | OpenCodeToolErrorEvent; // ============================================================================= // Tool Use ID Generation // ============================================================================= /** Counter for generating unique tool use IDs when call_id is not provided */ let toolUseIdCounter = 0; /** * Generate a unique tool use ID for tool calls without explicit IDs */ function generateToolUseId(): string { toolUseIdCounter += 1; return `opencode-tool-${toolUseIdCounter}`; } /** * Reset the tool use ID counter (useful for testing) */ export function resetToolUseIdCounter(): void { toolUseIdCounter = 0; } // ============================================================================= // Provider Implementation // ============================================================================= /** * OpencodeProvider - Integrates opencode CLI as an AI provider * * 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); } // ========================================================================== // CliProvider Abstract Method Implementations // ========================================================================== getName(): string { return 'opencode'; } getCliName(): string { return 'opencode'; } getSpawnConfig(): CliSpawnConfig { return { windowsStrategy: 'npx', npxPackage: 'opencode-ai@latest', commonPaths: { linux: [ path.join(os.homedir(), '.opencode/bin/opencode'), path.join(os.homedir(), '.npm-global/bin/opencode'), '/usr/local/bin/opencode', '/usr/bin/opencode', path.join(os.homedir(), '.local/bin/opencode'), ], darwin: [ path.join(os.homedir(), '.opencode/bin/opencode'), path.join(os.homedir(), '.npm-global/bin/opencode'), '/usr/local/bin/opencode', '/opt/homebrew/bin/opencode', path.join(os.homedir(), '.local/bin/opencode'), ], win32: [ path.join(os.homedir(), '.opencode', 'bin', 'opencode.exe'), path.join(os.homedir(), 'AppData', 'Roaming', 'npm', 'opencode.cmd'), path.join(os.homedir(), 'AppData', 'Roaming', 'npm', 'opencode'), path.join(process.env.APPDATA || '', 'npm', 'opencode.cmd'), ], }, }; } /** * Build CLI arguments for the `opencode run` command * * Arguments built: * - 'run' subcommand for executing queries * - '--format', 'json' for JSONL streaming output * - '--model', '' for model selection (if specified) * - '--session', '' for continuing an existing session (if sdkSessionId is set) * * 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 */ buildCliArgs(options: ExecuteOptions): string[] { const args: string[] = ['run']; // Add JSON output format for JSONL parsing (not 'stream-json') args.push('--format', 'json'); // Handle session resumption for conversation continuity. // The opencode CLI supports `--session ` to continue an existing session. // The sdkSessionId is captured from the sessionID field in previous stream events // and persisted by AgentService for use in follow-up messages. if (options.sdkSessionId) { args.push('--session', options.sdkSessionId); } // Handle model selection // Convert canonical prefix format (opencode-xxx) to CLI slash format (opencode/xxx) // OpenCode CLI expects provider/model format (e.g., 'opencode/big-model') if (options.model) { // Strip opencode- prefix if present, then ensure slash format const model = options.model.startsWith('opencode-') ? options.model.slice('opencode-'.length) : options.model; // If model has slash, it's already provider/model format; otherwise prepend opencode/ const cliModel = model.includes('/') ? model : `opencode/${model}`; args.push('--model', cliModel); } // Note: OpenCode reads from stdin automatically when input is piped // No '-' argument needed return args; } // ========================================================================== // Prompt Handling // ========================================================================== /** * Extract prompt text from ExecuteOptions for passing via stdin * * Handles both string prompts and array-based prompts with content blocks. * For array prompts with images, extracts only text content (images would * need separate handling via file paths if OpenCode supports them). * * @param options - Execution options containing the prompt * @returns Plain text prompt string */ private extractPromptText(options: ExecuteOptions): string { if (typeof options.prompt === 'string') { return options.prompt; } // Array-based prompt - extract text content if (Array.isArray(options.prompt)) { return options.prompt .filter((block) => block.type === 'text' && block.text) .map((block) => block.text) .join('\n'); } throw new Error('Invalid prompt format: expected string or content block array'); } /** * Build subprocess options with stdin data for prompt * * Extends the base class method to add stdinData containing the prompt. * This allows passing prompts via stdin instead of CLI arguments, * avoiding shell escaping issues with special characters. * * @param options - Execution options * @param cliArgs - CLI arguments from buildCliArgs * @returns SubprocessOptions with stdinData set */ protected buildSubprocessOptions(options: ExecuteOptions, cliArgs: string[]): SubprocessOptions { const subprocessOptions = super.buildSubprocessOptions(options, cliArgs); // Pass prompt via stdin to avoid shell interpretation of special characters // like $(), backticks, quotes, etc. that may appear in prompts or file content subprocessOptions.stdinData = this.extractPromptText(options); return subprocessOptions; } /** * Check if an error message indicates a session-not-found condition. * * Centralizes the pattern matching for session errors to avoid duplication. * Strips ANSI escape codes first since opencode CLI uses colored stderr output * (e.g. "\x1b[91m\x1b[1mError: \x1b[0mSession not found"). * * IMPORTANT: Patterns must be specific enough to avoid false positives. * Generic patterns like "notfounderror" or "resource not found" match * non-session errors (e.g. "ProviderModelNotFoundError") which would * trigger unnecessary retries that fail identically, producing confusing * error messages like "OpenCode session could not be created". * * @param errorText - Raw error text (may contain ANSI codes) * @returns true if the error indicates the session was not found */ private static isSessionNotFoundError(errorText: string): boolean { const cleaned = OpencodeProvider.stripAnsiCodes(errorText).toLowerCase(); // Explicit session-related phrases — high confidence if ( cleaned.includes('session not found') || cleaned.includes('session does not exist') || cleaned.includes('invalid session') || cleaned.includes('session expired') || cleaned.includes('no such session') ) { return true; } // Generic "NotFoundError" / "resource not found" are only session errors // when the message also references a session path or session ID. // Without this guard, errors like "ProviderModelNotFoundError" or // "Resource not found: /path/to/config.json" would false-positive. if (cleaned.includes('notfounderror') || cleaned.includes('resource not found')) { return cleaned.includes('/session/') || cleaned.includes('session'); } return false; } /** * Strip ANSI escape codes from a string. * * The OpenCode CLI uses colored stderr output (e.g. "\x1b[91m\x1b[1mError: \x1b[0m"). * These escape codes render as garbled text like "[91m[1mError: [0m" in the UI * when passed through as-is. This utility removes them so error messages are * clean and human-readable. */ private static stripAnsiCodes(text: string): string { return text.replace(/\x1b\[[0-9;]*m/g, ''); } /** * Clean a CLI error message for display. * * Strips ANSI escape codes AND removes the redundant "Error: " prefix that * the OpenCode CLI prepends to error messages in its colored stderr output * (e.g. "\x1b[91m\x1b[1mError: \x1b[0mSession not found" → "Session not found"). * * Without this, consumers that wrap the message in their own "Error: " prefix * (like AgentService or AgentExecutor) produce garbled double-prefixed output: * "Error: Error: Session not found". */ private static cleanErrorMessage(text: string): string { let cleaned = OpencodeProvider.stripAnsiCodes(text).trim(); // Remove leading "Error: " prefix (case-insensitive) if present. // The CLI formats errors as: \x1b[91m\x1b[1mError: \x1b[0m // After ANSI stripping this becomes: "Error: " cleaned = cleaned.replace(/^Error:\s*/i, '').trim(); return cleaned || text; } /** * Execute a query with automatic session resumption fallback. * * When a sdkSessionId is provided, the CLI receives `--session `. * If the session no longer exists on disk the CLI will fail with a * "NotFoundError" / "Resource not found" / "Session not found" error. * * The opencode CLI writes this to **stderr** and exits non-zero. * `spawnJSONLProcess` collects stderr and **yields** it as * `{ type: 'error', error: }` — it is NOT thrown. * After `normalizeEvent`, the error becomes a yielded `ProviderMessage` * with `type: 'error'`. A simple try/catch therefore cannot intercept it. * * This override iterates the parent stream, intercepts yielded error * messages that match the session-not-found pattern, and retries the * entire query WITHOUT the `--session` flag so a fresh session is started. * * Session-not-found retry is ONLY attempted when `sdkSessionId` is set. * Without the `--session` flag the CLI always creates a fresh session, so * retrying without it would be identical to the first attempt and would * fail the same way — producing a confusing "session could not be created" * message for what is actually a different error (model not found, auth * failure, etc.). * * All error messages (session or not) are cleaned of ANSI codes and the * CLI's redundant "Error: " prefix before being yielded to consumers. * * After a successful retry, the consumer (AgentService) will receive a new * session_id from the fresh stream events, which it persists to metadata — * replacing the stale sdkSessionId and preventing repeated failures. */ async *executeQuery(options: ExecuteOptions): AsyncGenerator { // When no sdkSessionId is set, there is nothing to "retry without" — just // stream normally and clean error messages as they pass through. if (!options.sdkSessionId) { for await (const msg of super.executeQuery(options)) { // Clean error messages so consumers don't get ANSI or double "Error:" prefix if (msg.type === 'error' && msg.error && typeof msg.error === 'string') { msg.error = OpencodeProvider.cleanErrorMessage(msg.error); } yield msg; } return; } // sdkSessionId IS set — the CLI will receive `--session `. // If that session no longer exists, intercept the error and retry fresh. const buffered: ProviderMessage[] = []; let sessionError = false; try { for await (const msg of super.executeQuery(options)) { if (msg.type === 'error') { const errorText = msg.error || ''; if (OpencodeProvider.isSessionNotFoundError(errorText)) { sessionError = true; opencodeLogger.info( `OpenCode session error detected (session "${options.sdkSessionId}") ` + `— retrying without --session to start fresh` ); break; // stop consuming the failed stream } // Non-session error — clean and buffer if (msg.error && typeof msg.error === 'string') { msg.error = OpencodeProvider.cleanErrorMessage(msg.error); } } buffered.push(msg); } } catch (error) { // Also handle thrown exceptions (e.g. from mapError in cli-provider) const errMsg = error instanceof Error ? error.message : String(error); if (OpencodeProvider.isSessionNotFoundError(errMsg)) { sessionError = true; opencodeLogger.info( `OpenCode session error detected (thrown, session "${options.sdkSessionId}") ` + `— retrying without --session to start fresh` ); } else { throw error; } } if (sessionError) { // Retry the entire query without the stale session ID. const retryOptions = { ...options, sdkSessionId: undefined }; opencodeLogger.info('Retrying OpenCode query without --session flag...'); // Stream the retry directly to the consumer. // If the retry also fails, it's a genuine error (not session-related) // and should be surfaced as-is rather than masked with a misleading // "session could not be created" message. for await (const retryMsg of super.executeQuery(retryOptions)) { if (retryMsg.type === 'error' && retryMsg.error && typeof retryMsg.error === 'string') { retryMsg.error = OpencodeProvider.cleanErrorMessage(retryMsg.error); } yield retryMsg; } } else { // No session error — flush buffered messages to the consumer for (const msg of buffered) { yield msg; } } } /** * Normalize a raw CLI event to ProviderMessage format * * 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 with reason 'stop'/'end_turn' -> type: 'result', subtype: 'success' * - step_finish with reason 'tool-calls' -> null (intermediate step, not final) * - step_finish with error -> type: 'error' * - tool_use -> type: 'assistant', content with type: 'tool_use' (OpenCode CLI format) * - tool_call -> type: 'assistant', content with type: 'tool_use' (legacy format) * - tool_result -> type: 'assistant', content with type: 'tool_result' * - error -> type: 'error' * * @param event - Raw event from OpenCode CLI JSONL output * @returns Normalized ProviderMessage or null to skip the event */ normalizeEvent(event: unknown): ProviderMessage | null { if (!event || typeof event !== 'object') { return null; } const openCodeEvent = event as OpenCodeStreamEvent; switch (openCodeEvent.type) { case 'text': { const textEvent = openCodeEvent as OpenCodeTextEvent; // Skip empty text if (!textEvent.part?.text) { return null; } const content: ContentBlock[] = [ { type: 'text', text: textEvent.part.text, }, ]; return { type: 'assistant', session_id: textEvent.sessionID, message: { role: 'assistant', content, }, }; } case 'step_start': { // Step start is informational - no message needed return null; } case 'step_finish': { const finishEvent = openCodeEvent as OpenCodeStepFinishEvent; // 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, }; } // 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', }; } // Intermediate step completion (reason: 'tool-calls') — the agent loop // is continuing because the model requested tool calls. Skip these so // consumers don't mistake them for final results. if (finishEvent.part?.reason === 'tool-calls') { return null; } // Final completion (reason: 'stop' or 'end_turn') return { type: 'result', subtype: 'success', session_id: finishEvent.sessionID, result: (finishEvent.part as OpenCodePart & { result?: string })?.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, }; } // OpenCode CLI emits 'tool_use' events (not 'tool_call') when the model invokes a tool. // The event format includes the tool name, call ID, and state with input/output. // Handle both 'tool_use' (actual CLI format) and 'tool_call' (legacy/alternative) for robustness. case 'tool_use': { const toolUseEvent = openCodeEvent as OpenCodeBaseEvent; const part = toolUseEvent.part as OpenCodePart & { callID?: string; tool?: string; state?: { status?: string; input?: unknown; output?: string; }; }; // Generate a tool use ID if not provided const toolUseId = part?.callID || part?.call_id || generateToolUseId(); const toolName = part?.tool || part?.name || 'unknown'; const content: ContentBlock[] = [ { type: 'tool_use', name: toolName, tool_use_id: toolUseId, input: part?.state?.input || part?.args, }, ]; // If the tool has already completed (state.status === 'completed'), also emit the result if (part?.state?.status === 'completed' && part?.state?.output) { content.push({ type: 'tool_result', tool_use_id: toolUseId, content: part.state.output, }); } return { type: 'assistant', session_id: toolUseEvent.sessionID, message: { role: 'assistant', content, }, }; } case 'tool_call': { const toolEvent = openCodeEvent as OpenCodeToolCallEvent; // Generate a tool use ID if not provided const toolUseId = toolEvent.part?.call_id || generateToolUseId(); const content: ContentBlock[] = [ { type: 'tool_use', name: toolEvent.part?.name || 'unknown', tool_use_id: toolUseId, input: toolEvent.part?.args, }, ]; return { type: 'assistant', session_id: toolEvent.sessionID, message: { role: 'assistant', content, }, }; } case 'tool_result': { const resultEvent = openCodeEvent as OpenCodeToolResultEvent; const content: ContentBlock[] = [ { type: 'tool_result', tool_use_id: resultEvent.part?.call_id, content: resultEvent.part?.output || '', }, ]; return { type: 'assistant', session_id: resultEvent.sessionID, message: { role: 'assistant', content, }, }; } 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; } // Clean error messages: strip ANSI escape codes AND the redundant "Error: " // prefix the CLI adds. The OpenCode CLI outputs colored stderr like: // \x1b[91m\x1b[1mError: \x1b[0mSession not found // Without cleaning, consumers that wrap in their own "Error: " prefix // produce "Error: Error: Session not found". errorMessage = OpencodeProvider.cleanErrorMessage(errorMessage); return { type: 'error', session_id: errorEvent.sessionID, error: errorMessage, }; } default: { // Unknown event type - skip it return null; } } } // ========================================================================== // Model Configuration // ========================================================================== /** * Get available models for OpenCode * * 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 { id: 'opencode/big-pickle', name: 'Big Pickle (Free)', modelString: 'opencode/big-pickle', provider: 'opencode', description: 'OpenCode free tier model - great for general coding', 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', name: 'GPT-5 Nano (Free)', modelString: 'opencode/gpt-5-nano', provider: 'opencode', description: 'Fast and lightweight free tier model', supportsTools: true, supportsVision: false, tier: 'basic', }, { id: 'opencode/grok-code', name: 'Grok Code (Free)', modelString: 'opencode/grok-code', provider: 'opencode', description: 'OpenCode free tier Grok model for coding', supportsTools: true, supportsVision: false, tier: 'basic', }, { id: 'opencode/minimax-m2.1-free', name: 'MiniMax M2.1 Free', modelString: 'opencode/minimax-m2.1-free', provider: 'opencode', description: 'OpenCode free tier MiniMax model', supportsTools: true, supportsVision: false, 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 = process.platform === 'win32' ? 'npx.cmd' : '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, // Use shell on Windows for .cmd files shell: process.platform === 'win32' && command.endsWith('.cmd'), }); 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 = process.platform === 'win32' ? 'npx.cmd' : '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, // Use shell on Windows for .cmd files shell: process.platform === 'win32' && command.endsWith('.cmd'), }); 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': 'zai-coding-plan', '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 // ========================================================================== /** * Check if a feature is supported by OpenCode * * Supported features: * - tools: Function calling / tool use * - text: Text generation * - vision: Image understanding */ supportsFeature(feature: string): boolean { const supportedFeatures = ['tools', 'text', 'vision']; return supportedFeatures.includes(feature); } // ========================================================================== // Authentication // ========================================================================== /** * Check authentication status for OpenCode CLI * * Checks for authentication via: * - OAuth token in auth file * - API key in auth file */ async checkAuth(): Promise { const authIndicators = await getOpenCodeAuthIndicators(); // Check for OAuth token if (authIndicators.hasOAuthToken) { return { authenticated: true, method: 'oauth', hasOAuthToken: true, hasApiKey: authIndicators.hasApiKey, }; } // Check for API key if (authIndicators.hasApiKey) { return { authenticated: true, method: 'api_key', hasOAuthToken: false, hasApiKey: true, }; } return { authenticated: false, method: 'none', hasOAuthToken: false, hasApiKey: false, }; } // ========================================================================== // Installation Detection // ========================================================================== /** * Detect OpenCode installation status * * Checks if the opencode CLI is available either through: * - Direct installation (npm global) * - NPX (fallback on Windows) * Also checks authentication status. */ async detectInstallation(): Promise { this.ensureCliDetected(); const installed = await this.isInstalled(); const auth = await this.checkAuth(); return { installed, path: this.cliPath || undefined, method: this.detectedStrategy === 'npx' ? 'npm' : 'cli', authenticated: auth.authenticated, hasApiKey: auth.hasApiKey, hasOAuthToken: auth.hasOAuthToken, }; } }