Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions interface/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -637,6 +637,7 @@ export interface ProviderStatus {
deepseek: boolean;
xai: boolean;
mistral: boolean;
opencode_zen: boolean;
}

export interface ProvidersResponse {
Expand Down
3 changes: 2 additions & 1 deletion interface/src/components/ModelSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const PROVIDER_LABELS: Record<string, string> = {
openrouter: "OpenRouter",
openai: "OpenAI",
zhipu: "Z.ai (GLM)",
"opencode-zen": "OpenCode Zen",
};

function formatContextWindow(tokens: number | null): string {
Expand Down Expand Up @@ -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),
);
Expand Down
1 change: 1 addition & 0 deletions interface/src/lib/providerIcons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()];
Expand Down
7 changes: 7 additions & 0 deletions interface/src/routes/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
69 changes: 67 additions & 2 deletions src/api/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2007,6 +2007,7 @@ struct ProviderStatus {
deepseek: bool,
xai: bool,
mistral: bool,
opencode_zen: bool,
}

#[derive(Serialize)]
Expand All @@ -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)?;
Expand Down Expand Up @@ -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
Expand All @@ -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(),
)
};

Expand All @@ -2097,6 +2100,7 @@ async fn get_providers(
deepseek,
xai,
mistral,
opencode_zen,
};
let has_any = providers.anthropic
|| providers.openai
Expand All @@ -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 }))
}
Expand All @@ -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,
Expand Down Expand Up @@ -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,
};

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -2699,6 +2711,56 @@ fn curated_models() -> Vec<ModelInfo> {
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,
},
]
}

Expand Down Expand Up @@ -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
}
Expand Down
13 changes: 13 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ pub struct LlmConfig {
pub deepseek_key: Option<String>,
pub xai_key: Option<String>,
pub mistral_key: Option<String>,
pub opencode_zen_key: Option<String>,
}

impl LlmConfig {
Expand All @@ -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()
}
}

Expand Down Expand Up @@ -874,6 +876,7 @@ struct TomlLlmConfig {
deepseek_key: Option<String>,
xai_key: Option<String>,
mistral_key: Option<String>,
opencode_zen_key: Option<String>,
}

#[derive(Deserialize, Default)]
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -2233,6 +2244,7 @@ pub fn run_onboarding() -> anyhow::Result<Option<PathBuf>> {
"DeepSeek",
"xAI (Grok)",
"Mistral AI",
"OpenCode Zen",
];
let provider_idx = Select::new()
.with_prompt("Which LLM provider do you want to use?")
Expand All @@ -2251,6 +2263,7 @@ pub fn run_onboarding() -> anyhow::Result<Option<PathBuf>> {
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!(),
};

Expand Down
2 changes: 2 additions & 0 deletions src/llm/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
}
}
Expand Down
13 changes: 13 additions & 0 deletions src/llm/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
))),
Expand Down Expand Up @@ -764,6 +765,18 @@ impl SpacebotModel {
"https://api.mistral.ai/v1/chat/completions",
).await
}

async fn call_opencode_zen(
&self,
request: CompletionRequest,
) -> Result<completion::CompletionResponse<RawResponse>, CompletionError> {
self.call_openai_compatible(
request,
"opencode-zen",
"OpenCode Zen",
"https://opencode.ai/zen/v1/chat/completions",
).await
}
}

// --- Helpers ---
Expand Down
4 changes: 4 additions & 0 deletions src/llm/providers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}
15 changes: 15 additions & 0 deletions src/llm/routing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}
Expand All @@ -271,6 +285,7 @@ pub fn provider_to_prefix(provider: &str) -> &str {
"deepseek" => "deepseek/",
"xai" => "xai/",
"mistral" => "mistral/",
"opencode-zen" => "opencode-zen/",
_ => "",
}
}
Expand Down