/** * 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 { 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 // ============================================================================= 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 }; } /** * Union type of all OpenCode stream events */ export type OpenCodeStreamEvent = | OpenCodeTextEvent | OpenCodeStepStartEvent | OpenCodeStepFinishEvent | OpenCodeToolCallEvent | 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 * - '-c', '' for working directory (using opencode's -c flag) * - '--model', '' for model selection (if specified) * * 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 model selection // 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: 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; } /** * 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' -> 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' * - 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', }; } // Successful 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, }; } 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; } 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': '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 // ========================================================================== /** * 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, }; } }