Files
automaker/apps/server/src/providers/opencode-provider.ts
USerik 47a6033b43 fix(opencode-provider): correct z.ai coding plan model mapping (#625)
* fix(opencode-provider): correct z.ai coding plan model mapping

The model mapping for 'z.ai coding plan' was incorrectly pointing to 'z-ai'
instead of 'zai-coding-plan', which would cause model resolution failures
when users selected the z.ai coding plan provider.

This fix ensures the correct model identifier is used for z.ai coding plan,
aligning with the expected model naming convention.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* test: Add unit tests for parseProvidersOutput function

Add comprehensive unit tests for the parseProvidersOutput private method
in OpencodeProvider. This addresses PR feedback requesting test coverage
for the z.ai coding plan mapping fix.

Test coverage (22 tests):
- Critical fix validation: z.ai coding plan vs z.ai distinction
- Provider name mapping: all 12 providers with case-insensitive handling
- Duplicate aliases: copilot, bedrock, lmstudio variants
- Authentication methods: oauth, api_key detection
- ANSI escape sequences: color code removal
- Edge cases: malformed input, whitespace, newlines
- Real-world CLI output: box characters, decorations

All tests passing. Ensures regression protection for provider parsing.

---------

Co-authored-by: devkeruse <devkeruse@gmail.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-20 21:03:38 +01:00

1206 lines
36 KiB
TypeScript

/**
* 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 };
}
/**
* 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<ModelDefinition[]> | 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', '<cwd>' for working directory (using opencode's -c flag)
* - '--model', '<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
// 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;
}
/**
* 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<ModelDefinition[]> {
// 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<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 {
// 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<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') {
// 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<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, ' ')
);
}
/**
* 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<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} (${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<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') {
// 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<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) {
// 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<OpenCodeAuthStatus> {
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<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,
};
}
}