From ef7f268eb40e2a3900a6d0a38830881379d90459 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Tue, 17 Feb 2026 09:56:53 +0800 Subject: [PATCH] Add OpenCode Zen provider support --- interface/src/api/client.ts | 1 + interface/src/components/ModelSelect.tsx | 3 +- interface/src/lib/providerIcons.tsx | 1 + interface/src/routes/Settings.tsx | 7 +++ src/api/server.rs | 69 +++++++++++++++++++++++- src/config.rs | 13 +++++ src/llm/manager.rs | 2 + src/llm/model.rs | 13 +++++ src/llm/providers.rs | 4 ++ src/llm/routing.rs | 15 ++++++ 10 files changed, 125 insertions(+), 3 deletions(-) diff --git a/interface/src/api/client.ts b/interface/src/api/client.ts index 8d7691144..7e3909513 100644 --- a/interface/src/api/client.ts +++ b/interface/src/api/client.ts @@ -637,6 +637,7 @@ export interface ProviderStatus { deepseek: boolean; xai: boolean; mistral: boolean; + opencode_zen: boolean; } export interface ProvidersResponse { diff --git a/interface/src/components/ModelSelect.tsx b/interface/src/components/ModelSelect.tsx index ad83a2263..ec4d35104 100644 --- a/interface/src/components/ModelSelect.tsx +++ b/interface/src/components/ModelSelect.tsx @@ -15,6 +15,7 @@ const PROVIDER_LABELS: Record = { openrouter: "OpenRouter", openai: "OpenAI", zhipu: "Z.ai (GLM)", + "opencode-zen": "OpenCode Zen", }; function formatContextWindow(tokens: number | null): string { @@ -108,7 +109,7 @@ export function ModelSelect({ label, description, value, onChange }: ModelSelect } }; - const providerOrder = ["openrouter", "anthropic", "openai", "zhipu"]; + const providerOrder = ["openrouter", "anthropic", "openai", "opencode-zen", "zhipu"]; const sortedProviders = Object.keys(grouped).sort( (a, b) => (providerOrder.indexOf(a) ?? 99) - (providerOrder.indexOf(b) ?? 99), ); diff --git a/interface/src/lib/providerIcons.tsx b/interface/src/lib/providerIcons.tsx index e0beb2bbd..c4e0e0471 100644 --- a/interface/src/lib/providerIcons.tsx +++ b/interface/src/lib/providerIcons.tsx @@ -37,6 +37,7 @@ export function ProviderIcon({ provider, className = "text-ink-faint", size = 24 together: Together, xai: XAI, zhipu: Zhipu, + "opencode-zen": OpenAI, }; const IconComponent = iconMap[provider.toLowerCase()]; diff --git a/interface/src/routes/Settings.tsx b/interface/src/routes/Settings.tsx index 46cbeb9c3..fffad8a51 100644 --- a/interface/src/routes/Settings.tsx +++ b/interface/src/routes/Settings.tsx @@ -117,6 +117,13 @@ const PROVIDERS = [ placeholder: "...", envVar: "MISTRAL_API_KEY", }, + { + id: "opencode-zen", + name: "OpenCode Zen", + description: "Multi-format gateway (Kimi, GLM, MiniMax, Qwen)", + placeholder: "...", + envVar: "OPENCODE_ZEN_API_KEY", + }, ] as const; export function Settings() { diff --git a/src/api/server.rs b/src/api/server.rs index dc60e201a..6850a2164 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -2007,6 +2007,7 @@ struct ProviderStatus { deepseek: bool, xai: bool, mistral: bool, + opencode_zen: bool, } #[derive(Serialize)] @@ -2033,7 +2034,7 @@ async fn get_providers( let config_path = state.config_path.read().await.clone(); // Check which providers have keys by reading the config - let (anthropic, openai, openrouter, zhipu, groq, together, fireworks, deepseek, xai, mistral) = if config_path.exists() { + let (anthropic, openai, openrouter, zhipu, groq, together, fireworks, deepseek, xai, mistral, opencode_zen) = if config_path.exists() { let content = tokio::fs::read_to_string(&config_path) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; @@ -2069,6 +2070,7 @@ async fn get_providers( has_key("deepseek_key", "DEEPSEEK_API_KEY"), has_key("xai_key", "XAI_API_KEY"), has_key("mistral_key", "MISTRAL_API_KEY"), + has_key("opencode_zen_key", "OPENCODE_ZEN_API_KEY"), ) } else { // No config file — check env vars only @@ -2083,6 +2085,7 @@ async fn get_providers( std::env::var("DEEPSEEK_API_KEY").is_ok(), std::env::var("XAI_API_KEY").is_ok(), std::env::var("MISTRAL_API_KEY").is_ok(), + std::env::var("OPENCODE_ZEN_API_KEY").is_ok(), ) }; @@ -2097,6 +2100,7 @@ async fn get_providers( deepseek, xai, mistral, + opencode_zen, }; let has_any = providers.anthropic || providers.openai @@ -2107,7 +2111,8 @@ async fn get_providers( || providers.fireworks || providers.deepseek || providers.xai - || providers.mistral; + || providers.mistral + || providers.opencode_zen; Ok(Json(ProvidersResponse { providers, has_any })) } @@ -2127,6 +2132,7 @@ async fn update_provider( "deepseek" => "deepseek_key", "xai" => "xai_key", "mistral" => "mistral_key", + "opencode-zen" => "opencode_zen_key", _ => { return Ok(Json(ProviderUpdateResponse { success: false, @@ -2201,6 +2207,11 @@ async fn update_provider( .and_then(|l| l.get("zhipu_key")) .and_then(|v| v.as_str()) .is_some_and(|s| !s.is_empty()), + "opencode-zen" => doc + .get("llm") + .and_then(|l| l.get("opencode_zen_key")) + .and_then(|v| v.as_str()) + .is_some_and(|s| !s.is_empty()), _ => false, }; @@ -2274,6 +2285,7 @@ async fn delete_provider( "deepseek" => "deepseek_key", "xai" => "xai_key", "mistral" => "mistral_key", + "opencode-zen" => "opencode_zen_key", _ => { return Ok(Json(ProviderUpdateResponse { success: false, @@ -2699,6 +2711,56 @@ fn curated_models() -> Vec { context_window: Some(128_000), curated: true, }, + // OpenCode Zen (OpenAI-compatible) + ModelInfo { + id: "opencode-zen/kimi-k2.5".into(), + name: "Kimi K2.5".into(), + provider: "opencode-zen".into(), + context_window: None, + curated: true, + }, + ModelInfo { + id: "opencode-zen/kimi-k2".into(), + name: "Kimi K2".into(), + provider: "opencode-zen".into(), + context_window: None, + curated: true, + }, + ModelInfo { + id: "opencode-zen/kimi-k2-thinking".into(), + name: "Kimi K2 Thinking".into(), + provider: "opencode-zen".into(), + context_window: None, + curated: true, + }, + ModelInfo { + id: "opencode-zen/glm-5".into(), + name: "GLM 5".into(), + provider: "opencode-zen".into(), + context_window: None, + curated: true, + }, + ModelInfo { + id: "opencode-zen/minimax-m2.5".into(), + name: "MiniMax M2.5".into(), + provider: "opencode-zen".into(), + context_window: None, + curated: true, + }, + ModelInfo { + id: "opencode-zen/qwen3-coder".into(), + name: "Qwen3 Coder 480B".into(), + provider: "opencode-zen".into(), + context_window: None, + curated: true, + }, + ModelInfo { + id: "opencode-zen/big-pickle".into(), + name: "Big Pickle".into(), + provider: "opencode-zen".into(), + context_window: None, + curated: true, + }, ] } @@ -2823,6 +2885,9 @@ async fn configured_providers(config_path: &std::path::Path) -> Vec<&'static str if has_key("mistral_key", "MISTRAL_API_KEY") { providers.push("mistral"); } + if has_key("opencode_zen_key", "OPENCODE_ZEN_API_KEY") { + providers.push("opencode-zen"); + } providers } diff --git a/src/config.rs b/src/config.rs index 0060ce329..f57996410 100644 --- a/src/config.rs +++ b/src/config.rs @@ -62,6 +62,7 @@ pub struct LlmConfig { pub deepseek_key: Option, pub xai_key: Option, pub mistral_key: Option, + pub opencode_zen_key: Option, } impl LlmConfig { @@ -77,6 +78,7 @@ impl LlmConfig { || self.deepseek_key.is_some() || self.xai_key.is_some() || self.mistral_key.is_some() + || self.opencode_zen_key.is_some() } } @@ -874,6 +876,7 @@ struct TomlLlmConfig { deepseek_key: Option, xai_key: Option, mistral_key: Option, + opencode_zen_key: Option, } #[derive(Deserialize, Default)] @@ -1143,6 +1146,7 @@ impl Config { std::env::var("ANTHROPIC_API_KEY").is_err() && std::env::var("OPENAI_API_KEY").is_err() && std::env::var("OPENROUTER_API_KEY").is_err() + && std::env::var("OPENCODE_ZEN_API_KEY").is_err() } /// Load configuration from the default config file, falling back to env vars. @@ -1186,6 +1190,7 @@ impl Config { deepseek_key: std::env::var("DEEPSEEK_API_KEY").ok(), xai_key: std::env::var("XAI_API_KEY").ok(), mistral_key: std::env::var("MISTRAL_API_KEY").ok(), + opencode_zen_key: std::env::var("OPENCODE_ZEN_API_KEY").ok(), }; // Note: We allow boot without provider keys now. System starts in setup mode. @@ -1293,6 +1298,12 @@ impl Config { .as_deref() .and_then(resolve_env_value) .or_else(|| std::env::var("MISTRAL_API_KEY").ok()), + opencode_zen_key: toml + .llm + .opencode_zen_key + .as_deref() + .and_then(resolve_env_value) + .or_else(|| std::env::var("OPENCODE_ZEN_API_KEY").ok()), }; // Note: We allow boot without provider keys now. System starts in setup mode. @@ -2233,6 +2244,7 @@ pub fn run_onboarding() -> anyhow::Result> { "DeepSeek", "xAI (Grok)", "Mistral AI", + "OpenCode Zen", ]; let provider_idx = Select::new() .with_prompt("Which LLM provider do you want to use?") @@ -2251,6 +2263,7 @@ pub fn run_onboarding() -> anyhow::Result> { 7 => ("DeepSeek API key", "deepseek_key", "deepseek"), 8 => ("xAI API key", "xai_key", "xai"), 9 => ("Mistral AI API key", "mistral_key", "mistral"), + 10 => ("OpenCode Zen API key", "opencode_zen_key", "opencode-zen"), _ => unreachable!(), }; diff --git a/src/llm/manager.rs b/src/llm/manager.rs index c3f01a1a6..80c476e2e 100644 --- a/src/llm/manager.rs +++ b/src/llm/manager.rs @@ -58,6 +58,8 @@ impl LlmManager { .ok_or_else(|| LlmError::MissingProviderKey("xai".into()).into()), "mistral" => self.config.mistral_key.clone() .ok_or_else(|| LlmError::MissingProviderKey("mistral".into()).into()), + "opencode-zen" => self.config.opencode_zen_key.clone() + .ok_or_else(|| LlmError::MissingProviderKey("opencode-zen".into()).into()), _ => Err(LlmError::UnknownProvider(provider.into()).into()), } } diff --git a/src/llm/model.rs b/src/llm/model.rs index b00b7dea0..6394a4354 100644 --- a/src/llm/model.rs +++ b/src/llm/model.rs @@ -76,6 +76,7 @@ impl SpacebotModel { "deepseek" => self.call_deepseek(request).await, "xai" => self.call_xai(request).await, "mistral" => self.call_mistral(request).await, + "opencode-zen" => self.call_opencode_zen(request).await, other => Err(CompletionError::ProviderError(format!( "unknown provider: {other}" ))), @@ -764,6 +765,18 @@ impl SpacebotModel { "https://api.mistral.ai/v1/chat/completions", ).await } + + async fn call_opencode_zen( + &self, + request: CompletionRequest, + ) -> Result, CompletionError> { + self.call_openai_compatible( + request, + "opencode-zen", + "OpenCode Zen", + "https://opencode.ai/zen/v1/chat/completions", + ).await + } } // --- Helpers --- diff --git a/src/llm/providers.rs b/src/llm/providers.rs index ea275d7f1..e3ce47ccc 100644 --- a/src/llm/providers.rs +++ b/src/llm/providers.rs @@ -16,6 +16,10 @@ pub async fn init_providers(config: &LlmConfig) -> Result<()> { if config.openai_key.is_some() { tracing::info!("OpenAI provider configured"); } + + if config.opencode_zen_key.is_some() { + tracing::info!("OpenCode Zen provider configured"); + } Ok(()) } diff --git a/src/llm/routing.rs b/src/llm/routing.rs index 8e0cae7a3..5671516cb 100644 --- a/src/llm/routing.rs +++ b/src/llm/routing.rs @@ -253,6 +253,20 @@ pub fn defaults_for_provider(provider: &str) -> RoutingConfig { rate_limit_cooldown_secs: 60, } } + "opencode-zen" => { + let channel: String = "opencode-zen/kimi-k2.5".into(); + let worker: String = "opencode-zen/kimi-k2.5".into(); + RoutingConfig { + channel: channel.clone(), + branch: channel.clone(), + worker: worker.clone(), + compactor: worker.clone(), + cortex: worker.clone(), + task_overrides: HashMap::from([("coding".into(), channel.clone())]), + fallbacks: HashMap::new(), + rate_limit_cooldown_secs: 60, + } + } // Anthropic or unknown — use the standard defaults _ => RoutingConfig::default(), } @@ -271,6 +285,7 @@ pub fn provider_to_prefix(provider: &str) -> &str { "deepseek" => "deepseek/", "xai" => "xai/", "mistral" => "mistral/", + "opencode-zen" => "opencode-zen/", _ => "", } }