diff --git a/.archon/workflows/test-workflows/e2e-opencode-smoke.yaml b/.archon/workflows/test-workflows/e2e-opencode-smoke.yaml new file mode 100644 index 0000000000..f22cd6a406 --- /dev/null +++ b/.archon/workflows/test-workflows/e2e-opencode-smoke.yaml @@ -0,0 +1,22 @@ +# E2E smoke test — opencode community provider +# Verifies: opencode server starts, event bridge yields assistant chunks, +# session.idle triggers result chunk. +name: e2e-opencode-smoke +description: 'Smoke test for opencode community provider. Verifies prompt response via sendQuery.' +provider: opencode +model: requesty/google/gemini-3-flash-preview + +nodes: + - id: simple + prompt: 'What is 2+2? Reply with just the number, nothing else.' + idle_timeout: 60000 + + - id: assert + bash: | + output="$simple.output" + if [ -z "$output" ]; then + echo "FAIL: simple node returned empty output" + exit 1 + fi + echo "PASS: got response: $output" + depends_on: [simple] diff --git a/bun.lock b/bun.lock index 1944301e01..9fb37297e0 100644 --- a/bun.lock +++ b/bun.lock @@ -133,6 +133,7 @@ "@mariozechner/pi-ai": "^0.67.5", "@mariozechner/pi-coding-agent": "^0.67.5", "@openai/codex-sdk": "^0.125.0", + "@opencode-ai/sdk": "^1.14.0", "@sinclair/typebox": "^0.34.41", }, "devDependencies": { @@ -727,6 +728,8 @@ "@openai/codex-win32-x64": ["@openai/codex@0.125.0-win32-x64", "", { "os": "win32", "cpu": "x64" }, "sha512-ofpOK+OWH5QFuUZ9pTM0d/PcXUXiIP5z5DpRcE9MlucJoyOl4Zy4Nu3NcuHF4YzCkZMQb6x3j0tjDEPHKqNQzw=="], + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.14.28", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-qRFJfD+Zdz3jHHSupW4F6Io1ZFrQ6gCRFlG50O6kEU9xRxrBpK0wGvP+Y5VwwvD/gH9WKMHYinlQpDVI9/lgJQ=="], + "@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="], "@pagefind/darwin-arm64": ["@pagefind/darwin-arm64@1.4.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2vMqkbv3lbx1Awea90gTaBsvpzgRs7MuSgKDxW0m9oV1GPZCZbZBJg/qL83GIUEN2BFlY46dtUZi54pwH+/pTQ=="], diff --git a/packages/providers/package.json b/packages/providers/package.json index 61a9ced635..c7d5d51b27 100644 --- a/packages/providers/package.json +++ b/packages/providers/package.json @@ -14,11 +14,12 @@ "./codex/config": "./src/codex/config.ts", "./codex/binary-resolver": "./src/codex/binary-resolver.ts", "./community/pi": "./src/community/pi/index.ts", + "./community/opencode": "./src/community/opencode/index.ts", "./errors": "./src/errors.ts", "./registry": "./src/registry.ts" }, "scripts": { - "test": "bun test src/claude/provider.test.ts && bun test src/codex/provider.test.ts && bun test src/registry.test.ts && bun test src/codex/binary-guard.test.ts && bun test src/codex/binary-resolver.test.ts && bun test src/codex/binary-resolver-dev.test.ts && bun test src/claude/binary-resolver.test.ts && bun test src/claude/binary-resolver-dev.test.ts && bun test src/community/pi/model-ref.test.ts && bun test src/community/pi/config.test.ts && bun test src/community/pi/event-bridge.test.ts && bun test src/community/pi/options-translator.test.ts && bun test src/community/pi/session-resolver.test.ts && bun test src/community/pi/provider.test.ts && bun test src/community/pi/provider-lazy-load.test.ts", + "test": "bun test src/claude/provider.test.ts && bun test src/codex/provider.test.ts && bun test src/registry.test.ts && bun test src/codex/binary-guard.test.ts && bun test src/codex/binary-resolver.test.ts && bun test src/codex/binary-resolver-dev.test.ts && bun test src/claude/binary-resolver.test.ts && bun test src/claude/binary-resolver-dev.test.ts && bun test src/community/pi/model-ref.test.ts && bun test src/community/pi/config.test.ts && bun test src/community/pi/event-bridge.test.ts && bun test src/community/pi/options-translator.test.ts && bun test src/community/pi/session-resolver.test.ts && bun test src/community/pi/provider.test.ts && bun test src/community/pi/provider-lazy-load.test.ts && bun test src/community/opencode/config.test.ts && bun test src/community/opencode/event-bridge.test.ts && bun test src/community/opencode/provider.test.ts", "type-check": "bun x tsc --noEmit" }, "dependencies": { @@ -27,6 +28,7 @@ "@mariozechner/pi-ai": "^0.67.5", "@mariozechner/pi-coding-agent": "^0.67.5", "@openai/codex-sdk": "^0.125.0", + "@opencode-ai/sdk": "^1.14.0", "@sinclair/typebox": "^0.34.41" }, "devDependencies": { diff --git a/packages/providers/src/community/opencode/capabilities.ts b/packages/providers/src/community/opencode/capabilities.ts new file mode 100644 index 0000000000..d5743c300d --- /dev/null +++ b/packages/providers/src/community/opencode/capabilities.ts @@ -0,0 +1,32 @@ +import type { ProviderCapabilities } from '../../types'; + +/** + * Opencode capabilities — conservative v1 declaration. Flags must reflect + * wired-up behavior; the dag-executor uses them to warn when a workflow node + * specifies a feature the provider silently ignores. + * + * structuredOutput is best-effort (prompt-engineering only — opencode has no + * SDK-level JSON mode). The provider appends a "respond with JSON matching + * this schema" instruction and parses the accumulated assistant text on + * session.idle. Reliable on instruction-following models; parse failures + * surface via the dag-executor's existing dag.structured_output_missing path. + * + * mcp/hooks/skills/agents/toolRestrictions: opencode manages its own tool + * ecosystem independently of Archon's layered tool configuration. These flags + * remain false until a mapping layer is implemented. + */ +export const OPENCODE_CAPABILITIES: ProviderCapabilities = { + sessionResume: true, + mcp: false, + hooks: false, + skills: false, + agents: false, + toolRestrictions: false, + structuredOutput: true, + envInjection: false, + costControl: false, + effortControl: false, + thinkingControl: false, + fallbackModel: false, + sandbox: false, +}; diff --git a/packages/providers/src/community/opencode/config.test.ts b/packages/providers/src/community/opencode/config.test.ts new file mode 100644 index 0000000000..6c0e5abcaf --- /dev/null +++ b/packages/providers/src/community/opencode/config.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, test } from 'bun:test'; + +import { parseOpencodeConfig, parseOpencodeModel } from './config'; + +describe('parseOpencodeConfig', () => { + test('returns empty object for empty input', () => { + expect(parseOpencodeConfig({})).toEqual({}); + }); + + test('parses valid model string', () => { + expect(parseOpencodeConfig({ model: 'ollama/qwen3:8b' })).toEqual({ + model: 'ollama/qwen3:8b', + }); + }); + + test('drops non-string model silently', () => { + expect(parseOpencodeConfig({ model: 123 })).toEqual({}); + expect(parseOpencodeConfig({ model: null })).toEqual({}); + expect(parseOpencodeConfig({ model: [] })).toEqual({}); + }); + + test('parses opencodeBinaryDir', () => { + expect(parseOpencodeConfig({ opencodeBinaryDir: '/usr/local/bin' })).toEqual({ + opencodeBinaryDir: '/usr/local/bin', + }); + }); + + test('drops non-string opencodeBinaryDir silently', () => { + expect(parseOpencodeConfig({ opencodeBinaryDir: 42 })).toEqual({}); + }); + + test('parses model and opencodeBinaryDir together', () => { + expect( + parseOpencodeConfig({ model: 'anthropic/claude-sonnet-4-5', opencodeBinaryDir: '/opt/bin' }) + ).toEqual({ model: 'anthropic/claude-sonnet-4-5', opencodeBinaryDir: '/opt/bin' }); + }); + + test('ignores unknown keys', () => { + expect(parseOpencodeConfig({ model: 'ollama/qwen3:8b', futureField: 'x' })).toEqual({ + model: 'ollama/qwen3:8b', + }); + }); + + test('does not throw on malformed input', () => { + expect(() => parseOpencodeConfig({ model: undefined })).not.toThrow(); + expect(() => parseOpencodeConfig({ model: {} })).not.toThrow(); + }); +}); + +describe('parseOpencodeModel', () => { + test('parses simple providerID/modelID', () => { + expect(parseOpencodeModel('ollama/qwen3:8b')).toEqual({ + providerID: 'ollama', + modelID: 'qwen3:8b', + }); + }); + + test('parses model with extra slashes in modelID', () => { + expect(parseOpencodeModel('openrouter/meta-llama/llama-3')).toEqual({ + providerID: 'openrouter', + modelID: 'meta-llama/llama-3', + }); + }); + + test('parses anthropic model', () => { + expect(parseOpencodeModel('anthropic/claude-sonnet-4-5')).toEqual({ + providerID: 'anthropic', + modelID: 'claude-sonnet-4-5', + }); + }); + + test('returns undefined for missing slash', () => { + expect(parseOpencodeModel('qwen3')).toBeUndefined(); + }); + + test('returns undefined for leading slash', () => { + expect(parseOpencodeModel('/model')).toBeUndefined(); + }); + + test('returns undefined for trailing slash', () => { + expect(parseOpencodeModel('ollama/')).toBeUndefined(); + }); + + test('returns undefined for empty string', () => { + expect(parseOpencodeModel('')).toBeUndefined(); + }); +}); diff --git a/packages/providers/src/community/opencode/config.ts b/packages/providers/src/community/opencode/config.ts new file mode 100644 index 0000000000..79066260f7 --- /dev/null +++ b/packages/providers/src/community/opencode/config.ts @@ -0,0 +1,39 @@ +import type { OpencodeProviderDefaults } from '../../types'; + +export type { OpencodeProviderDefaults }; + +/** + * Parse raw YAML-derived config into typed opencode defaults. + * Defensive: invalid fields are dropped silently (matches parsePiConfig and + * parseCodexConfig — never throws, so broken user config can't prevent + * provider registration or workflow discovery). + */ +export function parseOpencodeConfig(raw: Record): OpencodeProviderDefaults { + const result: OpencodeProviderDefaults = {}; + + if (typeof raw.model === 'string') { + result.model = raw.model; + } + + if (typeof raw.opencodeBinaryDir === 'string') { + result.opencodeBinaryDir = raw.opencodeBinaryDir; + } + + return result; +} + +/** + * Parse an opencode model string into providerID and modelID. + * opencode models use '/' format (e.g. 'ollama/qwen3:8b'). + * Returns undefined when the format is invalid. + */ +export function parseOpencodeModel( + model: string +): { providerID: string; modelID: string } | undefined { + const idx = model.indexOf('/'); + if (idx <= 0 || idx === model.length - 1) return undefined; + return { + providerID: model.slice(0, idx), + modelID: model.slice(idx + 1), + }; +} diff --git a/packages/providers/src/community/opencode/event-bridge.test.ts b/packages/providers/src/community/opencode/event-bridge.test.ts new file mode 100644 index 0000000000..0e22a84d2b --- /dev/null +++ b/packages/providers/src/community/opencode/event-bridge.test.ts @@ -0,0 +1,345 @@ +import { describe, expect, test } from 'bun:test'; + +import { augmentPromptForJsonSchema, bridgeOpencodeEvents } from './event-bridge'; + +const SESSION = 'ses_abc123'; + +async function collect(events: unknown[], sessionId = SESSION, schema?: Record) { + const chunks = []; + async function* gen() { + for (const e of events) yield e; + } + for await (const c of bridgeOpencodeEvents(gen(), sessionId, schema)) { + chunks.push(c); + } + return chunks; +} + +describe('bridgeOpencodeEvents', () => { + test('maps message.part.delta text to assistant chunks', async () => { + const chunks = await collect([ + { + type: 'message.part.delta', + properties: { sessionID: SESSION, field: 'text', delta: 'Hi' }, + }, + { type: 'message.part.delta', properties: { sessionID: SESSION, field: 'text', delta: '!' } }, + { type: 'session.idle', properties: { sessionID: SESSION } }, + ]); + + expect(chunks.filter(c => c.type === 'assistant')).toEqual([ + { type: 'assistant', content: 'Hi' }, + { type: 'assistant', content: '!' }, + ]); + }); + + test('skips empty deltas', async () => { + const chunks = await collect([ + { type: 'message.part.delta', properties: { sessionID: SESSION, field: 'text', delta: '' } }, + { type: 'session.idle', properties: { sessionID: SESSION } }, + ]); + expect(chunks.filter(c => c.type === 'assistant')).toHaveLength(0); + }); + + test('filters events from other sessions', async () => { + const chunks = await collect([ + { + type: 'message.part.delta', + properties: { sessionID: 'ses_OTHER', field: 'text', delta: 'noise' }, + }, + { + type: 'message.part.delta', + properties: { sessionID: SESSION, field: 'text', delta: 'signal' }, + }, + { type: 'session.idle', properties: { sessionID: SESSION } }, + ]); + const text = chunks.filter(c => c.type === 'assistant').map(c => c.content); + expect(text).toEqual(['signal']); + }); + + test('emits thinking chunks from reasoning part snapshots', async () => { + const chunks = await collect([ + { + type: 'message.part.updated', + properties: { + sessionID: SESSION, + part: { + id: 'prt1', + type: 'reasoning', + sessionID: SESSION, + messageID: 'msg1', + text: 'Think', + }, + }, + }, + { + type: 'message.part.updated', + properties: { + sessionID: SESSION, + part: { + id: 'prt1', + type: 'reasoning', + sessionID: SESSION, + messageID: 'msg1', + text: 'Think more', + }, + }, + }, + { type: 'session.idle', properties: { sessionID: SESSION } }, + ]); + + const thinking = chunks.filter(c => c.type === 'thinking'); + expect(thinking).toEqual([ + { type: 'thinking', content: 'Think' }, + { type: 'thinking', content: ' more' }, + ]); + }); + + test('emits tool + tool_result chunks for tool calls', async () => { + const chunks = await collect([ + { + type: 'message.part.updated', + properties: { + sessionID: SESSION, + part: { + id: 'prt-tool', + type: 'tool', + sessionID: SESSION, + messageID: 'msg1', + callID: 'call_xyz', + tool: 'bash', + state: { status: 'running', input: { command: 'ls' } }, + }, + }, + }, + { + type: 'message.part.updated', + properties: { + sessionID: SESSION, + part: { + id: 'prt-tool', + type: 'tool', + sessionID: SESSION, + messageID: 'msg1', + callID: 'call_xyz', + tool: 'bash', + state: { status: 'completed', input: { command: 'ls' }, output: 'file.txt' }, + }, + }, + }, + { type: 'session.idle', properties: { sessionID: SESSION } }, + ]); + + expect(chunks.filter(c => c.type === 'tool')).toEqual([ + { type: 'tool', toolName: 'bash', toolInput: { command: 'ls' }, toolCallId: 'call_xyz' }, + ]); + expect(chunks.filter(c => c.type === 'tool_result')).toEqual([ + { type: 'tool_result', toolName: 'bash', toolOutput: 'file.txt', toolCallId: 'call_xyz' }, + ]); + }); + + test('does not duplicate tool chunks on repeated state updates', async () => { + const chunks = await collect([ + { + type: 'message.part.updated', + properties: { + sessionID: SESSION, + part: { + id: 'prt-tool', + type: 'tool', + sessionID: SESSION, + messageID: 'msg1', + callID: 'call_1', + tool: 'read', + state: { status: 'running', input: { path: '/x' } }, + }, + }, + }, + // Second running update — should not produce another 'tool' chunk + { + type: 'message.part.updated', + properties: { + sessionID: SESSION, + part: { + id: 'prt-tool', + type: 'tool', + sessionID: SESSION, + messageID: 'msg1', + callID: 'call_1', + tool: 'read', + state: { status: 'running', input: { path: '/x' } }, + }, + }, + }, + { type: 'session.idle', properties: { sessionID: SESSION } }, + ]); + expect(chunks.filter(c => c.type === 'tool')).toHaveLength(1); + }); + + test('tool error maps to tool_result with Error: prefix', async () => { + const chunks = await collect([ + { + type: 'message.part.updated', + properties: { + sessionID: SESSION, + part: { + id: 'prt-t', + type: 'tool', + sessionID: SESSION, + messageID: 'msg1', + callID: 'c1', + tool: 'write', + state: { status: 'running', input: {} }, + }, + }, + }, + { + type: 'message.part.updated', + properties: { + sessionID: SESSION, + part: { + id: 'prt-t', + type: 'tool', + sessionID: SESSION, + messageID: 'msg1', + callID: 'c1', + tool: 'write', + state: { status: 'error', input: {}, error: 'permission denied' }, + }, + }, + }, + { type: 'session.idle', properties: { sessionID: SESSION } }, + ]); + const r = chunks.find(c => c.type === 'tool_result'); + expect(r?.toolOutput).toBe('Error: permission denied'); + }); + + test('accumulates tokens and cost from step-finish parts', async () => { + const chunks = await collect([ + { + type: 'message.part.updated', + properties: { + sessionID: SESSION, + part: { + id: 'sf1', + type: 'step-finish', + sessionID: SESSION, + messageID: 'msg1', + cost: 0.01, + tokens: { input: 100, output: 50 }, + }, + }, + }, + { + type: 'message.part.updated', + properties: { + sessionID: SESSION, + part: { + id: 'sf2', + type: 'step-finish', + sessionID: SESSION, + messageID: 'msg1', + cost: 0.02, + tokens: { input: 200, output: 80 }, + }, + }, + }, + { type: 'session.idle', properties: { sessionID: SESSION } }, + ]); + + const result = chunks.find(c => c.type === 'result'); + expect(result?.tokens).toEqual({ input: 300, output: 130, total: 430 }); + expect(result?.cost).toBeCloseTo(0.03); + }); + + test('terminates on session.error with isError result', async () => { + const chunks = await collect([ + { + type: 'session.error', + properties: { sessionID: SESSION, error: { message: 'auth failed' } }, + }, + ]); + const result = chunks.find(c => c.type === 'result'); + expect(result?.isError).toBe(true); + expect(result?.errors).toContain('auth failed'); + expect(chunks.length).toBe(1); + }); + + test('emits result even when stream ends without session.idle', async () => { + const chunks = await collect([ + { type: 'message.part.delta', properties: { sessionID: SESSION, field: 'text', delta: 'x' } }, + ]); + const result = chunks.find(c => c.type === 'result'); + expect(result).toBeDefined(); + expect(result?.isError).toBeUndefined(); + }); + + test('parses structured output from accumulated text when schema provided', async () => { + const chunks = await collect( + [ + { + type: 'message.part.delta', + properties: { sessionID: SESSION, field: 'text', delta: '{"answer":42}' }, + }, + { type: 'session.idle', properties: { sessionID: SESSION } }, + ], + SESSION, + { type: 'object', properties: { answer: { type: 'number' } } } + ); + const result = chunks.find(c => c.type === 'result'); + expect(result?.structuredOutput).toEqual({ answer: 42 }); + }); + + test('strips code fences before JSON parse', async () => { + const chunks = await collect( + [ + { + type: 'message.part.delta', + properties: { sessionID: SESSION, field: 'text', delta: '```json\n{"x":1}\n```' }, + }, + { type: 'session.idle', properties: { sessionID: SESSION } }, + ], + SESSION, + { type: 'object' } + ); + const result = chunks.find(c => c.type === 'result'); + expect(result?.structuredOutput).toEqual({ x: 1 }); + }); + + test('leaves structuredOutput undefined on parse failure', async () => { + const chunks = await collect( + [ + { + type: 'message.part.delta', + properties: { sessionID: SESSION, field: 'text', delta: 'not json' }, + }, + { type: 'session.idle', properties: { sessionID: SESSION } }, + ], + SESSION, + { type: 'object' } + ); + const result = chunks.find(c => c.type === 'result'); + expect(result?.structuredOutput).toBeUndefined(); + }); + + test('ignores server.connected heartbeat events', async () => { + const chunks = await collect([ + { type: 'server.connected', properties: {} }, + { type: 'server.heartbeat', properties: {} }, + { + type: 'message.part.delta', + properties: { sessionID: SESSION, field: 'text', delta: 'ok' }, + }, + { type: 'session.idle', properties: { sessionID: SESSION } }, + ]); + expect(chunks.filter(c => c.type === 'assistant')).toHaveLength(1); + }); +}); + +describe('augmentPromptForJsonSchema', () => { + test('appends schema instruction to prompt', () => { + const result = augmentPromptForJsonSchema('Hello', { type: 'object' }); + expect(result).toContain('Hello'); + expect(result).toContain('CRITICAL'); + expect(result).toContain('"type": "object"'); + }); +}); diff --git a/packages/providers/src/community/opencode/event-bridge.ts b/packages/providers/src/community/opencode/event-bridge.ts new file mode 100644 index 0000000000..475003ad3d --- /dev/null +++ b/packages/providers/src/community/opencode/event-bridge.ts @@ -0,0 +1,228 @@ +import type { MessageChunk, TokenUsage } from '../../types'; + +/** + * Raw event shape from the opencode SSE stream. + * The SDK's typed Event union omits 'message.part.delta' and 'server.*' + * events that the server actually emits, so we widen to unknown and + * discriminate by type string at runtime. + */ +interface RawEvent { + type: string; + properties: Record; +} + +interface RawToolState { + status: 'pending' | 'running' | 'completed' | 'error'; + input?: Record; + output?: string; + error?: string; +} + +interface RawPart { + id?: string; + type: string; + sessionID?: string; + messageID?: string; + // text / reasoning + text?: string; + // tool + callID?: string; + tool?: string; + state?: RawToolState; + // step-finish + cost?: number; + tokens?: { input: number; output: number }; +} + +/** + * Bridge the opencode SSE event stream into Archon MessageChunks. + * + * Caller responsibilities: + * - Subscribe to events BEFORE creating the session (avoids race). + * - Pass the session ID to filter out noise from other concurrent sessions + * sharing the same opencode server instance. + * - Call abortFn when the caller's AbortSignal fires; we wire it internally + * so the caller doesn't need to race on signal vs. stream end. + * + * Event mapping (opencode → Archon): + * message.part.delta {field:'text'} → { type:'assistant', content: delta } + * message.part.updated {reasoning} → { type:'thinking', content: new-text } + * message.part.updated {tool running}→ { type:'tool', toolName, toolInput, toolCallId } + * message.part.updated {tool done} → { type:'tool_result', toolName, toolOutput, toolCallId } + * message.part.updated {step-finish} → accumulates tokens / cost + * session.idle → { type:'result', sessionId, tokens, cost } + * session.error → { type:'result', isError:true, errors } + * + * Note: 'message.part.delta' is not in the SDK's typed Event union but IS + * emitted by the server (confirmed via smoke-test). We handle it via the + * RawEvent fallthrough. + */ +export async function* bridgeOpencodeEvents( + stream: AsyncGenerator, + sessionId: string, + outputSchema?: Record +): AsyncGenerator { + // Track which tool calls/results have been yielded to avoid duplicates. + const emittedToolCalls = new Set(); + const emittedToolResults = new Set(); + // Reasoning parts send full-text snapshots; track per-partID to yield deltas. + const reasoningLengths = new Map(); + + let inputTokens = 0; + let outputTokens = 0; + let totalCost = 0; + let assistantText = ''; + + for await (const raw of stream) { + const event = raw as RawEvent; + if (!event || typeof event.type !== 'string') continue; + const props = event.properties ?? {}; + + // Filter events to the current session. + // Server-level events (server.connected, server.heartbeat) have no + // sessionID in properties and are intentionally let through — we don't + // handle them, so they fall through the switch harmlessly. + if (typeof props.sessionID === 'string' && props.sessionID !== sessionId) continue; + + switch (event.type) { + case 'message.part.delta': { + // Text streaming: not in SDK types but real. Properties: + // { sessionID, messageID, partID, field: 'text', delta: string } + if (props.field === 'text' && typeof props.delta === 'string' && props.delta !== '') { + assistantText += props.delta; + yield { type: 'assistant', content: props.delta }; + } + break; + } + + case 'message.part.updated': { + const part = props.part as RawPart | undefined; + if (part?.sessionID !== sessionId) break; + + if (part.type === 'reasoning') { + // Full-text snapshot; emit only the new suffix. + const text = typeof part.text === 'string' ? part.text : ''; + const partId = part.id ?? ''; + const prev = reasoningLengths.get(partId) ?? 0; + if (text.length > prev) { + yield { type: 'thinking', content: text.slice(prev) }; + reasoningLengths.set(partId, text.length); + } + } else if (part.type === 'tool' && part.callID && part.tool && part.state) { + const { callID, tool, state } = part; + if (state.status === 'running' && !emittedToolCalls.has(callID)) { + emittedToolCalls.add(callID); + yield { + type: 'tool', + toolName: tool, + toolInput: state.input ?? {}, + toolCallId: callID, + }; + } else if (state.status === 'completed' && !emittedToolResults.has(callID)) { + emittedToolResults.add(callID); + yield { + type: 'tool_result', + toolName: tool, + toolOutput: state.output ?? '', + toolCallId: callID, + }; + } else if (state.status === 'error' && !emittedToolResults.has(callID)) { + emittedToolResults.add(callID); + yield { + type: 'tool_result', + toolName: tool, + toolOutput: `Error: ${state.error ?? 'unknown'}`, + toolCallId: callID, + }; + } + } else if (part.type === 'step-finish') { + if (part.tokens) { + inputTokens += part.tokens.input; + outputTokens += part.tokens.output; + } + if (typeof part.cost === 'number') { + totalCost += part.cost; + } + } + break; + } + + case 'session.idle': { + const tokens: TokenUsage = { + input: inputTokens, + output: outputTokens, + total: inputTokens + outputTokens, + }; + + let structuredOutput: unknown = undefined; + if (outputSchema && assistantText) { + try { + // Strip markdown code fences that instruction-following models may add. + const stripped = assistantText + .trim() + .replace(/^```(?:json)?\n?/, '') + .replace(/\n?```$/, ''); + structuredOutput = JSON.parse(stripped); + } catch { + // Parse failure: executor's dag.structured_output_missing path handles it. + } + } + + yield { + type: 'result', + sessionId, + tokens, + cost: totalCost, + ...(structuredOutput !== undefined ? { structuredOutput } : {}), + }; + return; + } + + case 'session.error': { + const error = props.error as Record | undefined; + const errorMsg = + (error?.message as string | undefined) ?? + (error?.code as string | undefined) ?? + 'opencode session error'; + yield { + type: 'result', + sessionId, + isError: true, + errors: [errorMsg], + }; + return; + } + } + } + + // Stream ended without a terminal event — yield a result so the caller + // gets a complete MessageChunk sequence regardless. + yield { + type: 'result', + sessionId, + tokens: { + input: inputTokens, + output: outputTokens, + total: inputTokens + outputTokens, + }, + cost: totalCost, + }; +} + +/** + * Augment a prompt with a "respond with JSON matching this schema" instruction. + * Used when outputFormat is specified — opencode has no SDK-level JSON mode. + */ +export function augmentPromptForJsonSchema( + prompt: string, + schema: Record +): string { + return `${prompt} + +--- + +CRITICAL: Respond with ONLY a JSON object matching the schema below. No prose before or after the JSON. No markdown code fences. Just the raw JSON object as your final message. + +Schema: +${JSON.stringify(schema, null, 2)}`; +} diff --git a/packages/providers/src/community/opencode/index.ts b/packages/providers/src/community/opencode/index.ts new file mode 100644 index 0000000000..3dc8e3b1d1 --- /dev/null +++ b/packages/providers/src/community/opencode/index.ts @@ -0,0 +1,6 @@ +export { OPENCODE_CAPABILITIES } from './capabilities'; +export { parseOpencodeConfig, parseOpencodeModel } from './config'; +export type { OpencodeProviderDefaults } from './config'; +export { augmentPromptForJsonSchema, bridgeOpencodeEvents } from './event-bridge'; +export { OpencodeProvider } from './provider'; +export { registerOpencodeProvider } from './registration'; diff --git a/packages/providers/src/community/opencode/provider.test.ts b/packages/providers/src/community/opencode/provider.test.ts new file mode 100644 index 0000000000..ed89a9d35f --- /dev/null +++ b/packages/providers/src/community/opencode/provider.test.ts @@ -0,0 +1,262 @@ +import { beforeEach, describe, expect, mock, test } from 'bun:test'; + +import { createMockLogger } from '../../test/mocks/logger'; + +// ─── Mock logger ──────────────────────────────────────────────────────────── +const mockLogger = createMockLogger(); +mock.module('@archon/paths', () => ({ + createLogger: mock(() => mockLogger), +})); + +// ─── Helpers to build scripted event sequences ─────────────────────────────── + +type ScriptedEvent = { type: string; properties: Record }; + +function textDeltaEvent(sessionID: string, delta: string): ScriptedEvent { + return { + type: 'message.part.delta', + properties: { sessionID, messageID: 'msg1', partID: 'prt1', field: 'text', delta }, + }; +} + +function stepFinishEvent( + sessionID: string, + cost: number, + tokens: { input: number; output: number } +): ScriptedEvent { + return { + type: 'message.part.updated', + properties: { + sessionID, + part: { + id: 'prt-sf', + type: 'step-finish', + sessionID, + messageID: 'msg1', + cost, + tokens, + }, + }, + }; +} + +function sessionIdleEvent(sessionID: string): ScriptedEvent { + return { type: 'session.idle', properties: { sessionID } }; +} + +function sessionErrorEvent(sessionID: string, message: string): ScriptedEvent { + return { + type: 'session.error', + properties: { sessionID, error: { message, code: 'ERR' } }, + }; +} + +async function* makeStream(events: ScriptedEvent[]): AsyncGenerator { + for (const e of events) yield e; +} + +// ─── Mock @opencode-ai/sdk ─────────────────────────────────────────────────── + +const SESSION_ID = 'ses_test123'; + +let scriptedEvents: ScriptedEvent[] = []; + +const mockSessionCreate = mock(async () => ({ data: { id: SESSION_ID } })); +const mockSessionGet = mock(async (_opts: unknown) => ({ data: { id: SESSION_ID } })); +const mockSessionPromptAsync = mock(async () => undefined); +const mockSessionAbort = mock(async () => undefined); +const mockEventSubscribe = mock(async () => ({ stream: makeStream(scriptedEvents) })); + +const mockClient = { + session: { + create: mockSessionCreate, + get: mockSessionGet, + promptAsync: mockSessionPromptAsync, + abort: mockSessionAbort, + }, + event: { + subscribe: mockEventSubscribe, + }, +}; + +mock.module('@opencode-ai/sdk', () => ({ + createOpencode: mock(async () => ({ + client: mockClient, + server: { url: 'http://127.0.0.1:4096', close: mock(() => undefined) }, + })), +})); + +// ─── Import provider AFTER mocks are wired ────────────────────────────────── +// mock.module() calls above intercept the @opencode-ai/sdk dynamic import +// that provider.ts performs lazily, so the static import below is safe. + +import { OpencodeProvider } from './provider'; + +beforeEach(() => { + scriptedEvents = []; + mockSessionCreate.mockReset(); + mockSessionGet.mockReset(); + mockSessionPromptAsync.mockReset(); + mockEventSubscribe.mockReset(); + mockSessionAbort.mockReset(); + // Restore default implementations after reset. + mockSessionCreate.mockImplementation(async () => ({ data: { id: SESSION_ID } })); + mockSessionGet.mockImplementation(async (_opts: unknown) => ({ data: { id: SESSION_ID } })); + mockSessionPromptAsync.mockImplementation(async () => undefined); + mockSessionAbort.mockImplementation(async () => undefined); + mockEventSubscribe.mockImplementation(async () => ({ stream: makeStream(scriptedEvents) })); +}); + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +describe('OpencodeProvider', () => { + test('streams assistant text and emits result chunk', async () => { + const provider = new OpencodeProvider(); + + scriptedEvents = [ + textDeltaEvent(SESSION_ID, 'Hello'), + textDeltaEvent(SESSION_ID, ' world'), + stepFinishEvent(SESSION_ID, 0.001, { input: 100, output: 20 }), + sessionIdleEvent(SESSION_ID), + ]; + + const chunks: Array<{ type: string; content?: string }> = []; + for await (const chunk of provider.sendQuery('say hello', '/tmp')) { + chunks.push(chunk as never); + } + + const assistantChunks = chunks.filter(c => c.type === 'assistant'); + expect(assistantChunks).toEqual([ + { type: 'assistant', content: 'Hello' }, + { type: 'assistant', content: ' world' }, + ]); + + const result = chunks.find(c => c.type === 'result') as + | { type: string; sessionId?: string; tokens?: unknown; cost?: number } + | undefined; + expect(result).toMatchObject({ + type: 'result', + sessionId: SESSION_ID, + tokens: { input: 100, output: 20, total: 120 }, + cost: 0.001, + }); + }); + + test('filters events from other sessions', async () => { + const provider = new OpencodeProvider(); + + const otherSession = 'ses_OTHER'; + scriptedEvents = [ + textDeltaEvent(otherSession, 'noise'), + textDeltaEvent(SESSION_ID, 'signal'), + sessionIdleEvent(otherSession), // ignored — different session + sessionIdleEvent(SESSION_ID), + ]; + + const chunks: Array<{ type: string; content?: string }> = []; + for await (const chunk of provider.sendQuery('test', '/tmp')) { + chunks.push(chunk as never); + } + + const assistantChunks = chunks.filter(c => c.type === 'assistant'); + expect(assistantChunks).toEqual([{ type: 'assistant', content: 'signal' }]); + }); + + test('yields isError result on session.error', async () => { + const provider = new OpencodeProvider(); + + scriptedEvents = [sessionErrorEvent(SESSION_ID, 'model not found')]; + + const chunks: Array<{ type: string; isError?: boolean; errors?: string[] }> = []; + for await (const chunk of provider.sendQuery('test', '/tmp')) { + chunks.push(chunk as never); + } + + const result = chunks.find(c => c.type === 'result'); + expect(result?.isError).toBe(true); + expect(result?.errors).toContain('model not found'); + }); + + test('yields system warning and creates new session on failed resume', async () => { + const provider = new OpencodeProvider(); + + mockSessionGet.mockImplementation(async () => { + throw new Error('session not found'); + }); + + scriptedEvents = [sessionIdleEvent(SESSION_ID)]; + + const chunks: Array<{ type: string; content?: string }> = []; + for await (const chunk of provider.sendQuery('test', '/tmp', 'ses_MISSING')) { + chunks.push(chunk as never); + } + + const systemChunks = chunks.filter(c => c.type === 'system'); + expect(systemChunks.length).toBeGreaterThan(0); + expect(systemChunks[0]?.content).toContain('Could not resume'); + expect(mockSessionCreate).toHaveBeenCalled(); + }); + + test('passes model spec to promptAsync when model is configured', async () => { + const provider = new OpencodeProvider(); + + scriptedEvents = [sessionIdleEvent(SESSION_ID)]; + + for await (const _ of provider.sendQuery('test', '/tmp', undefined, { + model: 'ollama/qwen3:8b', + })) { + // drain + } + + expect(mockSessionPromptAsync).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + model: { providerID: 'ollama', modelID: 'qwen3:8b' }, + }), + }) + ); + }); + + test('omits model field when no model specified', async () => { + const provider = new OpencodeProvider(); + + scriptedEvents = [sessionIdleEvent(SESSION_ID)]; + + for await (const _ of provider.sendQuery('test', '/tmp')) { + // drain + } + + const callArg = mockSessionPromptAsync.mock.calls[ + mockSessionPromptAsync.mock.calls.length - 1 + ]?.[0] as { body?: { model?: unknown } } | undefined; + expect(callArg?.body?.model).toBeUndefined(); + }); + + test('yields system warning for invalid model format', async () => { + const provider = new OpencodeProvider(); + + scriptedEvents = [sessionIdleEvent(SESSION_ID)]; + + const chunks: Array<{ type: string; content?: string }> = []; + for await (const chunk of provider.sendQuery('test', '/tmp', undefined, { + model: 'invalid-no-slash', + })) { + chunks.push(chunk as never); + } + + const warnings = chunks.filter(c => c.type === 'system'); + expect(warnings.length).toBeGreaterThan(0); + expect(warnings[0]?.content).toContain('invalid model format'); + }); + + test('getType returns opencode', () => { + expect(new OpencodeProvider().getType()).toBe('opencode'); + }); + + test('getCapabilities returns OPENCODE_CAPABILITIES', () => { + const provider = new OpencodeProvider(); + expect(provider.getCapabilities().sessionResume).toBe(true); + expect(provider.getCapabilities().structuredOutput).toBe(true); + expect(provider.getCapabilities().mcp).toBe(false); + }); +}); diff --git a/packages/providers/src/community/opencode/provider.ts b/packages/providers/src/community/opencode/provider.ts new file mode 100644 index 0000000000..bb47807b51 --- /dev/null +++ b/packages/providers/src/community/opencode/provider.ts @@ -0,0 +1,276 @@ +// IMPORTANT: Do NOT add static `import { createOpencode } from '@opencode-ai/sdk'` here. +// The SDK calls `cross-spawn('opencode', ...)` at server start; inside a compiled +// Archon binary that binary lookup may fail at startup if opencode isn't on PATH. +// The dynamic import below defers the spawn to the first actual sendQuery call so +// the process doesn't crash at boot when opencode is absent but unused. +// Type-only imports are fine — TypeScript erases them. + +import type { OpencodeClient } from '@opencode-ai/sdk'; + +import { sep } from 'node:path'; + +import { createLogger } from '@archon/paths'; + +import type { + IAgentProvider, + MessageChunk, + ProviderCapabilities, + SendQueryOptions, +} from '../../types'; +import { OPENCODE_CAPABILITIES } from './capabilities'; +import { parseOpencodeConfig, parseOpencodeModel } from './config'; +import { augmentPromptForJsonSchema, bridgeOpencodeEvents } from './event-bridge'; + +let cachedLog: ReturnType | undefined; +function getLog(): ReturnType { + if (!cachedLog) cachedLog = createLogger('provider.opencode'); + return cachedLog; +} + +/** + * Module-level singleton for the opencode server process. + * + * One server process services all OpencodeProvider instances for the + * lifetime of the parent process. Lazily initialized on first sendQuery, + * which lets the module be imported without spawning a child process. + * On error the promise is cleared so the next call retries. + */ +let serverState: + | Promise<{ client: OpencodeClient; server: { url: string; close(): void } }> + | undefined; + +function getOrCreateServer( + opencodeBinaryDir?: string +): Promise<{ client: OpencodeClient; server: { url: string; close(): void } }> { + if (!serverState) { + serverState = (async (): Promise<{ + client: OpencodeClient; + server: { url: string; close(): void }; + }> => { + // Prepend the user-configured binary directory to PATH so cross-spawn + // finds `opencode` even when it's installed outside the default PATH. + if (opencodeBinaryDir) { + const pathSep = sep === '\\' ? ';' : ':'; + if (!process.env.PATH?.includes(opencodeBinaryDir)) { + process.env.PATH = `${opencodeBinaryDir}${pathSep}${process.env.PATH ?? ''}`; + getLog().debug({ opencodeBinaryDir }, 'opencode.path_prepended'); + } + } + + const { createOpencode } = await import('@opencode-ai/sdk'); + const result = await createOpencode(); + + getLog().info({ url: result.server.url }, 'opencode.server_started'); + + // Best-effort cleanup on process exit. + process.on('exit', () => { + result.server.close(); + }); + + return result; + })().catch((err: unknown) => { + // Clear so the next sendQuery can retry (e.g. after installing opencode). + serverState = undefined; + throw err; + }); + } + return serverState; +} + +/** + * Opencode community provider — wraps the opencode SDK to give Archon workflows + * access to any model configured in `~/.config/opencode/opencode.json`, including + * local Ollama models and any provider opencode supports. + * + * Model format: '/' (e.g. 'ollama/qwen3:8b', + * 'anthropic/claude-sonnet-4-5', 'openai/gpt-4o'). + * When omitted, opencode uses its configured default model. + * + * Each sendQuery call creates a new session (or resumes an existing one when + * resumeSessionId is provided) and streams events until session.idle or + * session.error. + */ +export class OpencodeProvider implements IAgentProvider { + async *sendQuery( + prompt: string, + cwd: string, + resumeSessionId?: string, + options?: SendQueryOptions + ): AsyncGenerator { + const assistantConfig = options?.assistantConfig ?? {}; + const config = parseOpencodeConfig(assistantConfig); + + // 1. Ensure the opencode server is running. + const { client } = await getOrCreateServer(config.opencodeBinaryDir); + + // 2. Resolve model. Request-level wins over config default; when neither + // is set we omit the model field so opencode uses its configured default. + const modelStr = options?.model ?? config.model; + const modelSpec = modelStr ? parseOpencodeModel(modelStr) : undefined; + if (modelStr && !modelSpec) { + yield { + type: 'system', + content: `⚠️ opencode: invalid model format '${modelStr}'. Expected '/' (e.g. 'ollama/qwen3:8b'). Falling back to opencode default.`, + }; + } + + // 3. Subscribe and start an eager pump so no events are missed. + // + // The SDK returns a lazy async generator that only opens the HTTP + // connection on first next(). We start an IIFE pump immediately to + // keep the stream alive (Bun closes idle response bodies after ~99ms) + // and buffer all events into a single-producer/single-consumer queue + // (same pattern as the Pi event bridge). bridgeOpencodeEvents drains + // the queue at its own pace without dropping events. + // Subscribe without a directory filter so the pump receives events from all + // server instances. The opencode server dispatches model-response events + // through the process-CWD instance (not the session's cwd instance), so + // a directory-scoped subscription misses them. bridgeOpencodeEvents already + // filters by sessionId, so no spurious events reach the caller. + const { stream } = await client.event.subscribe({}); + + // Single-producer/single-consumer async queue. + const queueBuf: unknown[] = []; + const queueWaiters: ((r: IteratorResult) => void)[] = []; + let queueClosed = false; + + function queuePush(item: unknown): void { + const w = queueWaiters.shift(); + if (w) w({ value: item, done: false }); + else queueBuf.push(item); + } + + function queueClose(): void { + if (queueClosed) return; + queueClosed = true; + while (queueWaiters.length > 0) { + const w = queueWaiters.shift(); + if (w) w({ value: undefined, done: true }); + } + } + + async function* queueIterator(): AsyncGenerator { + while (true) { + const next = queueBuf.shift(); + if (next !== undefined) { + yield next; + continue; + } + if (queueClosed) return; + const r = await new Promise>(res => { + queueWaiters.push(res); + }); + if (r.done) return; + yield r.value; + } + } + + // Resolves once the SSE connection is confirmed open (first event arrives). + let resolveFirstEvent: (() => void) | undefined; + const firstEventPromise = new Promise(res => { + resolveFirstEvent = res; + }); + + let pumpErr: unknown = null; + const pumpTask = (async (): Promise => { + try { + for await (const ev of stream) { + if (resolveFirstEvent) { + resolveFirstEvent(); + resolveFirstEvent = undefined; + } + queuePush(ev); + } + } catch (err) { + pumpErr = err; + } finally { + if (resolveFirstEvent) { + resolveFirstEvent(); + resolveFirstEvent = undefined; + } + queueClose(); + } + })(); + + // Wait for the SSE connection to open (server.connected arrives first). + await firstEventPromise; + + // 4. Session management: resume or create. + let sessionId: string; + if (resumeSessionId) { + try { + const res = await client.session.get({ path: { id: resumeSessionId } }); + sessionId = (res as { data: { id: string } }).data.id; + getLog().debug({ sessionId }, 'opencode.session_resumed'); + } catch { + yield { + type: 'system', + content: '⚠️ Could not resume opencode session. Starting fresh conversation.', + }; + const createRes = await client.session.create({ query: { directory: cwd } }); + sessionId = (createRes as { data: { id: string } }).data.id; + } + } else { + const createRes = await client.session.create({ query: { directory: cwd } }); + sessionId = (createRes as { data: { id: string } }).data.id; + } + + // 5. Structured output: prompt-engineer JSON schema when requested. + const outputFormat = options?.outputFormat; + const effectivePrompt = outputFormat + ? augmentPromptForJsonSchema(prompt, outputFormat.schema) + : prompt; + + // 6. Fire prompt (fire-and-forget 204 endpoint). + await client.session.promptAsync({ + path: { id: sessionId }, + body: { + parts: [{ type: 'text', text: effectivePrompt }], + ...(options?.systemPrompt ? { system: options.systemPrompt } : {}), + ...(modelSpec ? { model: modelSpec } : {}), + }, + }); + + // 7. Wire abort before entering the stream loop. + if (options?.abortSignal) { + options.abortSignal.addEventListener( + 'abort', + () => { + void client.session.abort({ path: { id: sessionId } }).catch(() => { + // Ignore — the stream will terminate via session.idle / session.error. + }); + }, + { once: true } + ); + } + + getLog().info( + { + sessionId, + cwd, + model: modelStr ?? '(opencode default)', + resumed: resumeSessionId !== undefined, + }, + 'opencode.prompt_started' + ); + + // 8. Bridge events from the live queue → MessageChunk stream. + try { + yield* bridgeOpencodeEvents(queueIterator(), sessionId, outputFormat?.schema); + } catch (err) { + getLog().error({ err, sessionId }, 'opencode.prompt_failed'); + throw err; + } + getLog().info({ sessionId }, 'opencode.prompt_completed'); + await pumpTask; + if (pumpErr) throw pumpErr as Error; + } + + getType(): string { + return 'opencode'; + } + + getCapabilities(): ProviderCapabilities { + return OPENCODE_CAPABILITIES; + } +} diff --git a/packages/providers/src/community/opencode/registration.ts b/packages/providers/src/community/opencode/registration.ts new file mode 100644 index 0000000000..6b08ce00eb --- /dev/null +++ b/packages/providers/src/community/opencode/registration.ts @@ -0,0 +1,32 @@ +import { isRegisteredProvider, registerProvider } from '../../registry'; + +import { OPENCODE_CAPABILITIES } from './capabilities'; +import { parseOpencodeModel } from './config'; +import { OpencodeProvider } from './provider'; + +/** + * Register the opencode community provider. + * + * Idempotent — safe to call multiple times, so process entrypoints (CLI, + * server, config-loader) can each call it without coordination. Kept + * separate from `registerBuiltinProviders()` because `builtIn: false` is + * load-bearing: opencode validates the community-provider seam and must + * not be conflated with core providers. + */ +export function registerOpencodeProvider(): void { + if (isRegisteredProvider('opencode')) return; + registerProvider({ + id: 'opencode', + displayName: 'opencode (community)', + factory: () => new OpencodeProvider(), + capabilities: OPENCODE_CAPABILITIES, + isModelCompatible: (model: string): boolean => { + // opencode models use '/' format. + // builtIn: false so this is never called during model inference + // (inferProviderFromModel only iterates builtIn:true providers), + // but implemented correctly for completeness. + return parseOpencodeModel(model) !== undefined; + }, + builtIn: false, + }); +} diff --git a/packages/providers/src/registry.test.ts b/packages/providers/src/registry.test.ts index 64b879a91c..d8663e24aa 100644 --- a/packages/providers/src/registry.test.ts +++ b/packages/providers/src/registry.test.ts @@ -275,9 +275,8 @@ describe('registry', () => { describe('registerCommunityProviders (aggregator)', () => { test('registers all bundled community providers', () => { registerCommunityProviders(); - // Pi is currently the only community provider bundled. When more are - // added, they should appear here automatically. expect(isRegisteredProvider('pi')).toBe(true); + expect(isRegisteredProvider('opencode')).toBe(true); }); test('is idempotent', () => { diff --git a/packages/providers/src/registry.ts b/packages/providers/src/registry.ts index 1ae16759dc..fc402f8604 100644 --- a/packages/providers/src/registry.ts +++ b/packages/providers/src/registry.ts @@ -18,6 +18,7 @@ import { CodexProvider } from './codex/provider'; import { CLAUDE_CAPABILITIES } from './claude/capabilities'; import { CODEX_CAPABILITIES } from './codex/capabilities'; import { registerPiProvider } from './community/pi/registration'; +import { registerOpencodeProvider } from './community/opencode/registration'; import { UnknownProviderError } from './errors'; import { createLogger } from '@archon/paths'; @@ -163,6 +164,7 @@ export function registerBuiltinProviders(): void { */ export function registerCommunityProviders(): void { registerPiProvider(); + registerOpencodeProvider(); } /** @internal Test-only — clears the registry. Not for production use. */ diff --git a/packages/providers/src/types.ts b/packages/providers/src/types.ts index d6cb8b4a87..d63bb94947 100644 --- a/packages/providers/src/types.ts +++ b/packages/providers/src/types.ts @@ -82,6 +82,23 @@ export interface PiProviderDefaults { env?: Record; } +/** + * Community provider defaults for opencode (@opencode-ai/sdk). + * v1 shape — extend as capabilities are wired in. + */ +export interface OpencodeProviderDefaults { + [key: string]: unknown; + /** Default model in '/' format, e.g. 'ollama/qwen3:8b'. */ + model?: string; + /** + * Directory containing the opencode binary. When set, prepended to PATH + * before the opencode server is started. Useful when opencode is installed + * outside the default PATH (e.g. compiled Archon builds). + * @default undefined — opencode must be on PATH + */ + opencodeBinaryDir?: string; +} + /** Generic per-provider defaults bag used by config surfaces and UI. */ export type ProviderDefaults = Record;