| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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'; |
|
|
| |
| const opencodeLogger = createLogger('OpencodeProvider'); |
|
|
| |
| |
| |
|
|
| export interface OpenCodeAuthStatus { |
| authenticated: boolean; |
| method: 'api_key' | 'oauth' | 'none'; |
| hasOAuthToken?: boolean; |
| hasApiKey?: boolean; |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| export interface OpenCodeModelInfo { |
| |
| id: string; |
| |
| provider: string; |
| |
| name: string; |
| |
| displayName?: string; |
| } |
|
|
| |
| |
| |
| export interface OpenCodeProviderInfo { |
| |
| id: string; |
| |
| name: string; |
| |
| authenticated: boolean; |
| |
| authMethod?: 'oauth' | 'api_key'; |
| } |
|
|
| |
| 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._:/-]+$/; |
|
|
| |
| |
| |
|
|
| |
| |
| |
| 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; |
| }; |
| } |
|
|
| |
| |
| |
| |
| interface OpenCodeBaseEvent { |
| |
| type: string; |
| |
| timestamp?: number; |
| |
| sessionID?: string; |
| |
| part?: OpenCodePart; |
| } |
|
|
| |
| |
| |
| export interface OpenCodeTextEvent extends OpenCodeBaseEvent { |
| type: 'text'; |
| part: OpenCodePart & { type: 'text'; text: string }; |
| } |
|
|
| |
| |
| |
| export interface OpenCodeStepStartEvent extends OpenCodeBaseEvent { |
| type: 'step_start'; |
| part: OpenCodePart & { type: 'step-start' }; |
| } |
|
|
| |
| |
| |
| export interface OpenCodeStepFinishEvent extends OpenCodeBaseEvent { |
| type: 'step_finish'; |
| part: OpenCodePart & { type: 'step-finish'; reason?: string }; |
| } |
|
|
| |
| |
| |
| export interface OpenCodeToolCallEvent extends OpenCodeBaseEvent { |
| type: 'tool_call'; |
| part: OpenCodePart & { type: 'tool-call'; name: string; args?: unknown }; |
| } |
|
|
| |
| |
| |
| export interface OpenCodeToolResultEvent extends OpenCodeBaseEvent { |
| type: 'tool_result'; |
| part: OpenCodePart & { type: 'tool-result'; output: string }; |
| } |
|
|
| |
| |
| |
| interface OpenCodeErrorDetails { |
| name?: string; |
| message?: string; |
| data?: { |
| message?: string; |
| statusCode?: number; |
| isRetryable?: boolean; |
| }; |
| } |
|
|
| |
| |
| |
| export interface OpenCodeErrorEvent extends OpenCodeBaseEvent { |
| type: 'error'; |
| part?: OpenCodePart & { error: string }; |
| error?: string | OpenCodeErrorDetails; |
| } |
|
|
| |
| |
| |
| export interface OpenCodeToolErrorEvent extends OpenCodeBaseEvent { |
| type: 'tool_error'; |
| part?: OpenCodePart & { error: string }; |
| } |
|
|
| |
| |
| |
| |
| |
| 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 }; |
| }; |
| }; |
| } |
|
|
| |
| |
| |
| export type OpenCodeStreamEvent = |
| | OpenCodeTextEvent |
| | OpenCodeStepStartEvent |
| | OpenCodeStepFinishEvent |
| | OpenCodeToolCallEvent |
| | OpenCodeToolUseEvent |
| | OpenCodeToolResultEvent |
| | OpenCodeErrorEvent |
| | OpenCodeToolErrorEvent; |
|
|
| |
| |
| |
|
|
| |
| let toolUseIdCounter = 0; |
|
|
| |
| |
| |
| function generateToolUseId(): string { |
| toolUseIdCounter += 1; |
| return `opencode-tool-${toolUseIdCounter}`; |
| } |
|
|
| |
| |
| |
| export function resetToolUseIdCounter(): void { |
| toolUseIdCounter = 0; |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export class OpencodeProvider extends CliProvider { |
| |
| |
| |
|
|
| |
| private cachedModels: ModelDefinition[] | null = null; |
|
|
| |
| private modelsCacheExpiry: number = 0; |
|
|
| |
| private cachedProviders: OpenCodeProviderInfo[] | null = null; |
|
|
| |
| private isRefreshing: boolean = false; |
|
|
| |
| private refreshPromise: Promise<ModelDefinition[]> | null = null; |
|
|
| constructor(config: ProviderConfig = {}) { |
| super(config); |
| } |
|
|
| |
| |
| |
|
|
| 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'), |
| ], |
| }, |
| }; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| buildCliArgs(options: ExecuteOptions): string[] { |
| const args: string[] = ['run']; |
|
|
| |
| args.push('--format', 'json'); |
|
|
| |
| |
| |
| |
| if (options.sdkSessionId) { |
| args.push('--session', options.sdkSessionId); |
| } |
|
|
| |
| |
| |
| if (options.model) { |
| |
| const model = options.model.startsWith('opencode-') |
| ? options.model.slice('opencode-'.length) |
| : options.model; |
|
|
| |
| const cliModel = model.includes('/') ? model : `opencode/${model}`; |
|
|
| args.push('--model', cliModel); |
| } |
|
|
| |
| |
|
|
| return args; |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| private extractPromptText(options: ExecuteOptions): string { |
| if (typeof options.prompt === 'string') { |
| return options.prompt; |
| } |
|
|
| |
| 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'); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| protected buildSubprocessOptions(options: ExecuteOptions, cliArgs: string[]): SubprocessOptions { |
| const subprocessOptions = super.buildSubprocessOptions(options, cliArgs); |
|
|
| |
| |
| subprocessOptions.stdinData = this.extractPromptText(options); |
|
|
| return subprocessOptions; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| private static isSessionNotFoundError(errorText: string): boolean { |
| const cleaned = OpencodeProvider.stripAnsiCodes(errorText).toLowerCase(); |
|
|
| |
| 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; |
| } |
|
|
| |
| |
| |
| |
| if (cleaned.includes('notfounderror') || cleaned.includes('resource not found')) { |
| return cleaned.includes('/session/') || /\bsession\b/.test(cleaned); |
| } |
|
|
| return false; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| private static stripAnsiCodes(text: string): string { |
| return text.replace(/\x1b\[[0-9;]*m/g, ''); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| private static cleanErrorMessage(text: string): string { |
| let cleaned = OpencodeProvider.stripAnsiCodes(text).trim(); |
| |
| |
| |
| cleaned = cleaned.replace(/^Error:\s*/i, '').trim(); |
| return cleaned || text; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> { |
| |
| |
| if (!options.sdkSessionId) { |
| for await (const msg of super.executeQuery(options)) { |
| |
| if (msg.type === 'error' && msg.error && typeof msg.error === 'string') { |
| msg.error = OpencodeProvider.cleanErrorMessage(msg.error); |
| } |
| yield msg; |
| } |
| return; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| const buffered: ProviderMessage[] = []; |
| let sessionError = false; |
| let seenHealthyMessage = 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; |
| } |
|
|
| |
| if (msg.error && typeof msg.error === 'string') { |
| msg.error = OpencodeProvider.cleanErrorMessage(msg.error); |
| } |
| } else { |
| |
| seenHealthyMessage = true; |
| } |
|
|
| if (seenHealthyMessage && buffered.length > 0) { |
| |
| for (const bufferedMsg of buffered) { |
| yield bufferedMsg; |
| } |
| buffered.length = 0; |
| } |
|
|
| if (seenHealthyMessage) { |
| |
| yield msg; |
| } else { |
| |
| buffered.push(msg); |
| } |
| } |
| } catch (error) { |
| |
| 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) { |
| |
| const retryOptions = { ...options, sdkSessionId: undefined }; |
| opencodeLogger.info('Retrying OpenCode query without --session flag...'); |
|
|
| |
| |
| |
| |
| 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 if (buffered.length > 0) { |
| |
| |
| for (const msg of buffered) { |
| yield msg; |
| } |
| } |
| |
| |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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; |
|
|
| |
| 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': { |
| |
| return null; |
| } |
|
|
| case 'step_finish': { |
| const finishEvent = openCodeEvent as OpenCodeStepFinishEvent; |
|
|
| |
| if (finishEvent.part?.error) { |
| return { |
| type: 'error', |
| session_id: finishEvent.sessionID, |
| error: OpencodeProvider.cleanErrorMessage(finishEvent.part.error), |
| }; |
| } |
|
|
| |
| if (finishEvent.part?.reason === 'error') { |
| return { |
| type: 'error', |
| session_id: finishEvent.sessionID, |
| error: OpencodeProvider.cleanErrorMessage('Step execution failed'), |
| }; |
| } |
|
|
| |
| |
| |
| if (finishEvent.part?.reason === 'tool-calls') { |
| return null; |
| } |
|
|
| |
| |
| |
| |
| const SUCCESS_REASONS = new Set(['stop', 'end_turn']); |
| const reason = finishEvent.part?.reason; |
|
|
| if (reason === undefined || SUCCESS_REASONS.has(reason)) { |
| |
| return { |
| type: 'result', |
| subtype: 'success', |
| session_id: finishEvent.sessionID, |
| result: (finishEvent.part as OpenCodePart & { result?: string })?.result, |
| }; |
| } |
|
|
| |
| return { |
| type: 'result', |
| subtype: 'error', |
| session_id: finishEvent.sessionID, |
| error: `Step finished with non-success reason: ${reason}`, |
| result: (finishEvent.part as OpenCodePart & { result?: string })?.result, |
| }; |
| } |
|
|
| case 'tool_error': { |
| const toolErrorEvent = openCodeEvent as OpenCodeBaseEvent; |
|
|
| |
| const errorMessage = OpencodeProvider.cleanErrorMessage( |
| toolErrorEvent.part?.error || 'Tool execution failed' |
| ); |
|
|
| return { |
| type: 'error', |
| session_id: toolErrorEvent.sessionID, |
| error: errorMessage, |
| }; |
| } |
|
|
| |
| |
| |
| case 'tool_use': { |
| const toolUseEvent = openCodeEvent as OpenCodeToolUseEvent; |
| const part = toolUseEvent.part; |
|
|
| |
| 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 (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; |
|
|
| |
| 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; |
|
|
| |
| let errorMessage = 'Unknown error'; |
| if (errorEvent.error) { |
| if (typeof errorEvent.error === 'string') { |
| errorMessage = errorEvent.error; |
| } else { |
| |
| errorMessage = |
| errorEvent.error.data?.message || |
| errorEvent.error.message || |
| errorEvent.error.name || |
| 'Unknown error'; |
| } |
| } else if (errorEvent.part?.error) { |
| errorMessage = errorEvent.part.error; |
| } |
|
|
| |
| |
| |
| |
| |
| errorMessage = OpencodeProvider.cleanErrorMessage(errorMessage); |
|
|
| return { |
| type: 'error', |
| session_id: errorEvent.sessionID, |
| error: errorMessage, |
| }; |
| } |
|
|
| default: { |
| |
| return null; |
| } |
| } |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| getAvailableModels(): ModelDefinition[] { |
| |
| if (this.cachedModels && Date.now() < this.modelsCacheExpiry) { |
| return this.cachedModels; |
| } |
|
|
| |
| if (this.cachedModels) { |
| |
| this.refreshModels().catch((err) => { |
| opencodeLogger.debug(`Background model refresh failed: ${err}`); |
| }); |
| return this.cachedModels; |
| } |
|
|
| |
| return this.getDefaultModels(); |
| } |
|
|
| |
| |
| |
| private getDefaultModels(): ModelDefinition[] { |
| return [ |
| |
| { |
| 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-5-free', |
| name: 'GLM 5 Free', |
| modelString: 'opencode/glm-5-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/kimi-k2.5-free', |
| name: 'Kimi K2.5 Free', |
| modelString: 'opencode/kimi-k2.5-free', |
| provider: 'opencode', |
| description: 'OpenCode free tier Kimi model for coding', |
| supportsTools: true, |
| supportsVision: false, |
| tier: 'basic', |
| }, |
| { |
| id: 'opencode/minimax-m2.5-free', |
| name: 'MiniMax M2.5 Free', |
| modelString: 'opencode/minimax-m2.5-free', |
| provider: 'opencode', |
| description: 'OpenCode free tier MiniMax model', |
| supportsTools: true, |
| supportsVision: false, |
| tier: 'basic', |
| }, |
| ]; |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| async refreshModels(): Promise<ModelDefinition[]> { |
| |
| 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; |
| } |
| } |
|
|
| |
| |
| |
| private async doRefreshModels(): Promise<ModelDefinition[]> { |
| 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 { |
| |
| opencodeLogger.debug('No models returned from CLI, keeping existing cache'); |
| } |
|
|
| return this.cachedModels || this.getDefaultModels(); |
| } catch (error) { |
| opencodeLogger.debug(`Model refresh failed: ${error}`); |
| |
| return this.cachedModels || this.getDefaultModels(); |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| private async fetchModelsFromCli(): Promise<ModelDefinition[]> { |
| 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') { |
| |
| command = process.platform === 'win32' ? 'npx.cmd' : 'npx'; |
| args = ['opencode-ai@latest', 'models']; |
| opencodeLogger.debug(`Executing: ${command} ${args.join(' ')}`); |
| } else if (this.useWsl && this.wslCliPath) { |
| |
| command = 'wsl.exe'; |
| args = this.wslDistribution |
| ? ['-d', this.wslDistribution, this.wslCliPath, 'models'] |
| : [this.wslCliPath, 'models']; |
| opencodeLogger.debug(`Executing: ${command} ${args.join(' ')}`); |
| } else { |
| |
| command = this.cliPath; |
| args = ['models']; |
| opencodeLogger.debug(`Executing: ${command} ${args.join(' ')}`); |
| } |
|
|
| const { stdout } = await execFileAsync(command, args, { |
| encoding: 'utf-8', |
| timeout: 30000, |
| windowsHide: true, |
| |
| 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 []; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| private parseModelsOutput(output: string): ModelDefinition[] { |
| |
| const lines = output.split('\n'); |
| const models: ModelDefinition[] = []; |
|
|
| |
| |
| |
| const modelIdRegex = OPENCODE_MODEL_ID_PATTERN; |
|
|
| for (const line of lines) { |
| |
| const cleanLine = line.replace(/\x1b\[[0-9;]*m/g, '').trim(); |
|
|
| |
| if (!cleanLine) continue; |
|
|
| |
| 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; |
| } |
|
|
| |
| |
| |
| 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, |
| description: `${model.name} via ${this.formatProviderName(model.provider)}`, |
| supportsTools: true, |
| supportsVision: this.modelSupportsVision(model.id), |
| tier, |
| |
| default: model.id.includes('claude-sonnet-4'), |
| }; |
| } |
|
|
| |
| |
| |
| private formatProviderName(provider: string): string { |
| const providerNames: Record<string, string> = { |
| '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, ' ') |
| ); |
| } |
|
|
| |
| |
| |
| private formatModelDisplayName(model: OpenCodeModelInfo): string { |
| |
| |
| let rawName = model.name; |
| if (rawName.includes('/')) { |
| rawName = rawName.split('/').pop()!; |
| } |
|
|
| |
| const colonIdx = rawName.indexOf(':'); |
| let suffix = ''; |
| if (colonIdx !== -1) { |
| const tierPart = rawName.slice(colonIdx + 1); |
| if (/^(free|extended|beta|preview)$/i.test(tierPart)) { |
| suffix = ` (${tierPart.charAt(0).toUpperCase() + tierPart.slice(1)})`; |
| } |
| rawName = rawName.slice(0, colonIdx); |
| } |
|
|
| |
| const formattedName = rawName |
| .split('-') |
| .map((part) => { |
| |
| if (/^\d+$/.test(part)) { |
| return part; |
| } |
| return part.charAt(0).toUpperCase() + part.slice(1); |
| }) |
| .join(' ') |
| .replace(/(\d)\s+(\d)/g, '$1.$2'); |
|
|
| |
| const providerNames: Record<string, string> = { |
| 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}${suffix} (${providerDisplay})`; |
| } |
|
|
| |
| |
| |
| private inferModelTier(modelId: string): 'basic' | 'standard' | 'premium' { |
| const lowerModelId = modelId.toLowerCase(); |
|
|
| |
| if ( |
| lowerModelId.includes('opus') || |
| lowerModelId.includes('gpt-5') || |
| lowerModelId.includes('o3') || |
| lowerModelId.includes('o4') || |
| lowerModelId.includes('gemini-2') || |
| lowerModelId.includes('deepseek-r1') |
| ) { |
| return 'premium'; |
| } |
|
|
| |
| if ( |
| lowerModelId.includes('free') || |
| lowerModelId.includes('nano') || |
| lowerModelId.includes('mini') || |
| lowerModelId.includes('haiku') || |
| lowerModelId.includes('flash') |
| ) { |
| return 'basic'; |
| } |
|
|
| |
| return 'standard'; |
| } |
|
|
| |
| |
| |
| private modelSupportsVision(modelId: string): boolean { |
| const lowerModelId = modelId.toLowerCase(); |
|
|
| |
| const visionModels = ['claude', 'gpt-4', 'gpt-5', 'gemini', 'nova', 'llama-3', 'llama-4']; |
|
|
| return visionModels.some((vm) => lowerModelId.includes(vm)); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| async fetchAuthenticatedProviders(): Promise<OpenCodeProviderInfo[]> { |
| 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') { |
| |
| 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) { |
| |
| command = 'wsl.exe'; |
| args = this.wslDistribution |
| ? ['-d', this.wslDistribution, this.wslCliPath, 'auth', 'list'] |
| : [this.wslCliPath, 'auth', 'list']; |
| opencodeLogger.debug(`Executing: ${command} ${args.join(' ')}`); |
| } else { |
| |
| 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, |
| |
| 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 || []; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| private parseProvidersOutput(output: string): OpenCodeProviderInfo[] { |
| const lines = output.split('\n'); |
| const providers: OpenCodeProviderInfo[] = []; |
|
|
| |
| const providerIdMap: Record<string, string> = { |
| 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) { |
| |
| |
| if (line.includes('●')) { |
| |
| const cleanLine = line |
| .replace(/\x1b\[[0-9;]*m/g, '') |
| .replace(/●/g, '') |
| .trim(); |
|
|
| if (!cleanLine) continue; |
|
|
| |
| |
| const parts = cleanLine.split(/\s+/); |
| if (parts.length >= 2) { |
| const authMethod = parts[parts.length - 1].toLowerCase(); |
| const providerName = parts.slice(0, -1).join(' '); |
|
|
| |
| let authMethodType: 'oauth' | 'api_key' | undefined; |
| if (authMethod === 'oauth') { |
| authMethodType = 'oauth'; |
| } else if (authMethod === 'api' || authMethod === 'api_key') { |
| authMethodType = 'api_key'; |
| } |
|
|
| |
| const providerNameLower = providerName.toLowerCase(); |
| const providerId = |
| providerIdMap[providerNameLower] || providerNameLower.replace(/\s+/g, '-'); |
|
|
| providers.push({ |
| id: providerId, |
| name: providerName, |
| authenticated: true, |
| authMethod: authMethodType, |
| }); |
| } |
| } |
| } |
|
|
| opencodeLogger.debug(`Parsed ${providers.length} providers from auth list`); |
| return providers; |
| } |
|
|
| |
| |
| |
| getCachedProviders(): OpenCodeProviderInfo[] | null { |
| return this.cachedProviders; |
| } |
|
|
| |
| |
| |
| clearModelCache(): void { |
| this.cachedModels = null; |
| this.modelsCacheExpiry = 0; |
| this.cachedProviders = null; |
| opencodeLogger.debug('Model cache cleared'); |
| } |
|
|
| |
| |
| |
| hasCachedModels(): boolean { |
| return this.cachedModels !== null && this.cachedModels.length > 0; |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| supportsFeature(feature: string): boolean { |
| const supportedFeatures = ['tools', 'text', 'vision']; |
| return supportedFeatures.includes(feature); |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| async checkAuth(): Promise<OpenCodeAuthStatus> { |
| const authIndicators = await getOpenCodeAuthIndicators(); |
|
|
| |
| if (authIndicators.hasOAuthToken) { |
| return { |
| authenticated: true, |
| method: 'oauth', |
| hasOAuthToken: true, |
| hasApiKey: authIndicators.hasApiKey, |
| }; |
| } |
|
|
| |
| if (authIndicators.hasApiKey) { |
| return { |
| authenticated: true, |
| method: 'api_key', |
| hasOAuthToken: false, |
| hasApiKey: true, |
| }; |
| } |
|
|
| return { |
| authenticated: false, |
| method: 'none', |
| hasOAuthToken: false, |
| hasApiKey: false, |
| }; |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| async detectInstallation(): Promise<InstallationStatus> { |
| 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, |
| }; |
| } |
| } |
|
|