From a4695f68e5dcfcb791b008f412390a1c12bd231e Mon Sep 17 00:00:00 2001 From: Mars Date: Wed, 25 Feb 2026 21:47:45 -0500 Subject: [PATCH 1/4] feat(llm): add Kilo Gateway and OpenCode Go provider support --- README.md | 6 +- docs/content/docs/(configuration)/config.mdx | 15 +- docs/content/docs/(deployment)/roadmap.mdx | 2 +- .../docs/(getting-started)/quickstart.mdx | 4 +- interface/src/api/client.ts | 2 + interface/src/components/ModelSelect.tsx | 11 +- interface/src/lib/providerIcons.tsx | 22 + interface/src/routes/Settings.tsx | 24 +- src/api/models.rs | 126 +----- src/api/providers.rs | 159 ++----- src/config.rs | 394 +++++++++++++++--- src/llm/model.rs | 72 ++-- src/llm/providers.rs | 8 + src/llm/routing.rs | 34 ++ 14 files changed, 542 insertions(+), 337 deletions(-) diff --git a/README.md b/README.md index 0387d04ec..8cb580a59 100644 --- a/README.md +++ b/README.md @@ -184,7 +184,7 @@ coding = "ollama/qwen3" ```toml [llm.provider.my-provider] -api_type = "openai_completions" # or "anthropic" +api_type = "openai_completions" # or "openai_chat_completions", "openai_responses", "anthropic" base_url = "https://my-llm-host.example.com" api_key = "env:MY_PROVIDER_KEY" @@ -192,7 +192,7 @@ api_key = "env:MY_PROVIDER_KEY" channel = "my-provider/my-model" ``` -Additional built-in providers include **NVIDIA**, **MiniMax**, **Moonshot AI (Kimi)**, and **Z.AI Coding Plan** — configure with `nvidia_key`, `minimax_key`, `moonshot_key`, or `zai_coding_plan_key` in `[llm]`. +Additional built-in providers include **Kilo Gateway**, **OpenCode Go**, **NVIDIA**, **MiniMax**, **Moonshot AI (Kimi)**, and **Z.AI Coding Plan** — configure with `kilo_key`, `opencode_go_key`, `nvidia_key`, `minimax_key`, `moonshot_key`, or `zai_coding_plan_key` in `[llm]`. ### Skills @@ -381,7 +381,7 @@ Read the full vision in the [roadmap](docs/content/docs/(deployment)/roadmap.mdx ### Prerequisites - **Rust** 1.85+ ([rustup](https://rustup.rs/)) -- An LLM API key from any supported provider (Anthropic, OpenAI, OpenRouter, Z.ai, Groq, Together, Fireworks, DeepSeek, xAI, Mistral, NVIDIA, MiniMax, Moonshot AI, OpenCode Zen) — or use `spacebot auth login` for Anthropic OAuth +- An LLM API key from any supported provider (Anthropic, OpenAI, OpenRouter, Kilo Gateway, Z.ai, Groq, Together, Fireworks, DeepSeek, xAI, Mistral, NVIDIA, MiniMax, Moonshot AI, OpenCode Zen, OpenCode Go) — or use `spacebot auth login` for Anthropic OAuth ### Build and Run diff --git a/docs/content/docs/(configuration)/config.mdx b/docs/content/docs/(configuration)/config.mdx index f098b6d73..261c975ef 100644 --- a/docs/content/docs/(configuration)/config.mdx +++ b/docs/content/docs/(configuration)/config.mdx @@ -24,6 +24,7 @@ spacebot --config /path/to.toml # CLI override anthropic_key = "env:ANTHROPIC_API_KEY" openai_key = "env:OPENAI_API_KEY" openrouter_key = "env:OPENROUTER_API_KEY" +kilo_key = "env:KILO_API_KEY" zhipu_key = "env:ZHIPU_API_KEY" groq_key = "env:GROQ_API_KEY" together_key = "env:TOGETHER_API_KEY" @@ -32,6 +33,7 @@ deepseek_key = "env:DEEPSEEK_API_KEY" xai_key = "env:XAI_API_KEY" mistral_key = "env:MISTRAL_API_KEY" opencode_zen_key = "env:OPENCODE_ZEN_API_KEY" +opencode_go_key = "env:OPENCODE_GO_API_KEY" # Custom LLM providers (alternative to legacy keys) [llm.provider.my_anthropic] @@ -173,7 +175,7 @@ anthropic_key = "env:ANTHROPIC_API_KEY" This reads `ANTHROPIC_API_KEY` from the environment at startup. If the variable is unset, the value is treated as missing. -LLM keys also have implicit env fallbacks — if no key is set in the TOML, Spacebot checks `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, and `OPENROUTER_API_KEY` automatically. +LLM keys also have implicit env fallbacks — if no key is set in the TOML, Spacebot checks `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `OPENROUTER_API_KEY`, `KILO_API_KEY`, and `OPENCODE_GO_API_KEY` automatically. ## Env-Only Mode @@ -208,6 +210,7 @@ Model names include the provider as a prefix: | Anthropic | `anthropic/` | `anthropic/claude-sonnet-4-20250514` | | OpenAI | `openai/` | `openai/gpt-4o` | | OpenRouter | `openrouter//` | `openrouter/anthropic/claude-sonnet-4-20250514` | +| Kilo Gateway | `kilo//` | `kilo/anthropic/claude-sonnet-4.5` | | Custom provider | `/` | `my_openai/gpt-4o-mini` | You can mix providers across process types. See [Routing](/docs/routing) for the full routing system. @@ -329,6 +332,7 @@ If you define a custom provider with the same ID as a legacy key, your custom co | `anthropic_key` | string | None | Anthropic API key (or `env:VAR_NAME`) | | `openai_key` | string | None | OpenAI API key (or `env:VAR_NAME`) | | `openrouter_key` | string | None | OpenRouter API key (or `env:VAR_NAME`) | +| `kilo_key` | string | None | Kilo Gateway API key (or `env:VAR_NAME`) | | `zhipu_key` | string | None | Zhipu AI (GLM) API key (or `env:VAR_NAME`) | | `groq_key` | string | None | Groq API key (or `env:VAR_NAME`) | | `together_key` | string | None | Together AI API key (or `env:VAR_NAME`) | @@ -337,6 +341,7 @@ If you define a custom provider with the same ID as a legacy key, your custom co | `xai_key` | string | None | XAI API key (or `env:VAR_NAME`) | | `mistral_key` | string | None | Mistral API key (or `env:VAR_NAME`) | | `opencode_zen_key` | string | None | OpenCode Zen API key (or `env:VAR_NAME`) | +| `opencode_go_key` | string | None | OpenCode Go API key (or `env:VAR_NAME`) | #### Custom Providers @@ -344,7 +349,7 @@ Custom providers allow configuring LLM providers with custom endpoints and API t ```toml [llm.provider.] -api_type = "anthropic" # Required - one of: anthropic, openai_completions, openai_responses +api_type = "anthropic" # Required - see supported values below base_url = "https://api..." # Required - valid URL api_key = "env:API_KEY" # Required - API key (supports env:VAR_NAME format) name = "My Provider" # Optional - friendly name for display @@ -352,16 +357,18 @@ name = "My Provider" # Optional - friendly name for display | Field | Type | Required | Description | |-------|------|----------|-------------| -| `api_type` | string | Yes | API protocol type. One of: `anthropic` (Anthropic Messages API), `openai_completions` (OpenAI Chat Completions-compatible API), or `openai_responses` (OpenAI Responses API-compatible) | +| `api_type` | string | Yes | API protocol type. One of: `anthropic`, `openai_completions`, `openai_chat_completions`, `openai_responses`, `gemini`, or `kilo_gateway` | | `base_url` | string | Yes | Base URL of the API endpoint. Must be a valid URL (including protocol) | | `api_key` | string | Yes | API key for authentication. Supports `env:VAR_NAME` syntax to reference environment variables | | `name` | string | No | Optional friendly name for the provider (displayed in logs and UI) | > Note: -> - For `openai_completions` and `openai_responses`, configure `base_url` as the provider root URL (usually without a trailing `/v1`). +> - For `openai_completions`, `openai_chat_completions`, and `openai_responses`, configure `base_url` as the provider root URL (usually without a trailing `/v1`). > - Spacebot appends the endpoint path automatically: > - `openai_completions` -> `/v1/chat/completions` +> - `openai_chat_completions` -> `/chat/completions` > - `openai_responses` -> `/v1/responses` +> - `kilo_gateway` -> `/chat/completions` plus Kilo-required `HTTP-Referer` / `X-Title` headers > - If you include `/v1` in `base_url`, requests can end up with duplicated paths such as `/v1/v1/...`. **Provider ID Requirements:** diff --git a/docs/content/docs/(deployment)/roadmap.mdx b/docs/content/docs/(deployment)/roadmap.mdx index 6711bdd2d..b4460cd98 100644 --- a/docs/content/docs/(deployment)/roadmap.mdx +++ b/docs/content/docs/(deployment)/roadmap.mdx @@ -18,7 +18,7 @@ The full message-in → LLM → response-out pipeline is wired end-to-end across - **Config** — hierarchical TOML with `Config`, `AgentConfig`, `ResolvedAgentConfig`, `Binding`, `MessagingConfig`. File watcher with event filtering and content hash debounce for hot-reload. - **Multi-agent** — per-agent database isolation, `Agent` struct bundles all dependencies - **Database connections** — SQLite + LanceDB + redb per-agent, migrations for all tables -- **LLM** — `SpacebotModel` implements Rig's `CompletionModel`, routes through `LlmManager` via HTTP with retries and fallback chains across 11 providers (Anthropic, OpenAI, OpenRouter, Z.ai, Groq, Together, Fireworks, DeepSeek, xAI, Mistral, OpenCode Zen) +- **LLM** — `SpacebotModel` implements Rig's `CompletionModel`, routes through `LlmManager` via HTTP with retries and fallback chains across 13 providers (Anthropic, OpenAI, OpenRouter, Kilo Gateway, Z.ai, Groq, Together, Fireworks, DeepSeek, xAI, Mistral, OpenCode Zen, OpenCode Go) - **Model routing** — `RoutingConfig` with process-type defaults, task overrides, fallback chains - **Memory** — full stack: types, SQLite store (CRUD + graph), LanceDB (embeddings + vector + FTS), fastembed, hybrid search (RRF fusion). `memory_type` filter wired end-to-end through SearchConfig. `total_cmp` for safe sorting. - **Memory maintenance** — decay + prune implemented diff --git a/docs/content/docs/(getting-started)/quickstart.mdx b/docs/content/docs/(getting-started)/quickstart.mdx index 845107f98..3c7c55319 100644 --- a/docs/content/docs/(getting-started)/quickstart.mdx +++ b/docs/content/docs/(getting-started)/quickstart.mdx @@ -47,7 +47,7 @@ You can also manage updates from **Settings → Updates** in the web UI. - **Rust 1.85+** — `rustup update stable` - **Bun** (optional, for the web UI) — `curl -fsSL https://bun.sh/install | bash` -- **An LLM API key** — Anthropic, OpenAI, or OpenRouter +- **An LLM API key** — Anthropic, OpenAI, OpenRouter, Kilo Gateway, or OpenCode Go ### Install @@ -88,6 +88,8 @@ Create `~/.spacebot/config.toml`: [llm] anthropic_key = "sk-ant-..." # or: openrouter_key = "sk-or-..." +# or: kilo_key = "sk-..." +# or: opencode_go_key = "..." # or: openai_key = "sk-..." # Keys also support env references: anthropic_key = "env:ANTHROPIC_API_KEY" diff --git a/interface/src/api/client.ts b/interface/src/api/client.ts index 2683cc1e3..db27c1631 100644 --- a/interface/src/api/client.ts +++ b/interface/src/api/client.ts @@ -714,6 +714,7 @@ export interface ProviderStatus { openai: boolean; openai_chatgpt: boolean; openrouter: boolean; + kilo: boolean; zhipu: boolean; groq: boolean; together: boolean; @@ -724,6 +725,7 @@ export interface ProviderStatus { gemini: boolean; ollama: boolean; opencode_zen: boolean; + opencode_go: boolean; nvidia: boolean; minimax: boolean; minimax_cn: boolean; diff --git a/interface/src/components/ModelSelect.tsx b/interface/src/components/ModelSelect.tsx index 8abab4391..8a2b3c7df 100644 --- a/interface/src/components/ModelSelect.tsx +++ b/interface/src/components/ModelSelect.tsx @@ -15,6 +15,7 @@ interface ModelSelectProps { const PROVIDER_LABELS: Record = { anthropic: "Anthropic", openrouter: "OpenRouter", + kilo: "Kilo Gateway", openai: "OpenAI", "openai-chatgpt": "ChatGPT Plus (OAuth)", deepseek: "DeepSeek", @@ -27,6 +28,7 @@ const PROVIDER_LABELS: Record = { zhipu: "Z.ai (GLM)", ollama: "Ollama", "opencode-zen": "OpenCode Zen", + "opencode-go": "OpenCode Go", minimax: "MiniMax", "minimax-cn": "MiniMax CN", }; @@ -128,6 +130,7 @@ export function ModelSelect({ const providerOrder = [ "openrouter", + "kilo", "anthropic", "openai", "openai-chatgpt", @@ -141,12 +144,16 @@ export function ModelSelect({ "fireworks", "zhipu", "opencode-zen", + "opencode-go", "minimax", "minimax-cn", ]; + const providerRank = (provider: string) => { + const index = providerOrder.indexOf(provider); + return index === -1 ? Number.MAX_SAFE_INTEGER : index; + }; const sortedProviders = Object.keys(grouped).sort( - (a, b) => - (providerOrder.indexOf(a) ?? 99) - (providerOrder.indexOf(b) ?? 99), + (a, b) => providerRank(a) - providerRank(b), ); return ( diff --git a/interface/src/lib/providerIcons.tsx b/interface/src/lib/providerIcons.tsx index 29487961a..eaf286c79 100644 --- a/interface/src/lib/providerIcons.tsx +++ b/interface/src/lib/providerIcons.tsx @@ -90,6 +90,26 @@ function OllamaIcon({ size = 24, className }: IconProps) { ); } +function KiloIcon({ size = 24, className }: IconProps) { + return ( + + ); +} + export function ProviderIcon({ provider, className = "text-ink-faint", size = 24 }: ProviderIconProps) { const iconProps: Partial = { size, @@ -101,6 +121,7 @@ export function ProviderIcon({ provider, className = "text-ink-faint", size = 24 openai: OpenAI, "openai-chatgpt": OpenAI, openrouter: OpenRouter, + kilo: KiloIcon, groq: Groq, mistral: Mistral, gemini: Google, @@ -112,6 +133,7 @@ export function ProviderIcon({ provider, className = "text-ink-faint", size = 24 "zai-coding-plan": ZAI, ollama: OllamaIcon, "opencode-zen": OpenCodeZenIcon, + "opencode-go": OpenCodeZenIcon, nvidia: NvidiaIcon, minimax: Minimax, "minimax-cn": Minimax, diff --git a/interface/src/routes/Settings.tsx b/interface/src/routes/Settings.tsx index 3df6b4d15..0ecba37eb 100644 --- a/interface/src/routes/Settings.tsx +++ b/interface/src/routes/Settings.tsx @@ -78,6 +78,14 @@ const PROVIDERS = [ envVar: "OPENROUTER_API_KEY", defaultModel: "openrouter/anthropic/claude-sonnet-4", }, + { + id: "kilo", + name: "Kilo Gateway", + description: "OpenAI-compatible multi-provider gateway", + placeholder: "sk-...", + envVar: "KILO_API_KEY", + defaultModel: "kilo/anthropic/claude-sonnet-4.5", + }, { id: "opencode-zen", name: "OpenCode Zen", @@ -86,6 +94,14 @@ const PROVIDERS = [ envVar: "OPENCODE_ZEN_API_KEY", defaultModel: "opencode-zen/kimi-k2.5", }, + { + id: "opencode-go", + name: "OpenCode Go", + description: "Lite OpenCode model catalog and limits", + placeholder: "...", + envVar: "OPENCODE_GO_API_KEY", + defaultModel: "opencode-go/kimi-k2.5", + }, { id: "anthropic", name: "Anthropic", @@ -529,9 +545,9 @@ export function Settings() { }; return ( -
+
{/* Sidebar */} -
+
Settings @@ -551,13 +567,13 @@ export function Settings() {
{/* Content */} -
+

{SECTIONS.find((s) => s.id === activeSection)?.label}

-
+
{activeSection === "providers" ? (
{/* Section header */} diff --git a/src/api/models.rs b/src/api/models.rs index aa26da052..f988bf6b6 100644 --- a/src/api/models.rs +++ b/src/api/models.rs @@ -105,6 +105,7 @@ fn direct_provider_mapping(models_dev_id: &str) -> Option<&'static str> { match models_dev_id { "anthropic" => Some("anthropic"), "openai" => Some("openai"), + "kilo" => Some("kilo"), "deepseek" => Some("deepseek"), "xai" => Some("xai"), "mistral" => Some("mistral"), @@ -113,6 +114,11 @@ fn direct_provider_mapping(models_dev_id: &str) -> Option<&'static str> { "togetherai" => Some("together"), "fireworks-ai" => Some("fireworks"), "zhipuai" => Some("zhipu"), + "opencode" => Some("opencode-zen"), + "opencode-go" => Some("opencode-go"), + "zai-coding-plan" => Some("zai-coding-plan"), + "minimax" => Some("minimax"), + "moonshotai" => Some("moonshot"), _ => None, } } @@ -141,108 +147,7 @@ fn as_openai_chatgpt_model(model: &ModelInfo) -> Option { /// Models from providers not in models.dev (private/custom endpoints). fn extra_models() -> Vec { vec![ - ModelInfo { - id: "opencode-zen/kimi-k2.5".into(), - name: "Kimi K2.5".into(), - provider: "opencode-zen".into(), - context_window: None, - tool_call: true, - reasoning: true, - input_audio: false, - }, - ModelInfo { - id: "opencode-zen/kimi-k2".into(), - name: "Kimi K2".into(), - provider: "opencode-zen".into(), - context_window: None, - tool_call: true, - reasoning: false, - input_audio: false, - }, - ModelInfo { - id: "opencode-zen/kimi-k2-thinking".into(), - name: "Kimi K2 Thinking".into(), - provider: "opencode-zen".into(), - context_window: None, - tool_call: true, - reasoning: true, - input_audio: false, - }, - ModelInfo { - id: "opencode-zen/glm-5".into(), - name: "GLM 5".into(), - provider: "opencode-zen".into(), - context_window: None, - tool_call: true, - reasoning: false, - input_audio: false, - }, - ModelInfo { - id: "opencode-zen/minimax-m2.5".into(), - name: "MiniMax M2.5".into(), - provider: "opencode-zen".into(), - context_window: None, - tool_call: true, - reasoning: false, - input_audio: false, - }, - ModelInfo { - id: "opencode-zen/qwen3-coder".into(), - name: "Qwen3 Coder 480B".into(), - provider: "opencode-zen".into(), - context_window: None, - tool_call: true, - reasoning: false, - input_audio: false, - }, - ModelInfo { - id: "opencode-zen/big-pickle".into(), - name: "Big Pickle".into(), - provider: "opencode-zen".into(), - context_window: None, - tool_call: true, - reasoning: false, - input_audio: false, - }, - // Z.AI Coding Plan - ModelInfo { - id: "zai-coding-plan/glm-4.7".into(), - name: "GLM 4.7 (Coding)".into(), - provider: "zai-coding-plan".into(), - context_window: None, - tool_call: true, - reasoning: false, - input_audio: false, - }, - ModelInfo { - id: "zai-coding-plan/glm-5".into(), - name: "GLM 5 (Coding)".into(), - provider: "zai-coding-plan".into(), - context_window: None, - tool_call: true, - reasoning: false, - input_audio: false, - }, - ModelInfo { - id: "zai-coding-plan/glm-4.5-air".into(), - name: "GLM 4.5 Air (Coding)".into(), - provider: "zai-coding-plan".into(), - context_window: None, - tool_call: true, - reasoning: false, - input_audio: false, - }, - // MiniMax - ModelInfo { - id: "minimax/MiniMax-M2.5".into(), - name: "MiniMax M2.5".into(), - provider: "minimax".into(), - context_window: Some(200000), - tool_call: true, - reasoning: true, - input_audio: false, - }, - // MiniMax CN + // MiniMax CN - China-specific endpoint, not on models.dev ModelInfo { id: "minimax-cn/MiniMax-M2.5".into(), name: "MiniMax M2.5".into(), @@ -252,16 +157,7 @@ fn extra_models() -> Vec { reasoning: true, input_audio: false, }, - // Moonshot AI (Kimi) - ModelInfo { - id: "moonshot/kimi-k2.5".into(), - name: "Kimi K2.5".into(), - provider: "moonshot".into(), - context_window: None, - tool_call: true, - reasoning: true, - input_audio: false, - }, + // Moonshot AI (Kimi) - moonshot-v1-8k not on models.dev ModelInfo { id: "moonshot/moonshot-v1-8k".into(), name: "Moonshot V1 8K".into(), @@ -406,6 +302,9 @@ pub(super) async fn configured_providers(config_path: &std::path::Path) -> Vec<& if has_key("openrouter_key", "OPENROUTER_API_KEY") { providers.push("openrouter"); } + if has_key("kilo_key", "KILO_API_KEY") { + providers.push("kilo"); + } if has_key("zhipu_key", "ZHIPU_API_KEY") { providers.push("zhipu"); } @@ -433,6 +332,9 @@ pub(super) async fn configured_providers(config_path: &std::path::Path) -> Vec<& if has_key("opencode_zen_key", "OPENCODE_ZEN_API_KEY") { providers.push("opencode-zen"); } + if has_key("opencode_go_key", "OPENCODE_GO_API_KEY") { + providers.push("opencode-go"); + } if has_key("minimax_key", "MINIMAX_API_KEY") { providers.push("minimax"); } diff --git a/src/api/providers.rs b/src/api/providers.rs index f2df04bc1..e26209aae 100644 --- a/src/api/providers.rs +++ b/src/api/providers.rs @@ -43,6 +43,7 @@ pub(super) struct ProviderStatus { openai: bool, openai_chatgpt: bool, openrouter: bool, + kilo: bool, zhipu: bool, groq: bool, together: bool, @@ -53,6 +54,7 @@ pub(super) struct ProviderStatus { gemini: bool, ollama: bool, opencode_zen: bool, + opencode_go: bool, nvidia: bool, minimax: bool, minimax_cn: bool, @@ -127,6 +129,7 @@ fn provider_toml_key(provider: &str) -> Option<&'static str> { "anthropic" => Some("anthropic_key"), "openai" => Some("openai_key"), "openrouter" => Some("openrouter_key"), + "kilo" => Some("kilo_key"), "zhipu" => Some("zhipu_key"), "groq" => Some("groq_key"), "together" => Some("together_key"), @@ -137,6 +140,7 @@ fn provider_toml_key(provider: &str) -> Option<&'static str> { "gemini" => Some("gemini_key"), "ollama" => Some("ollama_base_url"), "opencode-zen" => Some("opencode_zen_key"), + "opencode-go" => Some("opencode_go_key"), "nvidia" => Some("nvidia_key"), "minimax" => Some("minimax_key"), "minimax-cn" => Some("minimax_cn_key"), @@ -165,133 +169,8 @@ fn normalize_openai_chatgpt_model(model: &str) -> Option { } fn build_test_llm_config(provider: &str, credential: &str) -> crate::config::LlmConfig { - use crate::config::{ApiType, ProviderConfig}; - let mut providers = HashMap::new(); - let provider_config = match provider { - "anthropic" => Some(ProviderConfig { - api_type: ApiType::Anthropic, - base_url: "https://api.anthropic.com".to_string(), - api_key: credential.to_string(), - name: None, - use_bearer_auth: false, - }), - "openai" => Some(ProviderConfig { - api_type: ApiType::OpenAiCompletions, - base_url: "https://api.openai.com".to_string(), - api_key: credential.to_string(), - name: None, - use_bearer_auth: false, - }), - "openrouter" => Some(ProviderConfig { - api_type: ApiType::OpenAiCompletions, - base_url: "https://openrouter.ai/api".to_string(), - api_key: credential.to_string(), - name: None, - use_bearer_auth: false, - }), - "zhipu" => Some(ProviderConfig { - api_type: ApiType::OpenAiCompletions, - base_url: "https://api.z.ai/api/paas/v4".to_string(), - api_key: credential.to_string(), - name: None, - use_bearer_auth: false, - }), - "groq" => Some(ProviderConfig { - api_type: ApiType::OpenAiCompletions, - base_url: "https://api.groq.com/openai".to_string(), - api_key: credential.to_string(), - name: None, - use_bearer_auth: false, - }), - "together" => Some(ProviderConfig { - api_type: ApiType::OpenAiCompletions, - base_url: "https://api.together.xyz".to_string(), - api_key: credential.to_string(), - name: None, - use_bearer_auth: false, - }), - "fireworks" => Some(ProviderConfig { - api_type: ApiType::OpenAiCompletions, - base_url: "https://api.fireworks.ai/inference".to_string(), - api_key: credential.to_string(), - name: None, - use_bearer_auth: false, - }), - "deepseek" => Some(ProviderConfig { - api_type: ApiType::OpenAiCompletions, - base_url: "https://api.deepseek.com".to_string(), - api_key: credential.to_string(), - name: None, - use_bearer_auth: false, - }), - "xai" => Some(ProviderConfig { - api_type: ApiType::OpenAiCompletions, - base_url: "https://api.x.ai".to_string(), - api_key: credential.to_string(), - name: None, - use_bearer_auth: false, - }), - "mistral" => Some(ProviderConfig { - api_type: ApiType::OpenAiCompletions, - base_url: "https://api.mistral.ai".to_string(), - api_key: credential.to_string(), - name: None, - use_bearer_auth: false, - }), - "gemini" => Some(ProviderConfig { - api_type: ApiType::Gemini, - base_url: crate::config::GEMINI_PROVIDER_BASE_URL.to_string(), - api_key: credential.to_string(), - name: None, - use_bearer_auth: false, - }), - "opencode-zen" => Some(ProviderConfig { - api_type: ApiType::OpenAiCompletions, - base_url: "https://opencode.ai/zen".to_string(), - api_key: credential.to_string(), - name: None, - use_bearer_auth: false, - }), - "nvidia" => Some(ProviderConfig { - api_type: ApiType::OpenAiCompletions, - base_url: "https://integrate.api.nvidia.com".to_string(), - api_key: credential.to_string(), - name: None, - use_bearer_auth: false, - }), - "minimax" => Some(ProviderConfig { - api_type: ApiType::Anthropic, - base_url: "https://api.minimax.io/anthropic".to_string(), - api_key: credential.to_string(), - name: None, - use_bearer_auth: false, - }), - "minimax-cn" => Some(ProviderConfig { - api_type: ApiType::Anthropic, - base_url: "https://api.minimaxi.com/anthropic".to_string(), - api_key: credential.to_string(), - name: None, - use_bearer_auth: false, - }), - "moonshot" => Some(ProviderConfig { - api_type: ApiType::OpenAiCompletions, - base_url: "https://api.moonshot.ai".to_string(), - api_key: credential.to_string(), - name: None, - use_bearer_auth: false, - }), - "zai-coding-plan" => Some(ProviderConfig { - api_type: ApiType::OpenAiCompletions, - base_url: "https://api.z.ai/api/coding/paas/v4".to_string(), - api_key: credential.to_string(), - name: None, - use_bearer_auth: false, - }), - _ => None, - }; - - if let Some(provider_config) = provider_config { + if let Some(provider_config) = crate::config::default_provider_config(provider, credential) { providers.insert(provider.to_string(), provider_config); } @@ -299,6 +178,7 @@ fn build_test_llm_config(provider: &str, credential: &str) -> crate::config::Llm anthropic_key: (provider == "anthropic").then(|| credential.to_string()), openai_key: (provider == "openai").then(|| credential.to_string()), openrouter_key: (provider == "openrouter").then(|| credential.to_string()), + kilo_key: (provider == "kilo").then(|| credential.to_string()), zhipu_key: (provider == "zhipu").then(|| credential.to_string()), groq_key: (provider == "groq").then(|| credential.to_string()), together_key: (provider == "together").then(|| credential.to_string()), @@ -310,6 +190,7 @@ fn build_test_llm_config(provider: &str, credential: &str) -> crate::config::Llm ollama_key: None, ollama_base_url: (provider == "ollama").then(|| credential.to_string()), opencode_zen_key: (provider == "opencode-zen").then(|| credential.to_string()), + opencode_go_key: (provider == "opencode-go").then(|| credential.to_string()), nvidia_key: (provider == "nvidia").then(|| credential.to_string()), minimax_key: (provider == "minimax").then(|| credential.to_string()), minimax_cn_key: (provider == "minimax-cn").then(|| credential.to_string()), @@ -450,6 +331,7 @@ pub(super) async fn get_providers( openai, openai_chatgpt, openrouter, + kilo, zhipu, groq, together, @@ -460,6 +342,7 @@ pub(super) async fn get_providers( gemini, ollama, opencode_zen, + opencode_go, nvidia, minimax, minimax_cn, @@ -491,6 +374,7 @@ pub(super) async fn get_providers( has_value("openai_key", "OPENAI_API_KEY"), openai_oauth_configured, has_value("openrouter_key", "OPENROUTER_API_KEY"), + has_value("kilo_key", "KILO_API_KEY"), has_value("zhipu_key", "ZHIPU_API_KEY"), has_value("groq_key", "GROQ_API_KEY"), has_value("together_key", "TOGETHER_API_KEY"), @@ -502,6 +386,7 @@ pub(super) async fn get_providers( has_value("ollama_base_url", "OLLAMA_BASE_URL") || has_value("ollama_key", "OLLAMA_API_KEY"), has_value("opencode_zen_key", "OPENCODE_ZEN_API_KEY"), + has_value("opencode_go_key", "OPENCODE_GO_API_KEY"), has_value("nvidia_key", "NVIDIA_API_KEY"), has_value("minimax_key", "MINIMAX_API_KEY"), has_value("minimax_cn_key", "MINIMAX_CN_API_KEY"), @@ -514,6 +399,7 @@ pub(super) async fn get_providers( std::env::var("OPENAI_API_KEY").is_ok(), openai_oauth_configured, std::env::var("OPENROUTER_API_KEY").is_ok(), + std::env::var("KILO_API_KEY").is_ok(), std::env::var("ZHIPU_API_KEY").is_ok(), std::env::var("GROQ_API_KEY").is_ok(), std::env::var("TOGETHER_API_KEY").is_ok(), @@ -524,6 +410,7 @@ pub(super) async fn get_providers( std::env::var("GEMINI_API_KEY").is_ok(), std::env::var("OLLAMA_BASE_URL").is_ok() || std::env::var("OLLAMA_API_KEY").is_ok(), std::env::var("OPENCODE_ZEN_API_KEY").is_ok(), + std::env::var("OPENCODE_GO_API_KEY").is_ok(), std::env::var("NVIDIA_API_KEY").is_ok(), std::env::var("MINIMAX_API_KEY").is_ok(), std::env::var("MINIMAX_CN_API_KEY").is_ok(), @@ -537,6 +424,7 @@ pub(super) async fn get_providers( openai, openai_chatgpt, openrouter, + kilo, zhipu, groq, together, @@ -547,6 +435,7 @@ pub(super) async fn get_providers( gemini, ollama, opencode_zen, + opencode_go, nvidia, minimax, minimax_cn, @@ -557,6 +446,7 @@ pub(super) async fn get_providers( || providers.openai || providers.openai_chatgpt || providers.openrouter + || providers.kilo || providers.zhipu || providers.groq || providers.together @@ -567,6 +457,7 @@ pub(super) async fn get_providers( || providers.gemini || providers.ollama || providers.opencode_zen + || providers.opencode_go || providers.nvidia || providers.minimax || providers.minimax_cn @@ -818,7 +709,9 @@ pub(super) async fn update_provider( State(state): State>, Json(request): Json, ) -> Result, StatusCode> { - let Some(key_name) = provider_toml_key(&request.provider) else { + let normalized_provider = request.provider.trim().to_lowercase(); + let normalized_model = request.model.trim(); + let Some(key_name) = provider_toml_key(&normalized_provider) else { return Ok(Json(ProviderUpdateResponse { success: false, message: format!("Unknown provider: {}", request.provider), @@ -839,7 +732,7 @@ pub(super) async fn update_provider( })); } - if !model_matches_provider(&request.provider, &request.model) { + if !model_matches_provider(&normalized_provider, normalized_model) { return Ok(Json(ProviderUpdateResponse { success: false, message: format!( @@ -868,7 +761,7 @@ pub(super) async fn update_provider( } doc["llm"][key_name] = toml_edit::value(request.api_key); - apply_model_routing(&mut doc, request.model.as_str()); + apply_model_routing(&mut doc, normalized_model); tokio::fs::write(&config_path, doc.to_string()) .await @@ -891,7 +784,8 @@ pub(super) async fn update_provider( pub(super) async fn test_provider_model( Json(request): Json, ) -> Result, StatusCode> { - if provider_toml_key(&request.provider).is_none() { + let normalized_provider = request.provider.trim().to_lowercase(); + if provider_toml_key(&normalized_provider).is_none() { return Ok(Json(ProviderModelTestResponse { success: false, message: format!("Unknown provider: {}", request.provider), @@ -921,7 +815,7 @@ pub(super) async fn test_provider_model( })); } - if !model_matches_provider(&request.provider, &request.model) { + if !model_matches_provider(&normalized_provider, &request.model) { return Ok(Json(ProviderModelTestResponse { success: false, message: format!( @@ -934,7 +828,7 @@ pub(super) async fn test_provider_model( })); } - let llm_config = build_test_llm_config(&request.provider, request.api_key.trim()); + let llm_config = build_test_llm_config(&normalized_provider, request.api_key.trim()); let llm_manager = match crate::llm::LlmManager::new(llm_config).await { Ok(manager) => Arc::new(manager), Err(error) => { @@ -975,6 +869,7 @@ pub(super) async fn delete_provider( State(state): State>, axum::extract::Path(provider): axum::extract::Path, ) -> Result, StatusCode> { + let provider = provider.trim().to_lowercase(); // OpenAI ChatGPT OAuth credentials are stored as a separate JSON file, // not in the TOML config, so handle removal separately. if provider == "openai-chatgpt" { diff --git a/src/config.rs b/src/config.rs index 4a54e8de7..79da09fbd 100644 --- a/src/config.rs +++ b/src/config.rs @@ -137,9 +137,13 @@ impl Default for MetricsConfig { /// API types supported by LLM providers. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ApiType { - /// OpenAI Completions API (https://api.openai.com/v1/completions) + /// OpenAI Chat Completions API (`/v1/chat/completions`) OpenAiCompletions, - /// OpenAI Responses API (https://api.openai.com/v1/chat/completions) + /// OpenAI-compatible Chat Completions API (`/chat/completions`) + OpenAiChatCompletions, + /// Kilo Gateway API (`/chat/completions`) with required gateway headers + KiloGateway, + /// OpenAI Responses API (`/v1/responses`) OpenAiResponses, /// Anthropic Messages API (https://api.anthropic.com/v1/messages) Anthropic, @@ -154,12 +158,14 @@ impl<'de> serde::Deserialize<'de> for ApiType { let s = String::deserialize(deserializer)?; match s.as_str() { "openai_completions" => Ok(Self::OpenAiCompletions), + "openai_chat_completions" => Ok(Self::OpenAiChatCompletions), + "kilo_gateway" => Ok(Self::KiloGateway), "openai_responses" => Ok(Self::OpenAiResponses), "anthropic" => Ok(Self::Anthropic), "gemini" => Ok(Self::Gemini), other => Err(serde::de::Error::invalid_value( serde::de::Unexpected::Str(other), - &"one of \"openai_completions\", \"openai_responses\", \"anthropic\", or \"gemini\"", + &"one of \"openai_completions\", \"openai_chat_completions\", \"kilo_gateway\", \"openai_responses\", \"anthropic\", or \"gemini\"", )), } } @@ -196,6 +202,7 @@ pub struct LlmConfig { pub anthropic_key: Option, pub openai_key: Option, pub openrouter_key: Option, + pub kilo_key: Option, pub zhipu_key: Option, pub groq_key: Option, pub together_key: Option, @@ -207,6 +214,7 @@ pub struct LlmConfig { pub ollama_key: Option, pub ollama_base_url: Option, pub opencode_zen_key: Option, + pub opencode_go_key: Option, pub nvidia_key: Option, pub minimax_key: Option, pub minimax_cn_key: Option, @@ -230,6 +238,7 @@ impl std::fmt::Debug for LlmConfig { "openrouter_key", &self.openrouter_key.as_ref().map(|_| "[REDACTED]"), ) + .field("kilo_key", &self.kilo_key.as_ref().map(|_| "[REDACTED]")) .field("zhipu_key", &self.zhipu_key.as_ref().map(|_| "[REDACTED]")) .field("groq_key", &self.groq_key.as_ref().map(|_| "[REDACTED]")) .field( @@ -262,6 +271,10 @@ impl std::fmt::Debug for LlmConfig { "opencode_zen_key", &self.opencode_zen_key.as_ref().map(|_| "[REDACTED]"), ) + .field( + "opencode_go_key", + &self.opencode_go_key.as_ref().map(|_| "[REDACTED]"), + ) .field( "nvidia_key", &self.nvidia_key.as_ref().map(|_| "[REDACTED]"), @@ -289,6 +302,7 @@ impl LlmConfig { self.anthropic_key.is_some() || self.openai_key.is_some() || self.openrouter_key.is_some() + || self.kilo_key.is_some() || self.zhipu_key.is_some() || self.groq_key.is_some() || self.together_key.is_some() @@ -300,6 +314,7 @@ impl LlmConfig { || self.ollama_key.is_some() || self.ollama_base_url.is_some() || self.opencode_zen_key.is_some() + || self.opencode_go_key.is_some() || self.nvidia_key.is_some() || self.minimax_key.is_some() || self.minimax_cn_key.is_some() @@ -312,7 +327,9 @@ impl LlmConfig { const ANTHROPIC_PROVIDER_BASE_URL: &str = "https://api.anthropic.com"; const OPENAI_PROVIDER_BASE_URL: &str = "https://api.openai.com"; const OPENROUTER_PROVIDER_BASE_URL: &str = "https://openrouter.ai/api"; +const KILO_PROVIDER_BASE_URL: &str = "https://api.kilo.ai/api/gateway"; const OPENCODE_ZEN_PROVIDER_BASE_URL: &str = "https://opencode.ai/zen"; +const OPENCODE_GO_PROVIDER_BASE_URL: &str = "https://opencode.ai/zen/go"; const MINIMAX_PROVIDER_BASE_URL: &str = "https://api.minimax.io/anthropic"; const MINIMAX_CN_PROVIDER_BASE_URL: &str = "https://api.minimaxi.com/anthropic"; const MOONSHOT_PROVIDER_BASE_URL: &str = "https://api.moonshot.ai"; @@ -329,6 +346,173 @@ const FIREWORKS_PROVIDER_BASE_URL: &str = "https://api.fireworks.ai/inference"; pub(crate) const GEMINI_PROVIDER_BASE_URL: &str = "https://generativelanguage.googleapis.com/v1beta/openai"; +/// Returns the default ProviderConfig for a provider ID and API key. +/// Used by API tests and other code that needs provider configs without duplicating metadata. +pub(crate) fn default_provider_config( + provider_id: &str, + api_key: impl Into, +) -> Option { + let api_key = api_key.into(); + Some(match provider_id { + "anthropic" => ProviderConfig { + api_type: ApiType::Anthropic, + base_url: ANTHROPIC_PROVIDER_BASE_URL.to_string(), + api_key, + name: None, + use_bearer_auth: false, + }, + "openai" => ProviderConfig { + api_type: ApiType::OpenAiCompletions, + base_url: OPENAI_PROVIDER_BASE_URL.to_string(), + api_key, + name: None, + use_bearer_auth: false, + }, + "openrouter" => ProviderConfig { + api_type: ApiType::OpenAiCompletions, + base_url: OPENROUTER_PROVIDER_BASE_URL.to_string(), + api_key, + name: None, + use_bearer_auth: false, + }, + "kilo" => ProviderConfig { + api_type: ApiType::KiloGateway, + base_url: KILO_PROVIDER_BASE_URL.to_string(), + api_key, + name: Some("Kilo Gateway".to_string()), + use_bearer_auth: false, + }, + "zhipu" => ProviderConfig { + api_type: ApiType::OpenAiChatCompletions, + base_url: ZHIPU_PROVIDER_BASE_URL.to_string(), + api_key, + name: Some("Z.AI (GLM)".to_string()), + use_bearer_auth: false, + }, + "groq" => ProviderConfig { + api_type: ApiType::OpenAiCompletions, + base_url: GROQ_PROVIDER_BASE_URL.to_string(), + api_key, + name: None, + use_bearer_auth: false, + }, + "together" => ProviderConfig { + api_type: ApiType::OpenAiCompletions, + base_url: TOGETHER_PROVIDER_BASE_URL.to_string(), + api_key, + name: None, + use_bearer_auth: false, + }, + "fireworks" => ProviderConfig { + api_type: ApiType::OpenAiCompletions, + base_url: FIREWORKS_PROVIDER_BASE_URL.to_string(), + api_key, + name: None, + use_bearer_auth: false, + }, + "deepseek" => ProviderConfig { + api_type: ApiType::OpenAiCompletions, + base_url: DEEPSEEK_PROVIDER_BASE_URL.to_string(), + api_key, + name: None, + use_bearer_auth: false, + }, + "xai" => ProviderConfig { + api_type: ApiType::OpenAiCompletions, + base_url: XAI_PROVIDER_BASE_URL.to_string(), + api_key, + name: None, + use_bearer_auth: false, + }, + "mistral" => ProviderConfig { + api_type: ApiType::OpenAiCompletions, + base_url: MISTRAL_PROVIDER_BASE_URL.to_string(), + api_key, + name: None, + use_bearer_auth: false, + }, + "gemini" => ProviderConfig { + api_type: ApiType::Gemini, + base_url: GEMINI_PROVIDER_BASE_URL.to_string(), + api_key, + name: None, + use_bearer_auth: false, + }, + "opencode-zen" => ProviderConfig { + api_type: ApiType::OpenAiCompletions, + base_url: OPENCODE_ZEN_PROVIDER_BASE_URL.to_string(), + api_key, + name: None, + use_bearer_auth: false, + }, + "opencode-go" => ProviderConfig { + api_type: ApiType::OpenAiCompletions, + base_url: OPENCODE_GO_PROVIDER_BASE_URL.to_string(), + api_key, + name: None, + use_bearer_auth: false, + }, + "nvidia" => ProviderConfig { + api_type: ApiType::OpenAiCompletions, + base_url: NVIDIA_PROVIDER_BASE_URL.to_string(), + api_key, + name: None, + use_bearer_auth: false, + }, + "minimax" => ProviderConfig { + api_type: ApiType::Anthropic, + base_url: MINIMAX_PROVIDER_BASE_URL.to_string(), + api_key, + name: None, + use_bearer_auth: false, + }, + "minimax-cn" => ProviderConfig { + api_type: ApiType::Anthropic, + base_url: MINIMAX_CN_PROVIDER_BASE_URL.to_string(), + api_key, + name: None, + use_bearer_auth: false, + }, + "moonshot" => ProviderConfig { + api_type: ApiType::OpenAiCompletions, + base_url: MOONSHOT_PROVIDER_BASE_URL.to_string(), + api_key, + name: None, + use_bearer_auth: false, + }, + "zai-coding-plan" => ProviderConfig { + api_type: ApiType::OpenAiChatCompletions, + base_url: ZAI_CODING_PLAN_BASE_URL.to_string(), + api_key, + name: Some("Z.AI Coding Plan".to_string()), + use_bearer_auth: false, + }, + _ => return None, + }) +} + +fn add_shorthand_provider( + providers: &mut std::collections::HashMap, + provider_id: &str, + key: Option, + api_type: ApiType, + base_url: &str, + name: Option<&str>, + use_bearer_auth: bool, +) { + if let Some(api_key) = key { + providers + .entry(provider_id.to_string()) + .or_insert_with(|| ProviderConfig { + api_type, + base_url: base_url.to_string(), + api_key, + name: name.map(str::to_string), + use_bearer_auth, + }); + } +} + /// Defaults inherited by all agents. Individual agents can override any field. #[derive(Clone)] pub struct DefaultsConfig { @@ -1603,6 +1787,7 @@ struct TomlLlmConfigFields { anthropic_key: Option, openai_key: Option, openrouter_key: Option, + kilo_key: Option, zhipu_key: Option, groq_key: Option, together_key: Option, @@ -1614,6 +1799,7 @@ struct TomlLlmConfigFields { ollama_key: Option, ollama_base_url: Option, opencode_zen_key: Option, + opencode_go_key: Option, nvidia_key: Option, minimax_key: Option, minimax_cn_key: Option, @@ -1631,6 +1817,7 @@ struct TomlLlmConfig { anthropic_key: Option, openai_key: Option, openrouter_key: Option, + kilo_key: Option, zhipu_key: Option, groq_key: Option, together_key: Option, @@ -1642,6 +1829,7 @@ struct TomlLlmConfig { ollama_key: Option, ollama_base_url: Option, opencode_zen_key: Option, + opencode_go_key: Option, nvidia_key: Option, minimax_key: Option, minimax_cn_key: Option, @@ -1684,6 +1872,7 @@ impl<'de> Deserialize<'de> for TomlLlmConfig { anthropic_key: fields.anthropic_key, openai_key: fields.openai_key, openrouter_key: fields.openrouter_key, + kilo_key: fields.kilo_key, zhipu_key: fields.zhipu_key, groq_key: fields.groq_key, together_key: fields.together_key, @@ -1695,6 +1884,7 @@ impl<'de> Deserialize<'de> for TomlLlmConfig { ollama_key: fields.ollama_key, ollama_base_url: fields.ollama_base_url, opencode_zen_key: fields.opencode_zen_key, + opencode_go_key: fields.opencode_go_key, nvidia_key: fields.nvidia_key, minimax_key: fields.minimax_key, minimax_cn_key: fields.minimax_cn_key, @@ -2209,6 +2399,7 @@ impl Config { let has_legacy_keys = std::env::var("ANTHROPIC_API_KEY").is_ok() || std::env::var("OPENAI_API_KEY").is_ok() || std::env::var("OPENROUTER_API_KEY").is_ok() + || std::env::var("KILO_API_KEY").is_ok() || std::env::var("ZHIPU_API_KEY").is_ok() || std::env::var("GROQ_API_KEY").is_ok() || std::env::var("TOGETHER_API_KEY").is_ok() @@ -2220,7 +2411,9 @@ impl Config { || std::env::var("OLLAMA_API_KEY").is_ok() || std::env::var("OLLAMA_BASE_URL").is_ok() || std::env::var("OPENCODE_ZEN_API_KEY").is_ok() + || std::env::var("OPENCODE_GO_API_KEY").is_ok() || std::env::var("MINIMAX_API_KEY").is_ok() + || std::env::var("MINIMAX_CN_API_KEY").is_ok() || std::env::var("MOONSHOT_API_KEY").is_ok() || std::env::var("ZAI_CODING_PLAN_API_KEY").is_ok(); @@ -2241,7 +2434,10 @@ impl Config { || std::env::var("ANTHROPIC_OAUTH_TOKEN").is_ok() || std::env::var("OPENAI_API_KEY").is_ok() || std::env::var("OPENROUTER_API_KEY").is_ok() - || std::env::var("OPENCODE_ZEN_API_KEY").is_ok(); + || std::env::var("KILO_API_KEY").is_ok() + || std::env::var("OPENCODE_ZEN_API_KEY").is_ok() + || std::env::var("OPENCODE_GO_API_KEY").is_ok() + || std::env::var("MINIMAX_CN_API_KEY").is_ok(); !has_provider_env_vars && !has_legacy_bootstrap_vars } @@ -2284,6 +2480,7 @@ impl Config { .or_else(|| std::env::var("ANTHROPIC_AUTH_TOKEN").ok()), openai_key: std::env::var("OPENAI_API_KEY").ok(), openrouter_key: std::env::var("OPENROUTER_API_KEY").ok(), + kilo_key: std::env::var("KILO_API_KEY").ok(), zhipu_key: std::env::var("ZHIPU_API_KEY").ok(), groq_key: std::env::var("GROQ_API_KEY").ok(), together_key: std::env::var("TOGETHER_API_KEY").ok(), @@ -2295,6 +2492,7 @@ impl Config { ollama_key: std::env::var("OLLAMA_API_KEY").ok(), ollama_base_url: std::env::var("OLLAMA_BASE_URL").ok(), opencode_zen_key: std::env::var("OPENCODE_ZEN_API_KEY").ok(), + opencode_go_key: std::env::var("OPENCODE_GO_API_KEY").ok(), nvidia_key: std::env::var("NVIDIA_API_KEY").ok(), minimax_key: std::env::var("MINIMAX_API_KEY").ok(), minimax_cn_key: std::env::var("MINIMAX_CN_API_KEY").ok(), @@ -2342,37 +2540,53 @@ impl Config { }); } - if let Some(zhipu_key) = llm.zhipu_key.clone() { - llm.providers - .entry("zhipu".to_string()) - .or_insert_with(|| ProviderConfig { - api_type: ApiType::OpenAiCompletions, - base_url: ZHIPU_PROVIDER_BASE_URL.to_string(), - api_key: zhipu_key, - name: None, - use_bearer_auth: false, - }); - } + add_shorthand_provider( + &mut llm.providers, + "kilo", + llm.kilo_key.clone(), + ApiType::KiloGateway, + KILO_PROVIDER_BASE_URL, + Some("Kilo Gateway"), + false, + ); + add_shorthand_provider( + &mut llm.providers, + "zhipu", + llm.zhipu_key.clone(), + ApiType::OpenAiChatCompletions, + ZHIPU_PROVIDER_BASE_URL, + Some("Z.AI (GLM)"), + false, + ); + add_shorthand_provider( + &mut llm.providers, + "zai-coding-plan", + llm.zai_coding_plan_key.clone(), + ApiType::OpenAiChatCompletions, + ZAI_CODING_PLAN_BASE_URL, + Some("Z.AI Coding Plan"), + false, + ); - if let Some(zai_coding_plan_key) = llm.zai_coding_plan_key.clone() { + if let Some(opencode_zen_key) = llm.opencode_zen_key.clone() { llm.providers - .entry("zai-coding-plan".to_string()) + .entry("opencode-zen".to_string()) .or_insert_with(|| ProviderConfig { api_type: ApiType::OpenAiCompletions, - base_url: ZAI_CODING_PLAN_BASE_URL.to_string(), - api_key: zai_coding_plan_key, + base_url: OPENCODE_ZEN_PROVIDER_BASE_URL.to_string(), + api_key: opencode_zen_key, name: None, use_bearer_auth: false, }); } - if let Some(opencode_zen_key) = llm.opencode_zen_key.clone() { + if let Some(opencode_go_key) = llm.opencode_go_key.clone() { llm.providers - .entry("opencode-zen".to_string()) + .entry("opencode-go".to_string()) .or_insert_with(|| ProviderConfig { api_type: ApiType::OpenAiCompletions, - base_url: OPENCODE_ZEN_PROVIDER_BASE_URL.to_string(), - api_key: opencode_zen_key, + base_url: OPENCODE_GO_PROVIDER_BASE_URL.to_string(), + api_key: opencode_go_key, name: None, use_bearer_auth: false, }); @@ -2685,6 +2899,9 @@ impl Config { .as_deref() .and_then(resolve_env_value) .or_else(|| std::env::var("OPENROUTER_API_KEY").ok()), + kilo_key: std::env::var("KILO_API_KEY") + .ok() + .or_else(|| toml.llm.kilo_key.as_deref().and_then(resolve_env_value)), zhipu_key: toml .llm .zhipu_key @@ -2751,6 +2968,9 @@ impl Config { .as_deref() .and_then(resolve_env_value) .or_else(|| std::env::var("OPENCODE_ZEN_API_KEY").ok()), + opencode_go_key: std::env::var("OPENCODE_GO_API_KEY") + .ok() + .or_else(|| toml.llm.opencode_go_key.as_deref().and_then(resolve_env_value)), nvidia_key: toml .llm .nvidia_key @@ -2849,37 +3069,53 @@ impl Config { }); } - if let Some(zhipu_key) = llm.zhipu_key.clone() { - llm.providers - .entry("zhipu".to_string()) - .or_insert_with(|| ProviderConfig { - api_type: ApiType::OpenAiCompletions, - base_url: ZHIPU_PROVIDER_BASE_URL.to_string(), - api_key: zhipu_key, - name: None, - use_bearer_auth: false, - }); - } + add_shorthand_provider( + &mut llm.providers, + "kilo", + llm.kilo_key.clone(), + ApiType::KiloGateway, + KILO_PROVIDER_BASE_URL, + Some("Kilo Gateway"), + false, + ); + add_shorthand_provider( + &mut llm.providers, + "zhipu", + llm.zhipu_key.clone(), + ApiType::OpenAiChatCompletions, + ZHIPU_PROVIDER_BASE_URL, + Some("Z.AI (GLM)"), + false, + ); + add_shorthand_provider( + &mut llm.providers, + "zai-coding-plan", + llm.zai_coding_plan_key.clone(), + ApiType::OpenAiChatCompletions, + ZAI_CODING_PLAN_BASE_URL, + Some("Z.AI Coding Plan"), + false, + ); - if let Some(zai_coding_plan_key) = llm.zai_coding_plan_key.clone() { + if let Some(opencode_zen_key) = llm.opencode_zen_key.clone() { llm.providers - .entry("zai-coding-plan".to_string()) + .entry("opencode-zen".to_string()) .or_insert_with(|| ProviderConfig { api_type: ApiType::OpenAiCompletions, - base_url: ZAI_CODING_PLAN_BASE_URL.to_string(), - api_key: zai_coding_plan_key, + base_url: OPENCODE_ZEN_PROVIDER_BASE_URL.to_string(), + api_key: opencode_zen_key, name: None, use_bearer_auth: false, }); } - if let Some(opencode_zen_key) = llm.opencode_zen_key.clone() { + if let Some(opencode_go_key) = llm.opencode_go_key.clone() { llm.providers - .entry("opencode-zen".to_string()) + .entry("opencode-go".to_string()) .or_insert_with(|| ProviderConfig { api_type: ApiType::OpenAiCompletions, - base_url: OPENCODE_ZEN_PROVIDER_BASE_URL.to_string(), - api_key: opencode_zen_key, + base_url: OPENCODE_GO_PROVIDER_BASE_URL.to_string(), + api_key: opencode_go_key, name: None, use_bearer_auth: false, }); @@ -4262,11 +4498,14 @@ pub fn run_onboarding() -> anyhow::Result> { "DeepSeek", "xAI (Grok)", "Mistral AI", + "Gemini", "Ollama", "OpenCode Zen", + "OpenCode Go", "MiniMax", "Moonshot AI (Kimi)", "Z.AI Coding Plan", + "Kilo Gateway", ]; let provider_idx = Select::new() .with_prompt("Which LLM provider do you want to use?") @@ -4323,13 +4562,15 @@ pub fn run_onboarding() -> anyhow::Result> { 10 => ("Google Gemini API key", "gemini_key", "gemini"), 11 => ("Ollama base URL (optional)", "ollama_base_url", "ollama"), 12 => ("OpenCode Zen API key", "opencode_zen_key", "opencode-zen"), - 13 => ("MiniMax API key", "minimax_key", "minimax"), - 14 => ("Moonshot API key", "moonshot_key", "moonshot"), - 15 => ( + 13 => ("OpenCode Go API key", "opencode_go_key", "opencode-go"), + 14 => ("MiniMax API key", "minimax_key", "minimax"), + 15 => ("Moonshot API key", "moonshot_key", "moonshot"), + 16 => ( "Z.AI Coding Plan API key", "zai_coding_plan_key", "zai-coding-plan", ), + 17 => ("Kilo Gateway API key", "kilo_key", "kilo"), _ => unreachable!(), }; let is_secret = provider_id != "ollama"; @@ -4542,7 +4783,7 @@ mod tests { impl EnvGuard { fn new() -> Self { - const KEYS: [&str; 22] = [ + const KEYS: [&str; 25] = [ "SPACEBOT_DIR", "SPACEBOT_DEPLOYMENT", "SPACEBOT_CRON_TIMEZONE", @@ -4550,6 +4791,7 @@ mod tests { "ANTHROPIC_OAUTH_TOKEN", "OPENAI_API_KEY", "OPENROUTER_API_KEY", + "KILO_API_KEY", "ZHIPU_API_KEY", "GROQ_API_KEY", "TOGETHER_API_KEY", @@ -4562,7 +4804,9 @@ mod tests { "OLLAMA_API_KEY", "OLLAMA_BASE_URL", "OPENCODE_ZEN_API_KEY", + "OPENCODE_GO_API_KEY", "MINIMAX_API_KEY", + "MINIMAX_CN_API_KEY", "MOONSHOT_API_KEY", "ZAI_CODING_PLAN_API_KEY", ]; @@ -4621,22 +4865,40 @@ api_key = "test-key" assert_eq!(result1.unwrap().api_type, ApiType::OpenAiCompletions); let toml2 = r#" -api_type = "openai_responses" -base_url = "https://api.openai.com" +api_type = "openai_chat_completions" +base_url = "https://api.example.com" api_key = "test-key" "#; let result2: StdResult = toml::from_str(toml2); assert!(result2.is_ok(), "Error: {:?}", result2.err()); - assert_eq!(result2.unwrap().api_type, ApiType::OpenAiResponses); + assert_eq!(result2.unwrap().api_type, ApiType::OpenAiChatCompletions); let toml3 = r#" -api_type = "anthropic" -base_url = "https://api.anthropic.com" +api_type = "kilo_gateway" +base_url = "https://api.kilo.ai/api/gateway" api_key = "test-key" "#; let result3: StdResult = toml::from_str(toml3); assert!(result3.is_ok(), "Error: {:?}", result3.err()); - assert_eq!(result3.unwrap().api_type, ApiType::Anthropic); + assert_eq!(result3.unwrap().api_type, ApiType::KiloGateway); + + let toml4 = r#" +api_type = "openai_responses" +base_url = "https://api.openai.com" +api_key = "test-key" +"#; + let result4: StdResult = toml::from_str(toml4); + assert!(result4.is_ok(), "Error: {:?}", result4.err()); + assert_eq!(result4.unwrap().api_type, ApiType::OpenAiResponses); + + let toml5 = r#" +api_type = "anthropic" +base_url = "https://api.anthropic.com" +api_key = "test-key" +"#; + let result5: StdResult = toml::from_str(toml5); + assert!(result5.is_ok(), "Error: {:?}", result5.err()); + assert_eq!(result5.unwrap().api_type, ApiType::Anthropic); } #[test] @@ -4647,6 +4909,8 @@ api_key = "test-key" let error = result.unwrap_err(); assert!(error.to_string().contains("invalid value")); assert!(error.to_string().contains("openai_completions")); + assert!(error.to_string().contains("openai_chat_completions")); + assert!(error.to_string().contains("kilo_gateway")); assert!(error.to_string().contains("openai_responses")); assert!(error.to_string().contains("anthropic")); } @@ -5366,6 +5630,7 @@ startup_delay_secs = 2 ("anthropic_key", "test-key", "anthropic", "anthropic.com"), ("openai_key", "test-key", "openai", "openai.com"), ("openrouter_key", "test-key", "openrouter", "openrouter.ai"), + ("kilo_key", "test-key", "kilo", "api.kilo.ai"), ("deepseek_key", "test-key", "deepseek", "deepseek.com"), ("minimax_key", "test-key", "minimax", "minimax.io"), ("minimax_cn_key", "test-key", "minimax-cn", "minimaxi.com"), @@ -5378,6 +5643,18 @@ startup_delay_secs = 2 ("together_key", "test-key", "together", "together"), ("xai_key", "test-key", "xai", "x.ai"), ("mistral_key", "test-key", "mistral", "mistral.ai"), + ( + "opencode_zen_key", + "test-key", + "opencode-zen", + "opencode.ai/zen", + ), + ( + "opencode_go_key", + "test-key", + "opencode-go", + "opencode.ai/zen/go", + ), ( "ollama_base_url", "http://localhost:11434", @@ -5429,6 +5706,7 @@ startup_delay_secs = 2 "openrouter", "openrouter.ai", ), + ("KILO_API_KEY", "test-key", "kilo", "api.kilo.ai"), ("DEEPSEEK_API_KEY", "test-key", "deepseek", "deepseek.com"), ("MINIMAX_API_KEY", "test-key", "minimax", "minimax.io"), ("NVIDIA_API_KEY", "test-key", "nvidia", "nvidia.com"), @@ -5439,6 +5717,18 @@ startup_delay_secs = 2 ("TOGETHER_API_KEY", "test-key", "together", "together"), ("XAI_API_KEY", "test-key", "xai", "x.ai"), ("MISTRAL_API_KEY", "test-key", "mistral", "mistral.ai"), + ( + "OPENCODE_ZEN_API_KEY", + "test-key", + "opencode-zen", + "opencode.ai/zen", + ), + ( + "OPENCODE_GO_API_KEY", + "test-key", + "opencode-go", + "opencode.ai/zen/go", + ), ( "OLLAMA_BASE_URL", "http://localhost:11434", diff --git a/src/llm/model.rs b/src/llm/model.rs index 4ba76e03d..7a14dc054 100644 --- a/src/llm/model.rs +++ b/src/llm/model.rs @@ -111,29 +111,44 @@ impl SpacebotModel { .map_err(|e| CompletionError::ProviderError(e.to_string()))?, }; - if provider_id == "zai-coding-plan" || provider_id == "zhipu" { - let display_name = if provider_id == "zhipu" { - "Z.AI (GLM)" - } else { - "Z.AI Coding Plan" - }; - let endpoint = format!( - "{}/chat/completions", - provider_config.base_url.trim_end_matches('/') - ); - return self - .call_openai_compatible_with_optional_auth( + match provider_config.api_type { + ApiType::Anthropic => self.call_anthropic(request, &provider_config).await, + ApiType::OpenAiCompletions => self.call_openai(request, &provider_config).await, + ApiType::OpenAiChatCompletions => { + let endpoint = format!( + "{}/chat/completions", + provider_config.base_url.trim_end_matches('/') + ); + let display_name = provider_config + .name + .as_deref() + .unwrap_or("OpenAI-compatible provider"); + self.call_openai_compatible_with_optional_auth( request, display_name, &endpoint, Some(provider_config.api_key.clone()), + &[], ) - .await; - } - - match provider_config.api_type { - ApiType::Anthropic => self.call_anthropic(request, &provider_config).await, - ApiType::OpenAiCompletions => self.call_openai(request, &provider_config).await, + .await + } + ApiType::KiloGateway => { + let endpoint = format!( + "{}/chat/completions", + provider_config.base_url.trim_end_matches('/') + ); + self.call_openai_compatible_with_optional_auth( + request, + "Kilo Gateway", + &endpoint, + Some(provider_config.api_key.clone()), + &[ + ("HTTP-Referer", "https://github.com/spacedriveapp/spacebot"), + ("X-Title", "spacebot"), + ], + ) + .await + } ApiType::OpenAiResponses => self.call_openai_responses(request, &provider_config).await, ApiType::Gemini => { self.call_openai_compatible(request, "Google Gemini", &provider_config) @@ -734,7 +749,9 @@ impl SpacebotModel { let base_url = provider_config.base_url.trim_end_matches('/'); let endpoint_path = match provider_config.api_type { ApiType::OpenAiCompletions | ApiType::OpenAiResponses => "/v1/chat/completions", - ApiType::Gemini => "/chat/completions", + ApiType::OpenAiChatCompletions | ApiType::Gemini | ApiType::KiloGateway => { + "/chat/completions" + } ApiType::Anthropic => { return Err(CompletionError::ProviderError(format!( "{provider_display_name} is configured with anthropic API type, but this call expects an OpenAI-compatible API" @@ -843,6 +860,7 @@ impl SpacebotModel { provider_display_name: &str, endpoint: &str, api_key: Option, + extra_headers: &[(&str, &str)], ) -> Result, CompletionError> { let mut messages = Vec::new(); @@ -887,15 +905,17 @@ impl SpacebotModel { body["tools"] = serde_json::json!(tools); } - let response = self.llm_manager.http_client().post(endpoint); + let mut request_builder = self.llm_manager.http_client().post(endpoint); - let response = if let Some(api_key) = api_key { - response.header("authorization", format!("Bearer {api_key}")) - } else { - response - }; + for (header_name, header_value) in extra_headers { + request_builder = request_builder.header(*header_name, *header_value); + } - let response = response + if let Some(api_key) = api_key { + request_builder = request_builder.header("authorization", format!("Bearer {api_key}")); + } + + let response = request_builder .header("content-type", "application/json") .json(&body) .send() diff --git a/src/llm/providers.rs b/src/llm/providers.rs index 6153e6682..7ab5e80b4 100644 --- a/src/llm/providers.rs +++ b/src/llm/providers.rs @@ -17,6 +17,10 @@ pub async fn init_providers(config: &LlmConfig) -> Result<()> { tracing::info!("OpenAI provider configured"); } + if config.kilo_key.is_some() { + tracing::info!("Kilo Gateway provider configured"); + } + if config.ollama_base_url.is_some() || config.ollama_key.is_some() { tracing::info!("Ollama provider configured"); } @@ -25,6 +29,10 @@ pub async fn init_providers(config: &LlmConfig) -> Result<()> { tracing::info!("OpenCode Zen provider configured"); } + if config.opencode_go_key.is_some() { + tracing::info!("OpenCode Go provider configured"); + } + if config.minimax_key.is_some() { tracing::info!("MiniMax provider configured"); } diff --git a/src/llm/routing.rs b/src/llm/routing.rs index 0549692e6..0c3b6744c 100644 --- a/src/llm/routing.rs +++ b/src/llm/routing.rs @@ -174,6 +174,22 @@ pub fn defaults_for_provider(provider: &str) -> RoutingConfig { ..RoutingConfig::default() } } + "kilo" => { + let channel: String = "kilo/anthropic/claude-sonnet-4.5".into(); + let worker: String = "kilo/anthropic/claude-haiku-4.5".into(); + RoutingConfig { + channel: channel.clone(), + branch: channel.clone(), + worker: worker.clone(), + compactor: worker.clone(), + cortex: worker, + voice: String::new(), + task_overrides: HashMap::from([("coding".into(), channel)]), + fallbacks: HashMap::new(), + rate_limit_cooldown_secs: 60, + ..RoutingConfig::default() + } + } "openai" => { let channel: String = "openai/gpt-4.1".into(); let worker: String = "openai/gpt-4.1-mini".into(); @@ -353,6 +369,22 @@ pub fn defaults_for_provider(provider: &str) -> RoutingConfig { ..RoutingConfig::default() } } + "opencode-go" => { + let channel: String = "opencode-go/kimi-k2.5".into(); + let worker: String = "opencode-go/kimi-k2".into(); + RoutingConfig { + channel: channel.clone(), + branch: channel.clone(), + worker: worker.clone(), + compactor: worker.clone(), + cortex: worker.clone(), + voice: String::new(), + task_overrides: HashMap::from([("coding".into(), channel.clone())]), + fallbacks: HashMap::from([(channel, vec![worker])]), + rate_limit_cooldown_secs: 60, + ..RoutingConfig::default() + } + } "nvidia" => RoutingConfig::for_model("nvidia/meta/llama-3.1-405b-instruct".into()), "minimax" => RoutingConfig::for_model("minimax/MiniMax-M2.5".into()), "minimax-cn" => RoutingConfig::for_model("minimax-cn/MiniMax-M2.5".into()), @@ -367,6 +399,7 @@ pub fn defaults_for_provider(provider: &str) -> RoutingConfig { pub fn provider_to_prefix(provider: &str) -> &str { match provider { "openrouter" => "openrouter/", + "kilo" => "kilo/", "openai" => "openai/", "openai-chatgpt" => "openai-chatgpt/", "anthropic" => "anthropic/", @@ -380,6 +413,7 @@ pub fn provider_to_prefix(provider: &str) -> &str { "gemini" => "gemini/", "nvidia" => "nvidia/", "opencode-zen" => "opencode-zen/", + "opencode-go" => "opencode-go/", "minimax" => "minimax/", "minimax-cn" => "minimax-cn/", "moonshot" => "moonshot/", From bcb6daaed4810573873be47effc31ab1f74135c3 Mon Sep 17 00:00:00 2001 From: Mars Date: Wed, 25 Feb 2026 22:14:23 -0500 Subject: [PATCH 2/4] chore: fmt --- src/config.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/config.rs b/src/config.rs index 79da09fbd..f613c2fea 100644 --- a/src/config.rs +++ b/src/config.rs @@ -2968,9 +2968,12 @@ impl Config { .as_deref() .and_then(resolve_env_value) .or_else(|| std::env::var("OPENCODE_ZEN_API_KEY").ok()), - opencode_go_key: std::env::var("OPENCODE_GO_API_KEY") - .ok() - .or_else(|| toml.llm.opencode_go_key.as_deref().and_then(resolve_env_value)), + opencode_go_key: std::env::var("OPENCODE_GO_API_KEY").ok().or_else(|| { + toml.llm + .opencode_go_key + .as_deref() + .and_then(resolve_env_value) + }), nvidia_key: toml .llm .nvidia_key From 850b43f70e73fa970b2ab3b2ce59233e714a1051 Mon Sep 17 00:00:00 2001 From: Mars Date: Wed, 25 Feb 2026 22:53:52 -0500 Subject: [PATCH 3/4] Fix model trimming, remove dead KiloGateway code, and consolidate opencode provider registration --- src/api/providers.rs | 9 ++-- src/config.rs | 124 +++++++++++++++++++++++++++++++++++-------- src/llm/model.rs | 10 ++-- 3 files changed, 114 insertions(+), 29 deletions(-) diff --git a/src/api/providers.rs b/src/api/providers.rs index e26209aae..6c5e8040f 100644 --- a/src/api/providers.rs +++ b/src/api/providers.rs @@ -785,6 +785,7 @@ pub(super) async fn test_provider_model( Json(request): Json, ) -> Result, StatusCode> { let normalized_provider = request.provider.trim().to_lowercase(); + let normalized_model = request.model.trim().to_string(); if provider_toml_key(&normalized_provider).is_none() { return Ok(Json(ProviderModelTestResponse { success: false, @@ -805,7 +806,7 @@ pub(super) async fn test_provider_model( })); } - if request.model.trim().is_empty() { + if normalized_model.is_empty() { return Ok(Json(ProviderModelTestResponse { success: false, message: "Model cannot be empty".to_string(), @@ -815,12 +816,12 @@ pub(super) async fn test_provider_model( })); } - if !model_matches_provider(&normalized_provider, &request.model) { + if !model_matches_provider(&normalized_provider, &normalized_model) { return Ok(Json(ProviderModelTestResponse { success: false, message: format!( "Model '{}' does not match provider '{}'.", - request.model, request.provider + normalized_model, request.provider ), provider: request.provider, model: request.model, @@ -842,7 +843,7 @@ pub(super) async fn test_provider_model( } }; - let model = crate::llm::SpacebotModel::make(&llm_manager, request.model.clone()); + let model = crate::llm::SpacebotModel::make(&llm_manager, normalized_model); let agent = AgentBuilder::new(model) .preamble("You are running a provider connectivity check. Reply with exactly: OK") .build(); diff --git a/src/config.rs b/src/config.rs index f613c2fea..badc1b522 100644 --- a/src/config.rs +++ b/src/config.rs @@ -2516,6 +2516,90 @@ impl Config { }); } + if let Some(openrouter_key) = llm.openrouter_key.clone() { + llm.providers + .entry("openrouter".to_string()) + .or_insert_with(|| ProviderConfig { + api_type: ApiType::OpenAiCompletions, + base_url: OPENROUTER_PROVIDER_BASE_URL.to_string(), + api_key: openrouter_key, + name: None, + use_bearer_auth: false, + }); + } + + add_shorthand_provider( + &mut llm.providers, + "kilo", + llm.kilo_key.clone(), + ApiType::KiloGateway, + KILO_PROVIDER_BASE_URL, + Some("Kilo Gateway"), + false, + ); + add_shorthand_provider( + &mut llm.providers, + "zhipu", + llm.zhipu_key.clone(), + ApiType::OpenAiChatCompletions, + ZHIPU_PROVIDER_BASE_URL, + Some("Z.AI (GLM)"), + false, + ); + add_shorthand_provider( + &mut llm.providers, + "zai-coding-plan", + llm.zai_coding_plan_key.clone(), + ApiType::OpenAiChatCompletions, + ZAI_CODING_PLAN_BASE_URL, + Some("Z.AI Coding Plan"), + false, + ); + + add_shorthand_provider( + &mut llm.providers, + "opencode-zen", + llm.opencode_zen_key.clone(), + ApiType::OpenAiCompletions, + OPENCODE_ZEN_PROVIDER_BASE_URL, + None, + false, + ); + + add_shorthand_provider( + &mut llm.providers, + "opencode-go", + llm.opencode_go_key.clone(), + ApiType::OpenAiCompletions, + OPENCODE_GO_PROVIDER_BASE_URL, + None, + false, + ); + + if let Some(minimax_key) = llm.minimax_key.clone() { + llm.providers + .entry("minimax".to_string()) + .or_insert_with(|| ProviderConfig { + api_type: ApiType::Anthropic, + base_url: MINIMAX_PROVIDER_BASE_URL.to_string(), + api_key: minimax_key, + name: None, + use_bearer_auth: false, + }); + } + + if let Some(minimax_cn_key) = llm.minimax_cn_key.clone() { + llm.providers + .entry("minimax-cn".to_string()) + .or_insert_with(|| ProviderConfig { + api_type: ApiType::Anthropic, + base_url: MINIMAX_CN_PROVIDER_BASE_URL.to_string(), + api_key: minimax_cn_key, + name: None, + use_bearer_auth: false, + }); + } + if let Some(openai_key) = llm.openai_key.clone() { llm.providers .entry("openai".to_string()) @@ -3100,29 +3184,25 @@ impl Config { false, ); - if let Some(opencode_zen_key) = llm.opencode_zen_key.clone() { - llm.providers - .entry("opencode-zen".to_string()) - .or_insert_with(|| ProviderConfig { - api_type: ApiType::OpenAiCompletions, - base_url: OPENCODE_ZEN_PROVIDER_BASE_URL.to_string(), - api_key: opencode_zen_key, - name: None, - use_bearer_auth: false, - }); - } + add_shorthand_provider( + &mut llm.providers, + "opencode-zen", + llm.opencode_zen_key.clone(), + ApiType::OpenAiCompletions, + OPENCODE_ZEN_PROVIDER_BASE_URL, + None, + false, + ); - if let Some(opencode_go_key) = llm.opencode_go_key.clone() { - llm.providers - .entry("opencode-go".to_string()) - .or_insert_with(|| ProviderConfig { - api_type: ApiType::OpenAiCompletions, - base_url: OPENCODE_GO_PROVIDER_BASE_URL.to_string(), - api_key: opencode_go_key, - name: None, - use_bearer_auth: false, - }); - } + add_shorthand_provider( + &mut llm.providers, + "opencode-go", + llm.opencode_go_key.clone(), + ApiType::OpenAiCompletions, + OPENCODE_GO_PROVIDER_BASE_URL, + None, + false, + ); if let Some(minimax_key) = llm.minimax_key.clone() { llm.providers diff --git a/src/llm/model.rs b/src/llm/model.rs index 7a14dc054..abcb627f0 100644 --- a/src/llm/model.rs +++ b/src/llm/model.rs @@ -749,14 +749,18 @@ impl SpacebotModel { let base_url = provider_config.base_url.trim_end_matches('/'); let endpoint_path = match provider_config.api_type { ApiType::OpenAiCompletions | ApiType::OpenAiResponses => "/v1/chat/completions", - ApiType::OpenAiChatCompletions | ApiType::Gemini | ApiType::KiloGateway => { - "/chat/completions" - } + ApiType::OpenAiChatCompletions | ApiType::Gemini => "/chat/completions", ApiType::Anthropic => { return Err(CompletionError::ProviderError(format!( "{provider_display_name} is configured with anthropic API type, but this call expects an OpenAI-compatible API" ))); } + _ => { + return Err(CompletionError::ProviderError(format!( + "{provider_display_name} uses API type {:?} which does not support OpenAI-compatible calls", + provider_config.api_type + ))); + } }; let endpoint = format!("{base_url}{endpoint_path}"); let api_key = provider_config.api_key.as_str(); From 34ee6eb821699b66fe94f01a4cd616a931747eca Mon Sep 17 00:00:00 2001 From: Mars Date: Thu, 26 Feb 2026 02:42:59 -0500 Subject: [PATCH 4/4] lint + fmt --- src/api/cron.rs | 57 +++++++++++++++++++++++++++---------------- src/cron/scheduler.rs | 9 ++++--- 2 files changed, 42 insertions(+), 24 deletions(-) diff --git a/src/api/cron.rs b/src/api/cron.rs index a4e52926e..1615dadcb 100644 --- a/src/api/cron.rs +++ b/src/api/cron.rs @@ -221,21 +221,21 @@ fn validate_cron_request(request: &CreateCronRequest) -> Result<(), (StatusCode, )); } - if let Some(start) = request.active_start_hour { - if start > 23 { - return Err(( - StatusCode::BAD_REQUEST, - "active_start_hour must be 0-23".into(), - )); - } + if let Some(start) = request.active_start_hour + && start > 23 + { + return Err(( + StatusCode::BAD_REQUEST, + "active_start_hour must be 0-23".into(), + )); } - if let Some(end) = request.active_end_hour { - if end > 23 { - return Err(( - StatusCode::BAD_REQUEST, - "active_end_hour must be 0-23".into(), - )); - } + if let Some(end) = request.active_end_hour + && end > 23 + { + return Err(( + StatusCode::BAD_REQUEST, + "active_end_hour must be 0-23".into(), + )); } Ok(()) @@ -248,24 +248,39 @@ pub(super) async fn create_or_update_cron( ) -> Result, (StatusCode, Json)> { if let Err((status, message)) = validate_cron_request(&request) { tracing::warn!(agent_id = %request.agent_id, cron_id = %request.id, %message, "cron validation failed"); - return Err((status, Json(CronActionResponse { - success: false, - message, - }))); + return Err(( + status, + Json(CronActionResponse { + success: false, + message, + }), + )); } let stores = state.cron_stores.load(); let schedulers = state.cron_schedulers.load(); let cron_err = |status: StatusCode, message: String| { - (status, Json(CronActionResponse { success: false, message })) + ( + status, + Json(CronActionResponse { + success: false, + message, + }), + ) }; let store = stores.get(&request.agent_id).ok_or_else(|| { - cron_err(StatusCode::NOT_FOUND, format!("agent '{}' not found", request.agent_id)) + cron_err( + StatusCode::NOT_FOUND, + format!("agent '{}' not found", request.agent_id), + ) })?; let scheduler = schedulers.get(&request.agent_id).ok_or_else(|| { - cron_err(StatusCode::NOT_FOUND, format!("agent '{}' not found", request.agent_id)) + cron_err( + StatusCode::NOT_FOUND, + format!("agent '{}' not found", request.agent_id), + ) })?; let active_hours = match (request.active_start_hour, request.active_end_hour) { diff --git a/src/cron/scheduler.rs b/src/cron/scheduler.rs index b28c6a3fe..04844f604 100644 --- a/src/cron/scheduler.rs +++ b/src/cron/scheduler.rs @@ -307,7 +307,9 @@ impl Scheduler { } } - if let Err(error) = exec_context.store.update_enabled(&exec_job_id, false).await { + if let Err(error) = + exec_context.store.update_enabled(&exec_job_id, false).await + { tracing::error!(%error, "failed to persist cron job disabled state"); } } @@ -324,11 +326,12 @@ impl Scheduler { } } - if let Err(error) = exec_context.store.update_enabled(&exec_job_id, false).await { + if let Err(error) = + exec_context.store.update_enabled(&exec_job_id, false).await + { tracing::error!(%error, "failed to persist run-once cron disabled state"); } } - }); } });