mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
1199 lines
36 KiB
TypeScript
1199 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 { 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<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
|
|
// 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<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': '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<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,
|
|
};
|
|
}
|
|
}
|