opencode support

This commit is contained in:
webdevcody
2026-01-08 15:30:20 -05:00
parent d1bd131cab
commit 5fbc7dd13e
26 changed files with 3723 additions and 26 deletions

View File

@@ -0,0 +1,605 @@
/**
* OpenCode Provider - Executes queries using opencode CLI
*
* Extends CliProvider with OpenCode-specific configuration:
* - Event normalization for OpenCode's stream-json format
* - Model definitions for anthropic, openai, and google models
* - 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 { CliProvider, type CliSpawnConfig } from './cli-provider.js';
import type {
ProviderConfig,
ExecuteOptions,
ProviderMessage,
ModelDefinition,
InstallationStatus,
ContentBlock,
} from '@automaker/types';
import { stripProviderPrefix } from '@automaker/types';
import { type SubprocessOptions } from '@automaker/platform';
// =============================================================================
// OpenCode Stream Event Types
// =============================================================================
/**
* Base interface for all OpenCode stream events
*/
interface OpenCodeBaseEvent {
/** Event type identifier */
type: string;
/** Optional session identifier */
session_id?: string;
}
/**
* Text delta event - Incremental text output from the model
*/
export interface OpenCodeTextDeltaEvent extends OpenCodeBaseEvent {
type: 'text-delta';
/** The incremental text content */
text: string;
}
/**
* Text end event - Signals completion of text generation
*/
export interface OpenCodeTextEndEvent extends OpenCodeBaseEvent {
type: 'text-end';
}
/**
* Tool call event - Request to execute a tool
*/
export interface OpenCodeToolCallEvent extends OpenCodeBaseEvent {
type: 'tool-call';
/** Unique identifier for this tool call */
call_id?: string;
/** Tool name to invoke */
name: string;
/** Arguments to pass to the tool */
args: unknown;
}
/**
* Tool result event - Output from a tool execution
*/
export interface OpenCodeToolResultEvent extends OpenCodeBaseEvent {
type: 'tool-result';
/** The tool call ID this result corresponds to */
call_id?: string;
/** Output from the tool execution */
output: string;
}
/**
* Tool error event - Tool execution failed
*/
export interface OpenCodeToolErrorEvent extends OpenCodeBaseEvent {
type: 'tool-error';
/** The tool call ID that failed */
call_id?: string;
/** Error message describing the failure */
error: string;
}
/**
* Start step event - Begins an agentic loop iteration
*/
export interface OpenCodeStartStepEvent extends OpenCodeBaseEvent {
type: 'start-step';
/** Step number in the agentic loop */
step?: number;
}
/**
* Finish step event - Completes an agentic loop iteration
*/
export interface OpenCodeFinishStepEvent extends OpenCodeBaseEvent {
type: 'finish-step';
/** Step number that completed */
step?: number;
/** Whether the step completed successfully */
success?: boolean;
/** Optional result data */
result?: string;
/** Optional error if step failed */
error?: string;
}
/**
* Union type of all OpenCode stream events
*/
export type OpenCodeStreamEvent =
| OpenCodeTextDeltaEvent
| OpenCodeTextEndEvent
| OpenCodeToolCallEvent
| OpenCodeToolResultEvent
| OpenCodeToolErrorEvent
| OpenCodeStartStepEvent
| OpenCodeFinishStepEvent;
// =============================================================================
// 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.
*/
export class OpencodeProvider extends CliProvider {
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(), '.npm-global/bin/opencode'),
'/usr/local/bin/opencode',
'/usr/bin/opencode',
path.join(os.homedir(), '.local/bin/opencode'),
],
darwin: [
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(), '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', 'stream-json' for JSONL streaming output
* - '-q' / '--quiet' to suppress spinner and interactive elements
* - '-c', '<cwd>' for working directory
* - '--model', '<model>' for model selection (if specified)
* - '-' as final arg to read prompt from stdin
*
* The prompt is NOT included in CLI args - it's passed via stdin to avoid
* shell escaping issues with special characters in content.
*
* @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 streaming JSON output format for JSONL parsing
args.push('--format', 'stream-json');
// Suppress spinner and interactive elements for non-TTY usage
args.push('-q');
// Set working directory
if (options.cwd) {
args.push('-c', options.cwd);
}
// 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);
}
// Use '-' to indicate reading prompt from stdin
// This avoids shell escaping issues with special characters
args.push('-');
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-delta -> type: 'assistant', content with type: 'text'
* - text-end -> null (informational, no message needed)
* - tool-call -> type: 'assistant', content with type: 'tool_use'
* - tool-result -> type: 'assistant', content with type: 'tool_result'
* - tool-error -> type: 'error'
* - start-step -> null (informational, no message needed)
* - finish-step with success -> type: 'result', subtype: 'success'
* - finish-step with 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-delta': {
const textEvent = openCodeEvent as OpenCodeTextDeltaEvent;
// Skip empty text deltas
if (!textEvent.text) {
return null;
}
const content: ContentBlock[] = [
{
type: 'text',
text: textEvent.text,
},
];
return {
type: 'assistant',
session_id: textEvent.session_id,
message: {
role: 'assistant',
content,
},
};
}
case 'text-end': {
// Text end is informational - no message needed
return null;
}
case 'tool-call': {
const toolEvent = openCodeEvent as OpenCodeToolCallEvent;
// Generate a tool use ID if not provided
const toolUseId = toolEvent.call_id || generateToolUseId();
const content: ContentBlock[] = [
{
type: 'tool_use',
name: toolEvent.name,
tool_use_id: toolUseId,
input: toolEvent.args,
},
];
return {
type: 'assistant',
session_id: toolEvent.session_id,
message: {
role: 'assistant',
content,
},
};
}
case 'tool-result': {
const resultEvent = openCodeEvent as OpenCodeToolResultEvent;
const content: ContentBlock[] = [
{
type: 'tool_result',
tool_use_id: resultEvent.call_id,
content: resultEvent.output,
},
];
return {
type: 'assistant',
session_id: resultEvent.session_id,
message: {
role: 'assistant',
content,
},
};
}
case 'tool-error': {
const errorEvent = openCodeEvent as OpenCodeToolErrorEvent;
return {
type: 'error',
session_id: errorEvent.session_id,
error: errorEvent.error || 'Tool execution failed',
};
}
case 'start-step': {
// Start step is informational - no message needed
return null;
}
case 'finish-step': {
const finishEvent = openCodeEvent as OpenCodeFinishStepEvent;
// Check if the step failed
if (finishEvent.success === false || finishEvent.error) {
return {
type: 'error',
session_id: finishEvent.session_id,
error: finishEvent.error || 'Step execution failed',
};
}
// Successful completion
return {
type: 'result',
subtype: 'success',
session_id: finishEvent.session_id,
result: finishEvent.result,
};
}
default: {
// Unknown event type - skip it
return null;
}
}
}
// ==========================================================================
// Model Configuration
// ==========================================================================
/**
* Get available models for OpenCode
*
* Returns model definitions for supported AI providers:
* - Anthropic Claude models (Sonnet, Opus, Haiku)
* - OpenAI GPT-4o
* - Google Gemini 2.5 Pro
*/
getAvailableModels(): 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',
},
{
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',
},
// Amazon Bedrock - Claude Models
{
id: 'amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0',
name: 'Claude Sonnet 4.5 (Bedrock)',
modelString: 'amazon-bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0',
provider: 'opencode',
description: 'Latest Claude Sonnet via AWS Bedrock - fast and intelligent',
supportsTools: true,
supportsVision: true,
tier: 'premium',
default: true,
},
{
id: 'amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0',
name: 'Claude Opus 4.5 (Bedrock)',
modelString: 'amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0',
provider: 'opencode',
description: 'Most capable Claude model via AWS Bedrock',
supportsTools: true,
supportsVision: true,
tier: 'premium',
},
{
id: 'amazon-bedrock/anthropic.claude-haiku-4-5-20251001-v1:0',
name: 'Claude Haiku 4.5 (Bedrock)',
modelString: 'amazon-bedrock/anthropic.claude-haiku-4-5-20251001-v1:0',
provider: 'opencode',
description: 'Fastest Claude model via AWS Bedrock',
supportsTools: true,
supportsVision: true,
tier: 'standard',
},
// Amazon Bedrock - DeepSeek Models
{
id: 'amazon-bedrock/deepseek.r1-v1:0',
name: 'DeepSeek R1 (Bedrock)',
modelString: 'amazon-bedrock/deepseek.r1-v1:0',
provider: 'opencode',
description: 'DeepSeek R1 reasoning model - excellent for coding',
supportsTools: true,
supportsVision: false,
tier: 'premium',
},
// Amazon Bedrock - Amazon Nova Models
{
id: 'amazon-bedrock/amazon.nova-pro-v1:0',
name: 'Amazon Nova Pro (Bedrock)',
modelString: 'amazon-bedrock/amazon.nova-pro-v1:0',
provider: 'opencode',
description: 'Amazon Nova Pro - balanced performance',
supportsTools: true,
supportsVision: true,
tier: 'standard',
},
// Amazon Bedrock - Meta Llama Models
{
id: 'amazon-bedrock/meta.llama4-maverick-17b-instruct-v1:0',
name: 'Llama 4 Maverick 17B (Bedrock)',
modelString: 'amazon-bedrock/meta.llama4-maverick-17b-instruct-v1:0',
provider: 'opencode',
description: 'Meta Llama 4 Maverick via AWS Bedrock',
supportsTools: true,
supportsVision: false,
tier: 'standard',
},
// Amazon Bedrock - Qwen Models
{
id: 'amazon-bedrock/qwen.qwen3-coder-480b-a35b-v1:0',
name: 'Qwen3 Coder 480B (Bedrock)',
modelString: 'amazon-bedrock/qwen.qwen3-coder-480b-a35b-v1:0',
provider: 'opencode',
description: 'Qwen3 Coder 480B - excellent for coding',
supportsTools: true,
supportsVision: false,
tier: 'premium',
},
];
}
// ==========================================================================
// 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);
}
// ==========================================================================
// Installation Detection
// ==========================================================================
/**
* Detect OpenCode installation status
*
* Checks if the opencode CLI is available either through:
* - Direct installation (npm global)
* - NPX (fallback on Windows)
*/
async detectInstallation(): Promise<InstallationStatus> {
this.ensureCliDetected();
const installed = await this.isInstalled();
return {
installed,
path: this.cliPath || undefined,
method: this.detectedStrategy === 'npx' ? 'npm' : 'cli',
};
}
}