From dff85711e849fbf5ea476fc7963c2c0005dce0c8 Mon Sep 17 00:00:00 2001 From: Dheer Gupta Date: Tue, 14 Jul 2026 17:55:42 +0530 Subject: [PATCH] feat: add OpenCode Zen as inference provider --- src/__tests__/inference-router.test.ts | 131 ++++++++++++++++++++++++- src/config.ts | 4 + src/conway/inference.ts | 33 +++++-- src/index.ts | 36 +++++++ src/inference/registry.ts | 1 + src/types.ts | 4 +- 6 files changed, 200 insertions(+), 9 deletions(-) diff --git a/src/__tests__/inference-router.test.ts b/src/__tests__/inference-router.test.ts index d6c7721a..a047f6e2 100644 --- a/src/__tests__/inference-router.test.ts +++ b/src/__tests__/inference-router.test.ts @@ -749,7 +749,7 @@ describe("Static Model Baseline", () => { }); it("all models have valid provider", () => { - const validProviders = ["openai", "anthropic", "conway", "other"]; + const validProviders = ["openai", "anthropic", "conway", "opencode", "other"]; for (const model of STATIC_MODEL_BASELINE) { expect(validProviders).toContain(model.provider); } @@ -950,6 +950,135 @@ describe("Inference DB Helpers", () => { }); }); +// ─── OpenCode Zen Provider Tests ───────────────────────────────── + +describe("OpenCode Zen provider support", () => { + it("opencode models can be registered in model registry", () => { + const registry = new ModelRegistry(db); + registry.initialize(); + const now = new Date().toISOString(); + registry.upsert({ + modelId: "gpt-5.6-sol", + provider: "opencode", + displayName: "GPT-5.6 Sol (via OpenCode Zen)", + tierMinimum: "normal", + costPer1kInput: 0, + costPer1kOutput: 0, + maxTokens: 4096, + contextWindow: 128000, + supportsTools: true, + supportsVision: true, + parameterStyle: "max_tokens", + enabled: true, + lastSeen: null, + createdAt: now, + updatedAt: now, + }); + const entry = registry.get("gpt-5.6-sol"); + expect(entry).toBeDefined(); + expect(entry!.provider).toBe("opencode"); + expect(entry!.enabled).toBe(true); + }); + + it("opencode models are not auto-disabled during baseline initialization", () => { + const registry = new ModelRegistry(db); + const now = new Date().toISOString(); + // Pre-populate an opencode model before initialization + const row: ModelRegistryRow = { + modelId: "claude-sonnet-5", + provider: "opencode", + displayName: "Claude Sonnet 5 (via OpenCode Zen)", + tierMinimum: "normal", + costPer1kInput: 0, + costPer1kOutput: 0, + maxTokens: 4096, + contextWindow: 200000, + supportsTools: true, + supportsVision: true, + parameterStyle: "max_tokens", + enabled: true, + createdAt: now, + updatedAt: now, + }; + modelRegistryUpsert(db, row); + // Initialize seeds baseline models - should NOT disable opencode models + registry.initialize(); + const entry = registry.get("claude-sonnet-5"); + expect(entry).toBeDefined(); + expect(entry!.enabled).toBe(true); + }); + + it("refreshFromApi populates opencode models from proxy discovery", () => { + const registry = new ModelRegistry(db); + registry.initialize(); + registry.refreshFromApi([ + { + id: "deepseek-v4-flash", + provider: "opencode", + display_name: "DeepSeek V4 Flash", + max_tokens: 8192, + context_window: 131072, + supports_tools: true, + supports_vision: false, + }, + { + id: "gemini-3.5-flash", + provider: "opencode", + display_name: "Gemini 3.5 Flash", + max_tokens: 4096, + context_window: 1048576, + supports_tools: true, + supports_vision: true, + }, + ]); + const deepseek = registry.get("deepseek-v4-flash"); + expect(deepseek).toBeDefined(); + expect(deepseek!.provider).toBe("opencode"); + expect(deepseek!.contextWindow).toBe(131072); + const gemini = registry.get("gemini-3.5-flash"); + expect(gemini).toBeDefined(); + expect(gemini!.provider).toBe("opencode"); + }); + + it("router selects opencode model when configured as fallback", () => { + const registry = new ModelRegistry(db); + registry.initialize(); + const now = new Date().toISOString(); + // Register an opencode model + registry.upsert({ + modelId: "opencode/gpt-5.6-sol", + provider: "opencode", + displayName: "GPT-5.6 Sol via OpenCode Zen", + tierMinimum: "normal", + costPer1kInput: 0, + costPer1kOutput: 0, + maxTokens: 4096, + contextWindow: 128000, + supportsTools: true, + supportsVision: true, + parameterStyle: "max_tokens", + enabled: true, + lastSeen: null, + createdAt: now, + updatedAt: now, + }); + const config: ModelStrategyConfig = { + ...DEFAULT_MODEL_STRATEGY_CONFIG, + inferenceModel: "opencode/gpt-5.6-sol", + }; + const budget = new InferenceBudgetTracker(db, config); + const router = new InferenceRouter(db, registry, budget); + // Disable all baseline models so fallback kicks in + for (const m of STATIC_MODEL_BASELINE) { + registry.setEnabled(m.modelId, false); + } + const selected = router.selectModel("normal", "agent_turn"); + expect(selected).toBeDefined(); + expect(selected!.modelId).toBe("opencode/gpt-5.6-sol"); + expect(selected!.provider).toBe("opencode"); + }); +}); + // ─── Default Model Strategy Config Tests ────────────────────────── describe("DEFAULT_MODEL_STRATEGY_CONFIG", () => { diff --git a/src/config.ts b/src/config.ts index 67fa5930..4f7610dc 100644 --- a/src/config.ts +++ b/src/config.ts @@ -126,6 +126,8 @@ export function createConfig(params: { openaiApiKey?: string; anthropicApiKey?: string; ollamaBaseUrl?: string; + opencodeBaseUrl?: string; + opencodeApiKey?: string; parentAddress?: string; treasuryPolicy?: TreasuryPolicy; chainType?: ChainType; @@ -144,6 +146,8 @@ export function createConfig(params: { openaiApiKey: params.openaiApiKey, anthropicApiKey: params.anthropicApiKey, ollamaBaseUrl: params.ollamaBaseUrl, + opencodeBaseUrl: params.opencodeBaseUrl, + opencodeApiKey: params.opencodeApiKey, inferenceModel: DEFAULT_CONFIG.inferenceModel || "gpt-5.2", maxTokensPerTurn: DEFAULT_CONFIG.maxTokensPerTurn || 4096, heartbeatConfigPath: diff --git a/src/conway/inference.ts b/src/conway/inference.ts index ebcf1819..02a7bca7 100644 --- a/src/conway/inference.ts +++ b/src/conway/inference.ts @@ -27,11 +27,13 @@ interface InferenceClientOptions { openaiApiKey?: string; anthropicApiKey?: string; ollamaBaseUrl?: string; + opencodeBaseUrl?: string; + opencodeApiKey?: string; /** Optional registry lookup — if provided, used before name heuristics */ getModelProvider?: (modelId: string) => string | undefined; } -type InferenceBackend = "conway" | "openai" | "anthropic" | "ollama"; +type InferenceBackend = "conway" | "openai" | "anthropic" | "ollama" | "opencode"; function isLoopbackHttpUrl(url: string | undefined): boolean { if (!url) return false; @@ -48,11 +50,11 @@ function isLoopbackHttpUrl(url: string | undefined): boolean { export function createInferenceClient( options: InferenceClientOptions, ): InferenceClient { - const { apiUrl, apiKey, openaiApiKey, anthropicApiKey, ollamaBaseUrl, getModelProvider } = options; + const { apiUrl, apiKey, openaiApiKey, anthropicApiKey, ollamaBaseUrl, opencodeBaseUrl, opencodeApiKey, getModelProvider } = options; const httpClient = new ResilientHttpClient({ baseTimeout: INFERENCE_TIMEOUT_MS, retryableStatuses: [429, 500, 502, 503, 504], - allowHttpOnLoopback: isLoopbackHttpUrl(ollamaBaseUrl), + allowHttpOnLoopback: isLoopbackHttpUrl(ollamaBaseUrl) || isLoopbackHttpUrl(opencodeBaseUrl), }); let currentModel = options.defaultModel; let maxTokens = options.maxTokens; @@ -68,13 +70,14 @@ export function createInferenceClient( openaiApiKey, anthropicApiKey, ollamaBaseUrl, + opencodeBaseUrl, getModelProvider, }); // Newer models (o-series, gpt-5.x, gpt-4.1) require max_completion_tokens. - // Ollama always uses max_tokens. + // Ollama and OpenCode always use max_tokens. const usesCompletionTokens = - backend !== "ollama" && /^(o[1-9]|gpt-5|gpt-4\.1)/.test(model); + backend !== "ollama" && backend !== "opencode" && /^(o[1-9]|gpt-5|gpt-4\.1)/.test(model); const tokenLimit = opts?.maxTokens || maxTokens; const body: Record = { @@ -111,10 +114,12 @@ export function createInferenceClient( } const openAiLikeApiUrl = + backend === "opencode" ? (opencodeBaseUrl as string).replace(/\/$/, "") : backend === "openai" ? "https://api.openai.com" : backend === "ollama" ? (ollamaBaseUrl as string).replace(/\/$/, "") : apiUrl; const openAiLikeApiKey = + backend === "opencode" ? (opencodeApiKey || "") : backend === "openai" ? (openaiApiKey as string) : backend === "ollama" ? "ollama" : apiKey; @@ -180,12 +185,14 @@ function resolveInferenceBackend( openaiApiKey?: string; anthropicApiKey?: string; ollamaBaseUrl?: string; + opencodeBaseUrl?: string; getModelProvider?: (modelId: string) => string | undefined; }, ): InferenceBackend { // Registry-based routing: most accurate, no name guessing if (keys.getModelProvider) { const provider = keys.getModelProvider(model); + if (provider === "opencode" && keys.opencodeBaseUrl) return "opencode"; if (provider === "ollama" && keys.ollamaBaseUrl) return "ollama"; if (provider === "anthropic" && keys.anthropicApiKey) return "anthropic"; if (provider === "openai" && keys.openaiApiKey) return "openai"; @@ -205,7 +212,7 @@ async function chatViaOpenAiCompatible(params: { body: Record; apiUrl: string; apiKey: string; - backend: "conway" | "openai" | "ollama"; + backend: "conway" | "openai" | "ollama" | "opencode"; httpClient: ResilientHttpClient; }): Promise { const resp = await params.httpClient.request(`${params.apiUrl}/v1/chat/completions`, { @@ -213,7 +220,7 @@ async function chatViaOpenAiCompatible(params: { headers: { "Content-Type": "application/json", Authorization: - params.backend === "openai" || params.backend === "ollama" + params.backend === "openai" || params.backend === "ollama" || params.backend === "opencode" ? `Bearer ${params.apiKey}` : params.apiKey, }, @@ -223,6 +230,18 @@ async function chatViaOpenAiCompatible(params: { if (!resp.ok) { const text = await resp.text(); + if (params.backend === "opencode") { + switch (resp.status) { + case 401: + throw new Error(`Invalid OpenCode API key: ${text}`); + case 429: + throw new Error(`Rate limited by OpenCode proxy: ${text}`); + case 404: + throw new Error(`Model not found on OpenCode Zen: ${text}`); + default: + throw new Error(`OpenCode Zen error ${resp.status}: ${text}`); + } + } throw new Error( `Inference error (${params.backend}): ${resp.status}: ${text}`, ); diff --git a/src/index.ts b/src/index.ts index 0d5f38c9..3931fdb9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -70,6 +70,8 @@ Environment: CONWAY_API_URL Conway API URL (default: https://api.conway.tech) CONWAY_API_KEY Conway API key (overrides config) OLLAMA_BASE_URL Ollama base URL (overrides config, e.g. http://localhost:11434) + OPENCODE_BASE_URL OpenCode Zen base URL (overrides config) + OPENCODE_API_KEY OpenCode Zen API key (overrides config) `); process.exit(0); } @@ -280,10 +282,39 @@ async function run(): Promise { // Resolve Ollama base URL: env var takes precedence over config const ollamaBaseUrl = process.env.OLLAMA_BASE_URL || config.ollamaBaseUrl; + // Resolve OpenCode Zen base URL and API key: env vars take precedence over config + const opencodeBaseUrl = process.env.OPENCODE_BASE_URL || config.opencodeBaseUrl; + const opencodeApiKey = process.env.OPENCODE_API_KEY || config.opencodeApiKey; + // Create inference client — pass a live registry lookup so model names like // "gpt-oss:120b" route to Ollama based on their registered provider, not heuristics. const modelRegistry = new ModelRegistry(db.raw); modelRegistry.initialize(); + + // Auto-discover models from OpenCode Zen proxy + if (opencodeBaseUrl && opencodeApiKey) { + try { + const modelsUrl = `${opencodeBaseUrl.replace(/\/$/, "")}/v1/models`; + const resp = await fetch(modelsUrl, { + headers: opencodeApiKey ? { Authorization: `Bearer ${opencodeApiKey}` } : {}, + signal: AbortSignal.timeout(10_000), + }); + if (resp.ok) { + const data = await resp.json() as any; + const models = (data.data || []).map((m: any) => ({ + ...m, + provider: "opencode", + })); + modelRegistry.refreshFromApi(models); + logger.info(`[${new Date().toISOString()}] OpenCode Zen: discovered ${models.length} models from ${modelsUrl}`); + } else { + logger.warn(`[${new Date().toISOString()}] OpenCode Zen model discovery failed: ${resp.status}`); + } + } catch (err: any) { + logger.warn(`[${new Date().toISOString()}] OpenCode Zen model discovery error: ${err.message}`); + } + } + const inference = createInferenceClient({ apiUrl: config.conwayApiUrl, apiKey, @@ -293,12 +324,17 @@ async function run(): Promise { openaiApiKey: config.openaiApiKey, anthropicApiKey: config.anthropicApiKey, ollamaBaseUrl, + opencodeBaseUrl, + opencodeApiKey, getModelProvider: (modelId) => modelRegistry.get(modelId)?.provider, }); if (ollamaBaseUrl) { logger.info(`[${new Date().toISOString()}] Ollama backend: ${ollamaBaseUrl}`); } + if (opencodeBaseUrl && opencodeApiKey) { + logger.info(`[${new Date().toISOString()}] OpenCode Zen backend: ${opencodeBaseUrl}`); + } // Create social client (chain-aware: pass ChainIdentity for Solana signing) let social: SocialClientInterface | undefined; diff --git a/src/inference/registry.ts b/src/inference/registry.ts index 9cfe84b4..7c57e448 100644 --- a/src/inference/registry.ts +++ b/src/inference/registry.ts @@ -72,6 +72,7 @@ export class ModelRegistry { !baselineIds.has(existing.modelId) && existing.enabled && existing.provider !== "ollama" && + existing.provider !== "opencode" && existing.provider !== "other" ) { modelRegistrySetEnabled(this.db, existing.modelId, false); diff --git a/src/types.ts b/src/types.ts index 636595ff..3fbdd009 100644 --- a/src/types.ts +++ b/src/types.ts @@ -52,6 +52,8 @@ export interface AutomatonConfig { openaiApiKey?: string; anthropicApiKey?: string; ollamaBaseUrl?: string; + opencodeBaseUrl?: string; + opencodeApiKey?: string; inferenceModel: string; maxTokensPerTurn: number; heartbeatConfigPath: string; @@ -1142,7 +1144,7 @@ export const DEFAULT_MEMORY_BUDGET: MemoryBudget = { // === Phase 2.3: Inference & Model Strategy Types === -export type ModelProvider = "openai" | "anthropic" | "conway" | "ollama" | "other"; +export type ModelProvider = "openai" | "anthropic" | "conway" | "ollama" | "opencode" | "other"; export type InferenceTaskType = | "agent_turn"