From 28a0f19c135fd1db58a8f51e4ce22ce8c48fda37 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Wed, 3 Jun 2026 14:40:25 -0700 Subject: [PATCH] fix(provider): polish v0.8.53 routing and shell gating --- CHANGELOG.md | 18 + README.md | 26 +- config.example.toml | 30 +- crates/agent/src/lib.rs | 22 + crates/cli/src/lib.rs | 5 +- crates/config/src/lib.rs | 303 ++++++++++- crates/tui/src/client.rs | 5 +- crates/tui/src/commands/config.rs | 364 ++++++++++++- crates/tui/src/commands/mod.rs | 2 +- crates/tui/src/commands/provider.rs | 3 +- crates/tui/src/config.rs | 589 +++++++++++++++++++-- crates/tui/src/core/engine.rs | 1 + crates/tui/src/core/engine/tests.rs | 16 +- crates/tui/src/core/engine/tool_catalog.rs | 5 +- crates/tui/src/localization.rs | 10 +- crates/tui/src/main.rs | 7 +- crates/tui/src/project_context.rs | 16 +- crates/tui/src/tools/subagent/mod.rs | 9 +- crates/tui/src/tools/subagent/tests.rs | 20 +- crates/tui/src/tools/web_search.rs | 3 +- crates/tui/src/tui/provider_picker.rs | 10 +- crates/tui/src/tui/ui.rs | 66 ++- crates/tui/src/tui/views/mod.rs | 107 +++- crates/tui/src/tui/views/mode_picker.rs | 2 +- docs/PROVIDERS.md | 24 +- 25 files changed, 1517 insertions(+), 146 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e576f767..cf84a8d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.8.53] - 2026-06-03 + +### Added + +- **Hugging Face Inference Providers.** Added `huggingface` as a native + provider route (`/provider huggingface`). Supports `HUGGINGFACE_API_KEY` + or `HF_TOKEN` for auth, `HUGGINGFACE_BASE_URL` and `HUGGINGFACE_MODEL` + for overrides, and `deepseek-ai/DeepSeek-V4-Pro` / `deepseek-ai/DeepSeek-V4-Flash` + as default models. Org-prefixed model IDs pass through. + +### Fixed + +- **Agent-mode shell error copy.** The missing-tool error for shell tools + now directs users to `allow_shell = true` instead of nudging toward YOLO + mode. `/config` surfaces `allow_shell` in the Permissions section. +- **Provider description.** `/provider` command description is now neutral + instead of recommending specific providers. + ## [0.8.52] - 2026-06-03 ### Added diff --git a/README.md b/README.md index b8dcdde8..21a89faa 100644 --- a/README.md +++ b/README.md @@ -301,6 +301,21 @@ Both binaries are required. Cross-compilation and platform-specific notes: [docs For the full shipped provider registry, including model IDs, auth variables, base URLs, and capability boundaries, see [docs/PROVIDERS.md](docs/PROVIDERS.md). +Think of provider and model as separate choices: `provider` is the route, +account, and endpoint; `model` is the model ID on that route. DeepSeek-family +models can be reached through several routes, so `/config` exposes both +`provider` and `provider_url`. + +| Route | Typical DeepSeek model ID | +|-------|---------------------------| +| `deepseek` | `deepseek-v4-pro` | +| `nvidia-nim` | `deepseek-ai/deepseek-v4-pro` | +| `openrouter` | `deepseek/deepseek-v4-pro` | +| `fireworks` | `accounts/fireworks/models/deepseek-v4-pro` | +| `siliconflow` | `deepseek-ai/DeepSeek-V4-Pro` | +| `openai` | Your gateway's model ID | +| `huggingface` | `deepseek-ai/DeepSeek-V4-Pro` | + ```bash # NVIDIA NIM codewhale auth set --provider nvidia-nim --api-key "YOUR_NVIDIA_API_KEY" @@ -358,6 +373,9 @@ codewhale --provider arcee --model trinity-large-preview # Xiaomi MiMo codewhale auth set --provider xiaomi-mimo --api-key "YOUR_XIAOMI_KEY" +# Token Plan (`tp-...`) keys default to https://token-plan-sgp.xiaomimimo.com/v1. +# To force a provider endpoint: /config provider_url token-plan --save +# or /config provider_url pay-as-you-go --save. codewhale --provider xiaomi-mimo --model mimo-v2.5-pro codewhale --provider xiaomi-mimo --model mimo-v2.5 codewhale --provider xiaomi-mimo speech "Hello from MiMo" --model tts -o hello.wav @@ -561,16 +579,17 @@ Key environment variables: | `DEEPSEEK_HTTP_HEADERS` | Optional custom model request headers, e.g. `X-Model-Provider-Id=your-model-provider` | | `DEEPSEEK_MODEL` | Default model | | `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` | Stream idle timeout in seconds, default `300`, clamped to `1..=3600` | -| `CODEWHALE_PROVIDER` / `DEEPSEEK_PROVIDER` | `deepseek` (default), `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `volcengine`, `openrouter`, `xiaomi-mimo`, `novita`, `fireworks`, `siliconflow`, `arcee`, `moonshot`, `sglang`, `vllm`, `ollama` | +| `CODEWHALE_PROVIDER` / `DEEPSEEK_PROVIDER` | `deepseek` (default), `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `volcengine`, `openrouter`, `xiaomi-mimo`, `novita`, `fireworks`, `siliconflow`, `siliconflow-CN`, `arcee`, `moonshot`, `sglang`, `vllm`, `ollama`, `huggingface` | | `DEEPSEEK_PROFILE` | Config profile name | | `DEEPSEEK_MEMORY` | Set to `on` to enable user memory | | `DEEPSEEK_ALLOW_INSECURE_HTTP=1` | Allow non-local `http://` API base URLs on trusted networks | -| `NVIDIA_API_KEY` / `OPENAI_API_KEY` / `ATLASCLOUD_API_KEY` / `WANJIE_ARK_API_KEY` / `VOLCENGINE_API_KEY` / `OPENROUTER_API_KEY` / `XIAOMI_MIMO_API_KEY` / `XIAOMI_API_KEY` / `MIMO_API_KEY` / `NOVITA_API_KEY` / `FIREWORKS_API_KEY` / `SILICONFLOW_API_KEY` / `ARCEE_API_KEY` / `MOONSHOT_API_KEY` / `KIMI_API_KEY` / `SGLANG_API_KEY` / `VLLM_API_KEY` / `OLLAMA_API_KEY` | Provider auth | +| `NVIDIA_API_KEY` / `OPENAI_API_KEY` / `ATLASCLOUD_API_KEY` / `WANJIE_ARK_API_KEY` / `VOLCENGINE_API_KEY` / `VOLCENGINE_ARK_API_KEY` / `ARK_API_KEY` / `OPENROUTER_API_KEY` / `XIAOMI_MIMO_API_KEY` / `XIAOMI_API_KEY` / `MIMO_API_KEY` / `NOVITA_API_KEY` / `FIREWORKS_API_KEY` / `SILICONFLOW_API_KEY` / `ARCEE_API_KEY` / `MOONSHOT_API_KEY` / `KIMI_API_KEY` / `SGLANG_API_KEY` / `VLLM_API_KEY` / `OLLAMA_API_KEY` / `HUGGINGFACE_API_KEY` / `HF_TOKEN` | Provider auth | | `OPENAI_BASE_URL` / `OPENAI_MODEL` | Generic OpenAI-compatible endpoint and model ID | | `ATLASCLOUD_BASE_URL` / `ATLASCLOUD_MODEL` | AtlasCloud endpoint and model override | | `WANJIE_ARK_BASE_URL` / `WANJIE_ARK_MODEL` | Wanjie Ark endpoint and model override | +| `VOLCENGINE_BASE_URL` / `VOLCENGINE_ARK_BASE_URL` / `ARK_BASE_URL` / `VOLCENGINE_MODEL` / `VOLCENGINE_ARK_MODEL` | Volcengine Ark endpoint and model override | | `OPENROUTER_BASE_URL` | OpenRouter endpoint override | -| `XIAOMI_MIMO_BASE_URL` / `MIMO_BASE_URL` / `XIAOMI_MIMO_MODEL` / `MIMO_MODEL` | Xiaomi MiMo endpoint and model override | +| `XIAOMI_MIMO_BASE_URL` / `MIMO_BASE_URL` / `XIAOMI_MIMO_MODEL` / `MIMO_MODEL` | Xiaomi MiMo endpoint and model override; Token Plan default is `https://token-plan-sgp.xiaomimimo.com/v1` | | `NOVITA_BASE_URL` | Novita endpoint override | | `FIREWORKS_BASE_URL` | Fireworks endpoint override | | `SILICONFLOW_BASE_URL` / `SILICONFLOW_MODEL` | SiliconFlow endpoint and model override | @@ -581,6 +600,7 @@ Key environment variables: | `VLLM_MODEL` | Self-hosted vLLM model ID | | `OLLAMA_BASE_URL` | Self-hosted Ollama endpoint | | `OLLAMA_MODEL` | Self-hosted Ollama model tag | +| `HUGGINGFACE_API_KEY` / `HF_TOKEN` / `HUGGINGFACE_BASE_URL` / `HUGGINGFACE_MODEL` | Hugging Face endpoint and model override | | `NO_ANIMATIONS=1` | Force accessibility mode at startup | | `SSL_CERT_FILE` | Custom CA bundle for corporate proxies | diff --git a/config.example.toml b/config.example.toml index ac8d22c0..6c73426f 100644 --- a/config.example.toml +++ b/config.example.toml @@ -13,13 +13,14 @@ # `[providers.*]` sections near the bottom of # this file — keeping both stored at once means `/provider deepseek` and # `/provider nvidia-nim` (or `--provider openai`, `--provider wanjie-ark`, -# `--provider volcengine`, `--provider xiaomi-mimo`, `--provider fireworks`, -# `--provider siliconflow`, `/provider arcee`, `/provider moonshot`, `/provider sglang`, -# `/provider vllm`, `/provider ollama`) toggle without having to re-enter keys. Top-level +# `--provider volcengine`, `--provider openrouter`, `--provider xiaomi-mimo`, +# `--provider fireworks`, `--provider siliconflow`, `--provider siliconflow-CN`, +# `/provider arcee`, `/provider moonshot`, `/provider sglang`, `/provider vllm`, +# `/provider ollama`, `/provider huggingface`) toggle without having to re-enter keys. Top-level # `api_key` / `base_url` are # still read as DeepSeek defaults when `[providers.deepseek]` is absent # (backward compatibility). -provider = "deepseek" # deepseek | deepseek-cn | nvidia-nim | openai | atlascloud | wanjie-ark | volcengine | openrouter | xiaomi-mimo | novita | fireworks | siliconflow | arcee | moonshot | sglang | vllm | ollama +provider = "deepseek" # deepseek | deepseek-cn | nvidia-nim | openai | atlascloud | wanjie-ark | volcengine | openrouter | xiaomi-mimo | novita | fireworks | siliconflow | siliconflow-CN | arcee | moonshot | sglang | vllm | ollama | huggingface api_key = "YOUR_DEEPSEEK_API_KEY" # must be non-empty base_url = "https://api.deepseek.com/beta" # provider = "deepseek-cn" # legacy alias (official host is still https://api.deepseek.com) @@ -238,6 +239,7 @@ max_subagents = 10 # optional (1-20) # Volcengine Ark: VOLCENGINE_API_KEY (or VOLCENGINE_ARK_API_KEY / ARK_API_KEY), VOLCENGINE_BASE_URL, VOLCENGINE_MODEL # OpenRouter: OPENROUTER_API_KEY, OPENROUTER_BASE_URL, OPENROUTER_MODEL # Xiaomi MiMo: XIAOMI_MIMO_API_KEY (or XIAOMI_API_KEY / MIMO_API_KEY), XIAOMI_MIMO_BASE_URL, XIAOMI_MIMO_MODEL +# Token Plan keys (`tp-...`) default to https://token-plan-sgp.xiaomimimo.com/v1. # Novita: NOVITA_API_KEY, NOVITA_BASE_URL, NOVITA_MODEL # Fireworks: FIREWORKS_API_KEY, FIREWORKS_BASE_URL # SiliconFlow: SILICONFLOW_API_KEY, SILICONFLOW_BASE_URL, SILICONFLOW_MODEL @@ -246,12 +248,21 @@ max_subagents = 10 # optional (1-20) # SGLang: SGLANG_BASE_URL, SGLANG_MODEL, optional SGLANG_API_KEY # vLLM: VLLM_BASE_URL, VLLM_MODEL, optional VLLM_API_KEY # Ollama: OLLAMA_BASE_URL, OLLAMA_MODEL, optional OLLAMA_API_KEY +# Hugging Face: HUGGINGFACE_API_KEY (or HF_TOKEN), HUGGINGFACE_BASE_URL, HUGGINGFACE_MODEL # # Custom DeepSeek-compatible APIs usually do not need a new provider table: # set `provider = "deepseek"` and override [providers.deepseek].base_url/model. # For generic OpenAI-compatible gateways, use `provider = "openai"` and the # [providers.openai] table below. Keep provider/api_key/base_url in user config # or environment variables; project overlays are not allowed to set them. +# +# Provider is the route/account/endpoint; model is the ID on that route. +# Common DeepSeek routes: +# provider = "deepseek" model = "deepseek-v4-pro" +# provider = "nvidia-nim" model = "deepseek-ai/deepseek-v4-pro" +# provider = "openrouter" model = "deepseek/deepseek-v4-pro" +# provider = "fireworks" model = "accounts/fireworks/models/deepseek-v4-pro" +# provider = "siliconflow" model = "deepseek-ai/DeepSeek-V4-Pro" # DeepSeek Platform (https://platform.deepseek.com) [providers.deepseek] @@ -313,7 +324,8 @@ max_subagents = 10 # optional (1-20) # Xiaomi MiMo OpenAI-compatible endpoint (https://platform.xiaomimimo.com) [providers.xiaomi_mimo] # api_key = "YOUR_XIAOMI_KEY" -# base_url = "https://api.xiaomimimo.com/v1" +# base_url = "https://token-plan-sgp.xiaomimimo.com/v1" # Token Plan / tp- keys +# # base_url = "https://api.xiaomimimo.com/v1" # Pay-as-you-go / sk- keys # model = "mimo-v2.5-pro" # chat/reasoning # Chat model IDs: mimo-v2.5-pro, mimo-v2.5 # TTS aliases are also accepted by `codewhale speech`: tts, voice-design, voice-clone @@ -371,6 +383,12 @@ max_subagents = 10 # optional (1-20) # base_url = "http://localhost:11434/v1" # model = "deepseek-coder:1.3b" # or any local Ollama tag +# Hugging Face Inference Providers (https://huggingface.co/docs/api-inference) +[providers.huggingface] +# api_key = "YOUR_HF_TOKEN" +# base_url = "https://router.huggingface.co/v1" +# model = "deepseek-ai/DeepSeek-V4-Pro" # or deepseek-ai/DeepSeek-V4-Flash + # ───────────────────────────────────────────────────────────────────────────────── # Web Search Provider # ───────────────────────────────────────────────────────────────────────────────── @@ -496,7 +514,7 @@ exec_policy = true # Xiaomi MiMo image understanding can be configured through the same tool: # model = "mimo-v2.5" # api_key = "YOUR_XIAOMI_KEY" -# base_url = "https://api.xiaomimimo.com/v1" +# base_url = "https://token-plan-sgp.xiaomimimo.com/v1" # Token Plan / tp- keys # ───────────────────────────────────────────────────────────────────────────────── # Retry Configuration diff --git a/crates/agent/src/lib.rs b/crates/agent/src/lib.rs index c1414adc..36f6e0bf 100644 --- a/crates/agent/src/lib.rs +++ b/crates/agent/src/lib.rs @@ -531,6 +531,28 @@ impl Default for ModelRegistry { supports_tools: true, supports_reasoning: false, }, + ModelInfo { + id: "deepseek-ai/DeepSeek-V4-Pro".to_string(), + provider: ProviderKind::Huggingface, + aliases: vec![ + "deepseek-v4-pro".to_string(), + "hf-deepseek-v4-pro".to_string(), + ], + supports_tools: true, + supports_reasoning: true, + }, + ModelInfo { + id: "deepseek-ai/DeepSeek-V4-Flash".to_string(), + provider: ProviderKind::Huggingface, + aliases: vec![ + "deepseek-v4-flash".to_string(), + "deepseek-chat".to_string(), + "deepseek-reasoner".to_string(), + "hf-deepseek-v4-flash".to_string(), + ], + supports_tools: true, + supports_reasoning: true, + }, ]; Self::new(models) } diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index e1b0a091..1ec8c054 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -750,11 +750,12 @@ fn provider_slot(provider: ProviderKind) -> &'static str { ProviderKind::Sglang => "sglang", ProviderKind::Vllm => "vllm", ProviderKind::Ollama => "ollama", + ProviderKind::Huggingface => "huggingface", } } /// Provider order used by the `auth list` and `auth status` outputs. -const PROVIDER_LIST: [ProviderKind; 17] = [ +const PROVIDER_LIST: [ProviderKind; 18] = [ ProviderKind::Deepseek, ProviderKind::NvidiaNim, ProviderKind::Openai, @@ -772,6 +773,7 @@ const PROVIDER_LIST: [ProviderKind; 17] = [ ProviderKind::Sglang, ProviderKind::Vllm, ProviderKind::Ollama, + ProviderKind::Huggingface, ]; #[cfg(test)] @@ -830,6 +832,7 @@ fn provider_env_vars(provider: ProviderKind) -> &'static [&'static str] { ProviderKind::Sglang => &["SGLANG_API_KEY"], ProviderKind::Vllm => &["VLLM_API_KEY"], ProviderKind::Ollama => &["OLLAMA_API_KEY"], + ProviderKind::Huggingface => &["HUGGINGFACE_API_KEY", "HF_TOKEN"], ProviderKind::Openai => &["OPENAI_API_KEY"], ProviderKind::Atlascloud => &["ATLASCLOUD_API_KEY"], ProviderKind::Volcengine => &[ diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 8e52f03c..e245caa6 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -68,12 +68,16 @@ const DEFAULT_KIMI_CODE_BASE_URL: &str = "https://api.kimi.com/coding/v1"; const DEFAULT_SGLANG_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro"; const DEFAULT_SGLANG_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash"; const DEFAULT_OPENROUTER_BASE_URL: &str = "https://openrouter.ai/api/v1"; -const DEFAULT_XIAOMI_MIMO_BASE_URL: &str = "https://api.xiaomimimo.com/v1"; +const XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL: &str = "https://api.xiaomimimo.com/v1"; +const DEFAULT_XIAOMI_MIMO_BASE_URL: &str = "https://token-plan-sgp.xiaomimimo.com/v1"; const DEFAULT_NOVITA_BASE_URL: &str = "https://api.novita.ai/v1"; const DEFAULT_FIREWORKS_BASE_URL: &str = "https://api.fireworks.ai/inference/v1"; const DEFAULT_SILICONFLOW_BASE_URL: &str = "https://api.siliconflow.com/v1"; const DEFAULT_SILICONFLOW_CN_BASE_URL: &str = "https://api.siliconflow.cn/v1"; const DEFAULT_ARCEE_BASE_URL: &str = "https://api.arcee.ai/api/v1"; +const DEFAULT_HUGGINGFACE_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro"; +const DEFAULT_HUGGINGFACE_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash"; +const DEFAULT_HUGGINGFACE_BASE_URL: &str = "https://router.huggingface.co/v1"; const DEFAULT_SGLANG_BASE_URL: &str = "http://localhost:30000/v1"; const DEFAULT_VLLM_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro"; const DEFAULT_VLLM_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash"; @@ -122,6 +126,8 @@ pub enum ProviderKind { Sglang, Vllm, Ollama, + #[serde(alias = "hugging-face", alias = "hugging_face", alias = "hf")] + Huggingface, } impl ProviderKind { @@ -145,6 +151,7 @@ impl ProviderKind { Self::Sglang => "sglang", Self::Vllm => "vllm", Self::Ollama => "ollama", + Self::Huggingface => "huggingface", } } @@ -173,6 +180,7 @@ impl ProviderKind { "sglang" | "sg-lang" => Some(Self::Sglang), "vllm" | "v-llm" => Some(Self::Vllm), "ollama" | "ollama-local" => Some(Self::Ollama), + "huggingface" | "hugging-face" | "hugging_face" | "hf" => Some(Self::Huggingface), _ => None, } } @@ -227,6 +235,8 @@ pub struct ProvidersToml { pub vllm: ProviderConfigToml, #[serde(default)] pub ollama: ProviderConfigToml, + #[serde(default)] + pub huggingface: ProviderConfigToml, } /// Sibling `permissions.toml` schema. @@ -268,6 +278,7 @@ impl ProvidersToml { ProviderKind::Sglang => &self.sglang, ProviderKind::Vllm => &self.vllm, ProviderKind::Ollama => &self.ollama, + ProviderKind::Huggingface => &self.huggingface, } } @@ -289,6 +300,7 @@ impl ProvidersToml { ProviderKind::Sglang => &mut self.sglang, ProviderKind::Vllm => &mut self.vllm, ProviderKind::Ollama => &mut self.ollama, + ProviderKind::Huggingface => &mut self.huggingface, } } } @@ -514,6 +526,10 @@ impl ConfigToml { &mut self.providers.wanjie_ark, &project.providers.wanjie_ark, ); + merge_project_provider_config( + &mut self.providers.volcengine, + &project.providers.volcengine, + ); merge_project_provider_config( &mut self.providers.openrouter, &project.providers.openrouter, @@ -529,9 +545,14 @@ impl ConfigToml { &project.providers.siliconflow, ); merge_project_provider_config(&mut self.providers.arcee, &project.providers.arcee); + merge_project_provider_config(&mut self.providers.moonshot, &project.providers.moonshot); merge_project_provider_config(&mut self.providers.sglang, &project.providers.sglang); merge_project_provider_config(&mut self.providers.vllm, &project.providers.vllm); merge_project_provider_config(&mut self.providers.ollama, &project.providers.ollama); + merge_project_provider_config( + &mut self.providers.huggingface, + &project.providers.huggingface, + ); } #[must_use] @@ -585,6 +606,9 @@ impl ConfigToml { "providers.volcengine.api_key" => self.providers.volcengine.api_key.clone(), "providers.volcengine.base_url" => self.providers.volcengine.base_url.clone(), "providers.volcengine.model" => self.providers.volcengine.model.clone(), + "providers.volcengine.http_headers" => { + serialize_http_headers(&self.providers.volcengine.http_headers) + } "providers.wanjie_ark.http_headers" => { serialize_http_headers(&self.providers.wanjie_ark.http_headers) } @@ -649,6 +673,12 @@ impl ConfigToml { "providers.ollama.http_headers" => { serialize_http_headers(&self.providers.ollama.http_headers) } + "providers.huggingface.api_key" => self.providers.huggingface.api_key.clone(), + "providers.huggingface.base_url" => self.providers.huggingface.base_url.clone(), + "providers.huggingface.model" => self.providers.huggingface.model.clone(), + "providers.huggingface.http_headers" => { + serialize_http_headers(&self.providers.huggingface.http_headers) + } _ => self.extras.get(key).map(toml::Value::to_string), } } @@ -744,6 +774,9 @@ impl ConfigToml { "providers.volcengine.model" => { self.providers.volcengine.model = Some(value.to_string()); } + "providers.volcengine.http_headers" => { + self.providers.volcengine.http_headers = parse_http_headers(value)?; + } "providers.wanjie_ark.http_headers" => { self.providers.wanjie_ark.http_headers = parse_http_headers(value)?; } @@ -882,6 +915,18 @@ impl ConfigToml { "providers.ollama.http_headers" => { self.providers.ollama.http_headers = parse_http_headers(value)?; } + "providers.huggingface.api_key" => { + self.providers.huggingface.api_key = Some(value.to_string()); + } + "providers.huggingface.base_url" => { + self.providers.huggingface.base_url = Some(value.to_string()); + } + "providers.huggingface.model" => { + self.providers.huggingface.model = Some(value.to_string()); + } + "providers.huggingface.http_headers" => { + self.providers.huggingface.http_headers = parse_http_headers(value)?; + } _ => { self.extras .insert(key.to_string(), toml::Value::String(value.to_string())); @@ -939,6 +984,9 @@ impl ConfigToml { "providers.volcengine.api_key" => self.providers.volcengine.api_key = None, "providers.volcengine.base_url" => self.providers.volcengine.base_url = None, "providers.volcengine.model" => self.providers.volcengine.model = None, + "providers.volcengine.http_headers" => { + self.providers.volcengine.http_headers.clear(); + } "providers.wanjie_ark.http_headers" => { self.providers.wanjie_ark.http_headers.clear(); } @@ -993,6 +1041,10 @@ impl ConfigToml { "providers.ollama.base_url" => self.providers.ollama.base_url = None, "providers.ollama.model" => self.providers.ollama.model = None, "providers.ollama.http_headers" => self.providers.ollama.http_headers.clear(), + "providers.huggingface.api_key" => self.providers.huggingface.api_key = None, + "providers.huggingface.base_url" => self.providers.huggingface.base_url = None, + "providers.huggingface.model" => self.providers.huggingface.model = None, + "providers.huggingface.http_headers" => self.providers.huggingface.http_headers.clear(), _ => { self.extras.remove(key); } @@ -1249,6 +1301,21 @@ impl ConfigToml { if let Some(v) = serialize_http_headers(&self.providers.ollama.http_headers) { out.insert("providers.ollama.http_headers".to_string(), v); } + if let Some(v) = self.providers.huggingface.api_key.as_ref() { + out.insert( + "providers.huggingface.api_key".to_string(), + redact_secret(v), + ); + } + if let Some(v) = self.providers.huggingface.base_url.as_ref() { + out.insert("providers.huggingface.base_url".to_string(), v.clone()); + } + if let Some(v) = self.providers.huggingface.model.as_ref() { + out.insert("providers.huggingface.model".to_string(), v.clone()); + } + if let Some(v) = serialize_http_headers(&self.providers.huggingface.http_headers) { + out.insert("providers.huggingface.http_headers".to_string(), v); + } for (k, v) in &self.extras { out.insert(k.clone(), v.to_string()); @@ -1298,13 +1365,18 @@ impl ConfigToml { .or_else(|| env.auth_mode.clone()) .or_else(|| provider_cfg.auth_mode.clone()) .or_else(|| self.auth_mode.clone()); - let base_url = cli + let from_file = provider_cfg.api_key.clone().or(root_deepseek_api_key); + let explicit_api_key_for_endpoint = cli.api_key.as_deref().or(from_file.as_deref()); + let configured_base_url = cli .base_url .clone() .or_else(|| env.base_url_for(provider)) .or_else(|| provider_cfg.base_url.clone()) - .or(root_deepseek_base_url) - .unwrap_or_else(|| match provider { + .or(root_deepseek_base_url); + let base_url = if provider == ProviderKind::XiaomiMimo { + resolve_xiaomi_mimo_base_url(configured_base_url, explicit_api_key_for_endpoint) + } else { + configured_base_url.unwrap_or_else(|| match provider { ProviderKind::Deepseek => DEFAULT_DEEPSEEK_BASE_URL.to_string(), ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL.to_string(), ProviderKind::Openai => DEFAULT_OPENAI_BASE_URL.to_string(), @@ -1328,7 +1400,9 @@ impl ConfigToml { ProviderKind::Sglang => DEFAULT_SGLANG_BASE_URL.to_string(), ProviderKind::Vllm => DEFAULT_VLLM_BASE_URL.to_string(), ProviderKind::Ollama => DEFAULT_OLLAMA_BASE_URL.to_string(), - }); + ProviderKind::Huggingface => DEFAULT_HUGGINGFACE_BASE_URL.to_string(), + }) + }; // CLI flag wins outright. Otherwise: config-file → injected secrets/env. // This makes `deepseek auth set` a reliable fix even when the user's // shell still exports an old key. When the file is empty, the injected @@ -1336,7 +1410,6 @@ impl ConfigToml { // falling back to ambient env. let uses_kimi_oauth = provider == ProviderKind::Moonshot && auth_mode.as_deref().is_some_and(auth_mode_uses_kimi_oauth); - let from_file = provider_cfg.api_key.clone().or(root_deepseek_api_key); let (api_key, api_key_source) = if let Some(value) = cli.api_key.clone() { (Some(value), Some(RuntimeApiKeySource::Cli)) } else if uses_kimi_oauth { @@ -1603,6 +1676,14 @@ fn normalize_model_for_provider(provider: ProviderKind, model: &str) -> String { "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner" | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2", ) => DEFAULT_VLLM_FLASH_MODEL.to_string(), + (ProviderKind::Huggingface, "deepseek-v4-pro" | "deepseek-v4pro") => { + DEFAULT_HUGGINGFACE_MODEL.to_string() + } + ( + ProviderKind::Huggingface, + "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-reasoner" + | "deepseek-r1" | "deepseek-v3" | "deepseek-v3.2", + ) => DEFAULT_HUGGINGFACE_FLASH_MODEL.to_string(), _ => model.to_string(), } } @@ -1729,6 +1810,7 @@ fn default_model_for_provider(provider: ProviderKind) -> &'static str { ProviderKind::Sglang => DEFAULT_SGLANG_MODEL, ProviderKind::Vllm => DEFAULT_VLLM_MODEL, ProviderKind::Ollama => DEFAULT_OLLAMA_MODEL, + ProviderKind::Huggingface => DEFAULT_HUGGINGFACE_MODEL, } } @@ -1751,6 +1833,7 @@ fn default_base_url_for_provider(provider: ProviderKind) -> &'static str { ProviderKind::Sglang => DEFAULT_SGLANG_BASE_URL, ProviderKind::Vllm => DEFAULT_VLLM_BASE_URL, ProviderKind::Ollama => DEFAULT_OLLAMA_BASE_URL, + ProviderKind::Huggingface => DEFAULT_HUGGINGFACE_BASE_URL, } } @@ -1761,6 +1844,29 @@ fn moonshot_base_url_uses_kimi_code(base_url: &str) -> bool { || normalized.starts_with("https://api.kimi.com/coding/") } +fn resolve_xiaomi_mimo_base_url(configured: Option, api_key: Option<&str>) -> String { + let uses_token_plan = xiaomi_mimo_api_key_uses_token_plan(api_key); + match configured { + Some(base_url) if uses_token_plan && xiaomi_mimo_base_url_is_pay_as_you_go(&base_url) => { + DEFAULT_XIAOMI_MIMO_BASE_URL.to_string() + } + Some(base_url) => base_url, + None if uses_token_plan || api_key.is_none() => DEFAULT_XIAOMI_MIMO_BASE_URL.to_string(), + None => XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL.to_string(), + } +} + +fn xiaomi_mimo_api_key_uses_token_plan(api_key: Option<&str>) -> bool { + api_key.is_some_and(|key| key.trim_start().starts_with("tp-")) +} + +fn xiaomi_mimo_base_url_is_pay_as_you_go(base_url: &str) -> bool { + matches!( + base_url.trim_end_matches('/').to_ascii_lowercase().as_str(), + "https://api.xiaomimimo.com" | "https://api.xiaomimimo.com/v1" + ) +} + fn base_url_is_custom_for_provider(provider: ProviderKind, base_url: &str) -> bool { if provider.is_siliconflow() && siliconflow_base_url_is_official(base_url) { return false; @@ -2317,8 +2423,11 @@ struct EnvRuntimeOverrides { model: Option, volcengine_model: Option, wanjie_ark_model: Option, + openrouter_model: Option, moonshot_model: Option, xiaomi_mimo_model: Option, + novita_model: Option, + fireworks_model: Option, arcee_model: Option, output_mode: Option, auth_mode: Option, @@ -2345,6 +2454,8 @@ struct EnvRuntimeOverrides { sglang_base_url: Option, vllm_base_url: Option, ollama_base_url: Option, + huggingface_base_url: Option, + huggingface_model: Option, } impl EnvRuntimeOverrides { @@ -2368,6 +2479,9 @@ impl EnvRuntimeOverrides { .or_else(|_| std::env::var("WANJIE_MAAS_MODEL")) .ok() .filter(|v| !v.trim().is_empty()), + openrouter_model: std::env::var("OPENROUTER_MODEL") + .ok() + .filter(|v| !v.trim().is_empty()), moonshot_model: std::env::var("MOONSHOT_MODEL") .or_else(|_| std::env::var("KIMI_MODEL_NAME")) .or_else(|_| std::env::var("KIMI_MODEL")) @@ -2377,6 +2491,12 @@ impl EnvRuntimeOverrides { .or_else(|_| std::env::var("MIMO_MODEL")) .ok() .filter(|v| !v.trim().is_empty()), + novita_model: std::env::var("NOVITA_MODEL") + .ok() + .filter(|v| !v.trim().is_empty()), + fireworks_model: std::env::var("FIREWORKS_MODEL") + .ok() + .filter(|v| !v.trim().is_empty()), arcee_model: std::env::var("ARCEE_MODEL") .ok() .filter(|v| !v.trim().is_empty()), @@ -2455,6 +2575,14 @@ impl EnvRuntimeOverrides { ollama_base_url: std::env::var("OLLAMA_BASE_URL") .ok() .filter(|v| !v.trim().is_empty()), + huggingface_base_url: std::env::var("HUGGINGFACE_BASE_URL") + .or_else(|_| std::env::var("HF_BASE_URL")) + .ok() + .filter(|v| !v.trim().is_empty()), + huggingface_model: std::env::var("HUGGINGFACE_MODEL") + .or_else(|_| std::env::var("HF_MODEL")) + .ok() + .filter(|v| !v.trim().is_empty()), } } @@ -2480,6 +2608,7 @@ impl EnvRuntimeOverrides { ProviderKind::Sglang => self.sglang_base_url.clone(), ProviderKind::Vllm => self.vllm_base_url.clone(), ProviderKind::Ollama => self.ollama_base_url.clone(), + ProviderKind::Huggingface => self.huggingface_base_url.clone(), } } @@ -2487,12 +2616,16 @@ impl EnvRuntimeOverrides { let model = match provider { ProviderKind::WanjieArk => self.wanjie_ark_model.clone(), ProviderKind::Volcengine => self.volcengine_model.clone(), + ProviderKind::Openrouter => self.openrouter_model.clone(), ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => { self.siliconflow_model.clone() } ProviderKind::Arcee => self.arcee_model.clone(), ProviderKind::Moonshot => self.moonshot_model.clone(), ProviderKind::XiaomiMimo => self.xiaomi_mimo_model.clone(), + ProviderKind::Novita => self.novita_model.clone(), + ProviderKind::Fireworks => self.fireworks_model.clone(), + ProviderKind::Huggingface => self.huggingface_model.clone(), _ => None, }?; @@ -2670,6 +2803,7 @@ mod tests { nvidia_nim_base_url: Option, openrouter_api_key: Option, openrouter_base_url: Option, + openrouter_model: Option, xiaomi_mimo_api_key: Option, xiaomi_api_key: Option, mimo_api_key: Option, @@ -2678,17 +2812,26 @@ mod tests { xiaomi_mimo_model: Option, mimo_model: Option, wanjie_ark_api_key: Option, + volcengine_api_key: Option, + volcengine_ark_api_key: Option, + ark_api_key: Option, + volcengine_base_url: Option, + volcengine_ark_base_url: Option, + ark_base_url: Option, wanjie_ark_base_url: Option, wanjie_base_url: Option, wanjie_maas_base_url: Option, volcengine_model: Option, + volcengine_ark_model: Option, wanjie_ark_model: Option, wanjie_model: Option, wanjie_maas_model: Option, novita_api_key: Option, novita_base_url: Option, + novita_model: Option, fireworks_api_key: Option, fireworks_base_url: Option, + fireworks_model: Option, siliconflow_api_key: Option, siliconflow_base_url: Option, siliconflow_model: Option, @@ -2733,6 +2876,7 @@ mod tests { nvidia_nim_base_url: env::var_os("NVIDIA_NIM_BASE_URL"), openrouter_api_key: env::var_os("OPENROUTER_API_KEY"), openrouter_base_url: env::var_os("OPENROUTER_BASE_URL"), + openrouter_model: env::var_os("OPENROUTER_MODEL"), xiaomi_mimo_api_key: env::var_os("XIAOMI_MIMO_API_KEY"), xiaomi_api_key: env::var_os("XIAOMI_API_KEY"), mimo_api_key: env::var_os("MIMO_API_KEY"), @@ -2741,17 +2885,26 @@ mod tests { xiaomi_mimo_model: env::var_os("XIAOMI_MIMO_MODEL"), mimo_model: env::var_os("MIMO_MODEL"), wanjie_ark_api_key: env::var_os("WANJIE_ARK_API_KEY"), + volcengine_api_key: env::var_os("VOLCENGINE_API_KEY"), + volcengine_ark_api_key: env::var_os("VOLCENGINE_ARK_API_KEY"), + ark_api_key: env::var_os("ARK_API_KEY"), + volcengine_base_url: env::var_os("VOLCENGINE_BASE_URL"), + volcengine_ark_base_url: env::var_os("VOLCENGINE_ARK_BASE_URL"), + ark_base_url: env::var_os("ARK_BASE_URL"), wanjie_ark_base_url: env::var_os("WANJIE_ARK_BASE_URL"), wanjie_base_url: env::var_os("WANJIE_BASE_URL"), wanjie_maas_base_url: env::var_os("WANJIE_MAAS_BASE_URL"), volcengine_model: env::var_os("VOLCENGINE_MODEL"), + volcengine_ark_model: env::var_os("VOLCENGINE_ARK_MODEL"), wanjie_ark_model: env::var_os("WANJIE_ARK_MODEL"), wanjie_model: env::var_os("WANJIE_MODEL"), wanjie_maas_model: env::var_os("WANJIE_MAAS_MODEL"), novita_api_key: env::var_os("NOVITA_API_KEY"), novita_base_url: env::var_os("NOVITA_BASE_URL"), + novita_model: env::var_os("NOVITA_MODEL"), fireworks_api_key: env::var_os("FIREWORKS_API_KEY"), fireworks_base_url: env::var_os("FIREWORKS_BASE_URL"), + fireworks_model: env::var_os("FIREWORKS_MODEL"), siliconflow_api_key: env::var_os("SILICONFLOW_API_KEY"), siliconflow_base_url: env::var_os("SILICONFLOW_BASE_URL"), siliconflow_model: env::var_os("SILICONFLOW_MODEL"), @@ -2791,6 +2944,7 @@ mod tests { env::remove_var("NVIDIA_NIM_BASE_URL"); env::remove_var("OPENROUTER_API_KEY"); env::remove_var("OPENROUTER_BASE_URL"); + env::remove_var("OPENROUTER_MODEL"); env::remove_var("XIAOMI_MIMO_API_KEY"); env::remove_var("XIAOMI_API_KEY"); env::remove_var("MIMO_API_KEY"); @@ -2799,16 +2953,26 @@ mod tests { env::remove_var("XIAOMI_MIMO_MODEL"); env::remove_var("MIMO_MODEL"); env::remove_var("WANJIE_ARK_API_KEY"); + env::remove_var("VOLCENGINE_API_KEY"); + env::remove_var("VOLCENGINE_ARK_API_KEY"); + env::remove_var("ARK_API_KEY"); + env::remove_var("VOLCENGINE_BASE_URL"); + env::remove_var("VOLCENGINE_ARK_BASE_URL"); + env::remove_var("ARK_BASE_URL"); env::remove_var("WANJIE_ARK_BASE_URL"); env::remove_var("WANJIE_BASE_URL"); env::remove_var("WANJIE_MAAS_BASE_URL"); + env::remove_var("VOLCENGINE_MODEL"); + env::remove_var("VOLCENGINE_ARK_MODEL"); env::remove_var("WANJIE_ARK_MODEL"); env::remove_var("WANJIE_MODEL"); env::remove_var("WANJIE_MAAS_MODEL"); env::remove_var("NOVITA_API_KEY"); env::remove_var("NOVITA_BASE_URL"); + env::remove_var("NOVITA_MODEL"); env::remove_var("FIREWORKS_API_KEY"); env::remove_var("FIREWORKS_BASE_URL"); + env::remove_var("FIREWORKS_MODEL"); env::remove_var("SILICONFLOW_API_KEY"); env::remove_var("SILICONFLOW_BASE_URL"); env::remove_var("SILICONFLOW_MODEL"); @@ -2865,6 +3029,7 @@ mod tests { Self::restore_var("NVIDIA_NIM_BASE_URL", self.nvidia_nim_base_url.take()); Self::restore_var("OPENROUTER_API_KEY", self.openrouter_api_key.take()); Self::restore_var("OPENROUTER_BASE_URL", self.openrouter_base_url.take()); + Self::restore_var("OPENROUTER_MODEL", self.openrouter_model.take()); Self::restore_var("XIAOMI_MIMO_API_KEY", self.xiaomi_mimo_api_key.take()); Self::restore_var("XIAOMI_API_KEY", self.xiaomi_api_key.take()); Self::restore_var("MIMO_API_KEY", self.mimo_api_key.take()); @@ -2873,17 +3038,29 @@ mod tests { Self::restore_var("XIAOMI_MIMO_MODEL", self.xiaomi_mimo_model.take()); Self::restore_var("MIMO_MODEL", self.mimo_model.take()); Self::restore_var("WANJIE_ARK_API_KEY", self.wanjie_ark_api_key.take()); + Self::restore_var("VOLCENGINE_API_KEY", self.volcengine_api_key.take()); + Self::restore_var("VOLCENGINE_ARK_API_KEY", self.volcengine_ark_api_key.take()); + Self::restore_var("ARK_API_KEY", self.ark_api_key.take()); + Self::restore_var("VOLCENGINE_BASE_URL", self.volcengine_base_url.take()); + Self::restore_var( + "VOLCENGINE_ARK_BASE_URL", + self.volcengine_ark_base_url.take(), + ); + Self::restore_var("ARK_BASE_URL", self.ark_base_url.take()); Self::restore_var("WANJIE_ARK_BASE_URL", self.wanjie_ark_base_url.take()); Self::restore_var("WANJIE_BASE_URL", self.wanjie_base_url.take()); Self::restore_var("WANJIE_MAAS_BASE_URL", self.wanjie_maas_base_url.take()); Self::restore_var("VOLCENGINE_MODEL", self.volcengine_model.take()); + Self::restore_var("VOLCENGINE_ARK_MODEL", self.volcengine_ark_model.take()); Self::restore_var("WANJIE_ARK_MODEL", self.wanjie_ark_model.take()); Self::restore_var("WANJIE_MODEL", self.wanjie_model.take()); Self::restore_var("WANJIE_MAAS_MODEL", self.wanjie_maas_model.take()); Self::restore_var("NOVITA_API_KEY", self.novita_api_key.take()); Self::restore_var("NOVITA_BASE_URL", self.novita_base_url.take()); + Self::restore_var("NOVITA_MODEL", self.novita_model.take()); Self::restore_var("FIREWORKS_API_KEY", self.fireworks_api_key.take()); Self::restore_var("FIREWORKS_BASE_URL", self.fireworks_base_url.take()); + Self::restore_var("FIREWORKS_MODEL", self.fireworks_model.take()); Self::restore_var("SILICONFLOW_API_KEY", self.siliconflow_api_key.take()); Self::restore_var("SILICONFLOW_BASE_URL", self.siliconflow_base_url.take()); Self::restore_var("SILICONFLOW_MODEL", self.siliconflow_model.take()); @@ -3398,6 +3575,48 @@ unix_socket_path = "/tmp/cw-hooks.sock" Ok(()) } + #[test] + fn volcengine_provider_config_values_round_trip() -> Result<()> { + let mut config = ConfigToml::default(); + + config.set_value("providers.volcengine.api_key", "volcengine-secret-value")?; + config.set_value("providers.volcengine.base_url", DEFAULT_VOLCENGINE_BASE_URL)?; + config.set_value("providers.volcengine.model", DEFAULT_VOLCENGINE_MODEL)?; + config.set_value("providers.volcengine.http_headers", "X-Test=ok")?; + + assert_eq!( + config + .get_display_value("providers.volcengine.api_key") + .as_deref(), + Some("volc***alue") + ); + assert_eq!( + config.get_value("providers.volcengine.base_url").as_deref(), + Some(DEFAULT_VOLCENGINE_BASE_URL) + ); + assert_eq!( + config.get_value("providers.volcengine.model").as_deref(), + Some(DEFAULT_VOLCENGINE_MODEL) + ); + assert_eq!( + config + .get_value("providers.volcengine.http_headers") + .as_deref(), + Some("X-Test=ok") + ); + assert_eq!( + config + .list_values() + .get("providers.volcengine.http_headers") + .map(String::as_str), + Some("X-Test=ok") + ); + + config.unset_value("providers.volcengine.http_headers")?; + assert_eq!(config.get_value("providers.volcengine.http_headers"), None); + Ok(()) + } + #[test] fn project_merge_denies_credentials_endpoints_and_provider_selection() { let mut base = ConfigToml { @@ -3421,6 +3640,8 @@ unix_socket_path = "/tmp/cw-hooks.sock" project.providers.openrouter.api_key = Some("attacker-openrouter-key".to_string()); project.providers.openrouter.base_url = Some("https://evil.example/openrouter".to_string()); project.providers.openrouter.model = Some("deepseek/deepseek-v4-pro".to_string()); + project.providers.volcengine.model = Some("DeepSeek-V4-Pro".to_string()); + project.providers.moonshot.model = Some("kimi-k2.6".to_string()); base.merge_project_overrides(project); @@ -3439,6 +3660,11 @@ unix_socket_path = "/tmp/cw-hooks.sock" base.providers.openrouter.model.as_deref(), Some("deepseek/deepseek-v4-pro") ); + assert_eq!( + base.providers.volcengine.model.as_deref(), + Some("DeepSeek-V4-Pro") + ); + assert_eq!(base.providers.moonshot.model.as_deref(), Some("kimi-k2.6")); } #[test] @@ -3843,6 +4069,29 @@ model = "mimo-v2.5-pro" assert_eq!(resolved.model, DEFAULT_XIAOMI_MIMO_MODEL); } + #[test] + fn xiaomi_token_plan_key_rewrites_saved_pay_as_you_go_base_url() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let config: ConfigToml = toml::from_str( + r#" +provider = "xiaomi-mimo" + +[providers.xiaomi_mimo] +api_key = "tp-test-token-plan-key" +base_url = "https://api.xiaomimimo.com/v1" +model = "mimo-v2.5-pro" +"#, + ) + .expect("xiaomi token-plan config"); + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::XiaomiMimo); + assert_eq!(resolved.base_url, DEFAULT_XIAOMI_MIMO_BASE_URL); + assert_eq!(resolved.model, DEFAULT_XIAOMI_MIMO_MODEL); + } + #[test] fn xiaomi_mimo_aliases_resolve_to_canonical_models() { assert_eq!( @@ -4270,13 +4519,14 @@ model = "mimo-v2.5-pro" } #[test] - fn openrouter_env_api_key_falls_back_when_config_missing() { + fn openrouter_env_overrides_key_and_model_when_config_missing() { let _lock = env_lock(); let _env = EnvGuard::without_deepseek_runtime_overrides(); // Safety: test-only environment mutation guarded by a module mutex. unsafe { env::set_var("DEEPSEEK_PROVIDER", "openrouter"); env::set_var("OPENROUTER_API_KEY", "or-env-key"); + env::set_var("OPENROUTER_MODEL", "deepseek-v4-flash"); } let resolved = @@ -4285,6 +4535,7 @@ model = "mimo-v2.5-pro" assert_eq!(resolved.provider, ProviderKind::Openrouter); assert_eq!(resolved.api_key.as_deref(), Some("or-env-key")); assert_eq!(resolved.base_url, DEFAULT_OPENROUTER_BASE_URL); + assert_eq!(resolved.model, DEFAULT_OPENROUTER_FLASH_MODEL); } #[test] @@ -4309,13 +4560,14 @@ model = "mimo-v2.5-pro" } #[test] - fn novita_env_api_key_falls_back_when_config_missing() { + fn novita_env_overrides_key_and_model_when_config_missing() { let _lock = env_lock(); let _env = EnvGuard::without_deepseek_runtime_overrides(); // Safety: test-only environment mutation guarded by a module mutex. unsafe { env::set_var("DEEPSEEK_PROVIDER", "novita"); env::set_var("NOVITA_API_KEY", "novita-env-key"); + env::set_var("NOVITA_MODEL", "deepseek-v4-flash"); } let resolved = @@ -4324,16 +4576,21 @@ model = "mimo-v2.5-pro" assert_eq!(resolved.provider, ProviderKind::Novita); assert_eq!(resolved.api_key.as_deref(), Some("novita-env-key")); assert_eq!(resolved.base_url, DEFAULT_NOVITA_BASE_URL); + assert_eq!(resolved.model, DEFAULT_NOVITA_FLASH_MODEL); } #[test] - fn fireworks_env_api_key_falls_back_when_config_missing() { + fn fireworks_env_overrides_key_and_model_when_config_missing() { let _lock = env_lock(); let _env = EnvGuard::without_deepseek_runtime_overrides(); // Safety: test-only environment mutation guarded by a module mutex. unsafe { env::set_var("DEEPSEEK_PROVIDER", "fireworks"); env::set_var("FIREWORKS_API_KEY", "fw-env-key"); + env::set_var( + "FIREWORKS_MODEL", + "accounts/fireworks/models/account-specific-model", + ); } let resolved = @@ -4342,6 +4599,10 @@ model = "mimo-v2.5-pro" assert_eq!(resolved.provider, ProviderKind::Fireworks); assert_eq!(resolved.api_key.as_deref(), Some("fw-env-key")); assert_eq!(resolved.base_url, DEFAULT_FIREWORKS_BASE_URL); + assert_eq!( + resolved.model, + "accounts/fireworks/models/account-specific-model" + ); } #[test] @@ -4472,6 +4733,30 @@ model = "mimo-v2.5-pro" assert_eq!(resolved.model, "account-model-id"); } + #[test] + fn volcengine_env_aliases_override_key_base_url_and_model() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + // Safety: test-only environment mutation guarded by a module mutex. + unsafe { + env::set_var("DEEPSEEK_PROVIDER", "volcengine"); + env::set_var("ARK_API_KEY", "volcengine-env-key"); + env::set_var("ARK_BASE_URL", "https://volcengine.example/api/coding/v3"); + env::set_var("VOLCENGINE_ARK_MODEL", "DeepSeek-V4-Flash"); + } + + let resolved = + ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Volcengine); + assert_eq!(resolved.api_key.as_deref(), Some("volcengine-env-key")); + assert_eq!( + resolved.base_url, + "https://volcengine.example/api/coding/v3" + ); + assert_eq!(resolved.model, "DeepSeek-V4-Flash"); + } + #[test] fn openrouter_provider_normalizes_flash_aliases() { let _lock = env_lock(); diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index 25c92188..3e3ca487 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -1117,6 +1117,7 @@ pub(super) fn apply_reasoning_effort( | ApiProvider::Atlascloud | ApiProvider::WanjieArk | ApiProvider::Arcee + | ApiProvider::Huggingface | ApiProvider::Moonshot | ApiProvider::Ollama => {} ApiProvider::NvidiaNim => { @@ -1151,7 +1152,7 @@ pub(super) fn apply_reasoning_effort( ApiProvider::XiaomiMimo => { body["thinking"] = json!({ "type": "enabled" }); } - ApiProvider::Arcee => { + ApiProvider::Arcee | ApiProvider::Huggingface => { let value = match normalized.as_str() { "minimal" => "minimal", "low" => "low", @@ -1205,7 +1206,7 @@ pub(super) fn apply_reasoning_effort( ApiProvider::XiaomiMimo => { body["thinking"] = json!({ "type": "enabled" }); } - ApiProvider::Arcee => { + ApiProvider::Arcee | ApiProvider::Huggingface => { body["reasoning_effort"] = json!("high"); } ApiProvider::Fireworks => { diff --git a/crates/tui/src/commands/config.rs b/crates/tui/src/commands/config.rs index 4e9b9b1a..4f2a0ca9 100644 --- a/crates/tui/src/commands/config.rs +++ b/crates/tui/src/commands/config.rs @@ -6,7 +6,8 @@ use std::time::Duration; use super::CommandResult; use crate::client::DeepSeekClient; use crate::config::{ - COMMON_DEEPSEEK_MODELS, Config, clear_api_key, effective_home_dir, expand_path, + ApiProvider, COMMON_DEEPSEEK_MODELS, Config, DEFAULT_XIAOMI_MIMO_BASE_URL, + XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL, clear_api_key, effective_home_dir, expand_path, normalize_model_name_for_provider, }; use crate::config_ui::{ConfigUiMode, parse_mode}; @@ -125,7 +126,9 @@ fn show_single_setting(app: &App, key: &str) -> CommandResult { Some(app.model.clone()) } } + "provider" => Some(app.api_provider.as_str().to_string()), "approval_mode" | "approval" => Some(app.approval_mode.label().to_string()), + "allow_shell" | "shell" | "exec_shell" => Some(app.allow_shell.to_string()), "base_url" => { let config = match Config::load(app.config_path.clone(), app.config_profile.as_deref()) { @@ -136,6 +139,19 @@ fn show_single_setting(app: &App, key: &str) -> CommandResult { }; Some(config.deepseek_base_url()) } + "provider_url" | "provider_base_url" | "endpoint" => { + let config = match Config::load(app.config_path.clone(), app.config_profile.as_deref()) + { + Ok(mut config) => { + config.provider = Some(app.api_provider.as_str().to_string()); + config + } + Err(err) => { + return CommandResult::error(format!("Failed to load config: {err}")); + } + }; + Some(config.deepseek_base_url()) + } "locale" | "language" => Some(locale_display(app.ui_locale).to_string()), "theme" | "ui_theme" => { Some(crate::palette::theme_label_for_mode(app.ui_theme.mode).to_string()) @@ -366,6 +382,146 @@ pub fn persist_root_string_key( Ok(path) } +fn persist_root_bool_key( + config_path: Option<&Path>, + key: &str, + value: bool, +) -> anyhow::Result { + use anyhow::Context; + use std::fs; + + let path = config_toml_path(config_path)?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create config directory {}", parent.display()))?; + } + + let mut doc: toml::Value = if path.exists() { + let raw = fs::read_to_string(&path) + .with_context(|| format!("failed to read config at {}", path.display()))?; + toml::from_str(&raw) + .with_context(|| format!("failed to parse config at {}", path.display()))? + } else { + toml::Value::Table(toml::value::Table::new()) + }; + let table = doc + .as_table_mut() + .context("config.toml root must be a table")?; + table.insert(key.to_string(), toml::Value::Boolean(value)); + let body = toml::to_string_pretty(&doc).context("failed to serialize config.toml")?; + fs::write(&path, body) + .with_context(|| format!("failed to write config at {}", path.display()))?; + Ok(path) +} + +fn persist_provider_base_url_key( + config_path: Option<&Path>, + provider: ApiProvider, + value: &str, +) -> anyhow::Result { + use anyhow::Context; + use std::fs; + + let path = config_toml_path(config_path)?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create config directory {}", parent.display()))?; + } + + let mut doc: toml::Value = if path.exists() { + let raw = fs::read_to_string(&path) + .with_context(|| format!("failed to read config at {}", path.display()))?; + toml::from_str(&raw) + .with_context(|| format!("failed to parse config at {}", path.display()))? + } else { + toml::Value::Table(toml::value::Table::new()) + }; + let table = doc + .as_table_mut() + .context("config.toml root must be a table")?; + let providers = table + .entry("providers".to_string()) + .or_insert_with(|| toml::Value::Table(toml::value::Table::new())) + .as_table_mut() + .context("`providers` must be a table")?; + let provider_key = provider_base_url_table_key(provider)?; + let entry = providers + .entry(provider_key.to_string()) + .or_insert_with(|| toml::Value::Table(toml::value::Table::new())) + .as_table_mut() + .with_context(|| format!("`providers.{provider_key}` must be a table"))?; + entry.insert( + "base_url".to_string(), + toml::Value::String(value.to_string()), + ); + + let body = toml::to_string_pretty(&doc).context("failed to serialize config.toml")?; + fs::write(&path, body) + .with_context(|| format!("failed to write config at {}", path.display()))?; + Ok(path) +} + +fn provider_base_url_table_key(provider: ApiProvider) -> anyhow::Result<&'static str> { + match provider { + ApiProvider::Deepseek | ApiProvider::DeepseekCN => { + anyhow::bail!("DeepSeek uses the root base_url setting") + } + ApiProvider::NvidiaNim => Ok("nvidia_nim"), + ApiProvider::Openai => Ok("openai"), + ApiProvider::Atlascloud => Ok("atlascloud"), + ApiProvider::WanjieArk => Ok("wanjie_ark"), + ApiProvider::Volcengine => Ok("volcengine"), + ApiProvider::Openrouter => Ok("openrouter"), + ApiProvider::XiaomiMimo => Ok("xiaomi_mimo"), + ApiProvider::Novita => Ok("novita"), + ApiProvider::Fireworks => Ok("fireworks"), + ApiProvider::Siliconflow | ApiProvider::SiliconflowCn => Ok("siliconflow"), + ApiProvider::Arcee => Ok("arcee"), + ApiProvider::Huggingface => Ok("huggingface"), + ApiProvider::Moonshot => Ok("moonshot"), + ApiProvider::Sglang => Ok("sglang"), + ApiProvider::Vllm => Ok("vllm"), + ApiProvider::Ollama => Ok("ollama"), + } +} + +fn resolve_provider_url_value(provider: ApiProvider, value: &str) -> Result { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err("provider_url cannot be empty".to_string()); + } + + if provider == ApiProvider::XiaomiMimo { + match trimmed.to_ascii_lowercase().as_str() { + "token" | "token-plan" | "token_plan" | "token-plan-sgp" | "sgp" => { + return Ok(DEFAULT_XIAOMI_MIMO_BASE_URL.to_string()); + } + "payg" | "pay-go" | "paygo" | "pay-as-you-go" | "pay_as_you_go" | "api" => { + return Ok(XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL.to_string()); + } + _ => {} + } + } + + if trimmed.contains("://") { + Ok(trimmed.to_string()) + } else if provider == ApiProvider::XiaomiMimo { + Err("provider_url for Xiaomi MiMo must be token-plan, pay-as-you-go, or a URL".to_string()) + } else { + Err("provider_url must be a URL".to_string()) + } +} + +fn parse_config_bool(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "on" | "true" | "yes" | "1" | "enabled" => Ok(true), + "off" | "false" | "no" | "0" | "disabled" => Ok(false), + _ => Err(format!( + "Failed to parse boolean '{value}': expected on/off, true/false, yes/no." + )), + } +} + /// Resolve the path to `~/.codewhale/config.toml` (or /// `$CODEWHALE_CONFIG_PATH` / `$DEEPSEEK_CONFIG_PATH`). Mirrors what `Config::load` accepts so we /// never write to a different file than the one we read. @@ -434,6 +590,25 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> AppAction::UpdateCompaction(app.compaction_config()), ); } + "provider" => { + let value = value.trim(); + let Some(provider) = ApiProvider::parse(value) else { + return CommandResult::error(format!( + "Unknown provider '{value}'. Use: {}.", + ApiProvider::names_hint() + )); + }; + if provider == app.api_provider { + return CommandResult::message(format!("provider = {}", provider.as_str())); + } + return CommandResult::with_message_and_action( + format!("provider = {}", provider.as_str()), + AppAction::SwitchProvider { + provider, + model: None, + }, + ); + } "approval_mode" | "approval" => { let mode = ApprovalMode::from_config_value(value); return match mode { @@ -446,6 +621,27 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> ), }; } + "allow_shell" | "shell" | "exec_shell" => { + let enabled = match parse_config_bool(value) { + Ok(enabled) => enabled, + Err(err) => return CommandResult::error(err), + }; + app.allow_shell = enabled; + let suffix = if persist { + match persist_root_bool_key(app.config_path.as_deref(), "allow_shell", enabled) { + Ok(path) => format!(" (saved to {})", path.display()), + Err(err) => return CommandResult::error(format!("Failed to save: {err}")), + } + } else { + " (session only, add --save to persist)".to_string() + }; + let mode_hint = if enabled { + " Agent mode will expose shell on the next turn with approval gating. YOLO also enables shell and auto-approves." + } else { + " Shell tools will be hidden on the next turn. Re-enable with `/config allow_shell true`." + }; + return CommandResult::message(format!("allow_shell = {enabled}{suffix}.{mode_hint}")); + } "mcp_config_path" | "mcp" => { if value.trim().is_empty() { return CommandResult::error("mcp_config_path cannot be empty"); @@ -490,6 +686,46 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> "base_url must be saved with --save; client base URL is loaded from config on startup. Restart and re-open your session after saving.", ); } + "provider_url" | "provider_base_url" | "endpoint" => { + let value = match resolve_provider_url_value(app.api_provider, value) { + Ok(value) => value, + Err(err) => return CommandResult::error(err), + }; + if matches!( + app.api_provider, + ApiProvider::Deepseek | ApiProvider::DeepseekCN + ) { + if persist { + match persist_root_string_key(app.config_path.as_deref(), "base_url", &value) { + Ok(path) => { + return CommandResult::message(format!( + "provider_url = {value} (saved to {}; restart required)", + path.display() + )); + } + Err(err) => return CommandResult::error(format!("Failed to save: {err}")), + } + } + } else if persist { + match persist_provider_base_url_key( + app.config_path.as_deref(), + app.api_provider, + &value, + ) { + Ok(path) => { + return CommandResult::message(format!( + "provider_url = {value} for {} (saved to {}; restart required)", + app.api_provider.as_str(), + path.display() + )); + } + Err(err) => return CommandResult::error(format!("Failed to save: {err}")), + } + } + return CommandResult::error( + "provider_url must be saved with --save; client base URL is loaded from config on startup. Restart and re-open your session after saving.", + ); + } _ => {} } @@ -1941,6 +2177,88 @@ mod tests { assert!(saved.contains("base_url = \"https://example.internal.local/v1\"")); } + #[test] + fn config_command_provider_emits_switch_action() { + let mut app = create_test_app(); + let result = config_command(&mut app, Some("provider openrouter")); + + assert!(!result.is_error); + assert_eq!(result.message.as_deref(), Some("provider = openrouter")); + match result.action { + Some(AppAction::SwitchProvider { provider, model }) => { + assert_eq!(provider, ApiProvider::Openrouter); + assert_eq!(model, None); + } + other => panic!("expected SwitchProvider action, got {other:?}"), + } + } + + #[test] + fn config_command_provider_rejects_unknown_provider() { + let mut app = create_test_app(); + let result = config_command(&mut app, Some("provider anthropic")); + assert!(result.is_error); + let msg = result.message.unwrap(); + assert!(msg.contains("Unknown provider 'anthropic'")); + assert!(msg.contains("openrouter")); + assert!(msg.contains("xiaomi-mimo")); + } + + #[test] + fn config_command_allow_shell_enables_agent_shell_session_only() { + let mut app = create_test_app(); + assert!(!app.allow_shell); + + let result = config_command(&mut app, Some("allow_shell true")); + assert!(!result.is_error); + assert!(app.allow_shell); + let msg = result.message.unwrap(); + + assert!(msg.contains("allow_shell = true")); + assert!(msg.contains("session only")); + assert!(msg.contains("Agent mode")); + assert!(msg.contains("approval gating")); + assert!(msg.contains("next turn")); + assert!(msg.contains("YOLO also enables shell and auto-approves")); + } + + #[test] + fn config_command_allow_shell_save_persists_root_boolean() { + let temp_root = env::temp_dir().join(format!( + "codewhale-allow-shell-save-app-path-test-{}", + std::process::id() + )); + fs::create_dir_all(&temp_root).unwrap(); + + let config_path = temp_root.join("custom-config.toml"); + + let mut app = create_test_app(); + app.config_path = Some(config_path.clone()); + let result = config_command(&mut app, Some("allow_shell true --save")); + let msg = result.message.unwrap(); + let saved = fs::read_to_string(&config_path).unwrap(); + + assert!(app.allow_shell); + assert_eq!( + msg, + format!( + "allow_shell = true (saved to {}). Agent mode will expose shell on the next turn with approval gating. YOLO also enables shell and auto-approves.", + config_path.display() + ) + ); + assert!(saved.contains("allow_shell = true")); + } + + #[test] + fn config_command_allow_shell_rejects_invalid_boolean() { + let mut app = create_test_app(); + let result = config_command(&mut app, Some("allow_shell maybe")); + assert!(result.is_error); + assert!(!app.allow_shell); + let msg = result.message.unwrap(); + assert!(msg.contains("Failed to parse boolean 'maybe'")); + } + #[test] fn config_command_base_url_without_save_requires_save() { let _lock = lock_test_env(); @@ -2036,6 +2354,50 @@ mod tests { assert!(saved.contains("base_url = \"https://example.session.local/v1\"")); } + #[test] + fn config_command_provider_url_token_plan_persists_provider_base_url() { + let temp_root = env::temp_dir().join(format!( + "codewhale-provider-url-save-app-path-test-{}", + std::process::id() + )); + fs::create_dir_all(&temp_root).unwrap(); + + let config_path = temp_root.join("custom-config.toml"); + + let mut app = create_test_app(); + app.api_provider = ApiProvider::XiaomiMimo; + app.config_path = Some(config_path.clone()); + let result = config_command(&mut app, Some("provider_url token-plan --save")); + let msg = result.message.unwrap(); + let saved = fs::read_to_string(&config_path).unwrap(); + + assert_eq!( + msg, + format!( + "provider_url = {} for xiaomi-mimo (saved to {}; restart required)", + DEFAULT_XIAOMI_MIMO_BASE_URL, + config_path.display() + ) + ); + assert!(saved.contains("[providers.xiaomi_mimo]")); + assert!(saved.contains(&format!("base_url = \"{}\"", DEFAULT_XIAOMI_MIMO_BASE_URL))); + } + + #[test] + fn config_command_provider_url_without_save_requires_save() { + let _lock = lock_test_env(); + let mut app = create_test_app(); + app.api_provider = ApiProvider::XiaomiMimo; + let result = config_command(&mut app, Some("provider_url token-plan")); + assert!(result.is_error); + let msg = result.message.unwrap(); + + assert!( + msg.contains("provider_url must be saved with --save"), + "got {msg}" + ); + } + #[test] fn theme_command_accepts_grayscale_arg() { let nanos = SystemTime::now() diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index dd10da10..9a953be6 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -179,7 +179,7 @@ pub const COMMANDS: &[CommandInfo] = &[ CommandInfo { name: "provider", aliases: &[], - usage: "/provider [name]", + usage: "/provider [name] [model]", description_id: MessageId::CmdProviderDescription, }, CommandInfo { diff --git a/crates/tui/src/commands/provider.rs b/crates/tui/src/commands/provider.rs index 34172c9e..911e6299 100644 --- a/crates/tui/src/commands/provider.rs +++ b/crates/tui/src/commands/provider.rs @@ -30,7 +30,8 @@ pub fn provider(app: &mut App, args: Option<&str>) -> CommandResult { let Some(target) = ApiProvider::parse(name) else { return CommandResult::error(format!( - "Unknown provider '{name}'. Expected: deepseek, nvidia-nim, openai, atlascloud, wanjie-ark, openrouter, xiaomi-mimo, novita, fireworks, siliconflow, siliconflow-CN, moonshot, sglang, vllm, or ollama." + "Unknown provider '{name}'. Expected: {}.", + ApiProvider::names_hint() )); }; diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 28091d20..cc60d20f 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -90,7 +90,8 @@ pub const RECENT_OPENROUTER_LARGE_MODELS: &[&str] = &[ ]; pub const DEFAULT_OPENROUTER_BASE_URL: &str = "https://openrouter.ai/api/v1"; pub const DEFAULT_XIAOMI_MIMO_MODEL: &str = "mimo-v2.5-pro"; -pub const DEFAULT_XIAOMI_MIMO_BASE_URL: &str = "https://api.xiaomimimo.com/v1"; +pub const XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL: &str = "https://api.xiaomimimo.com/v1"; +pub const DEFAULT_XIAOMI_MIMO_BASE_URL: &str = "https://token-plan-sgp.xiaomimimo.com/v1"; pub const XIAOMI_MIMO_V2_5_OMNI_MODEL: &str = "mimo-v2.5"; pub const XIAOMI_MIMO_ASR_MODEL: &str = "mimo-v2.5-asr"; pub const XIAOMI_MIMO_TTS_MODEL: &str = "mimo-v2.5-tts"; @@ -122,6 +123,9 @@ pub const DEFAULT_VLLM_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash"; pub const DEFAULT_VLLM_BASE_URL: &str = "http://localhost:8000/v1"; pub const DEFAULT_OLLAMA_MODEL: &str = "deepseek-coder:1.3b"; pub const DEFAULT_OLLAMA_BASE_URL: &str = "http://localhost:11434/v1"; +pub const DEFAULT_HUGGINGFACE_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro"; +pub const DEFAULT_HUGGINGFACE_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash"; +pub const DEFAULT_HUGGINGFACE_BASE_URL: &str = "https://router.huggingface.co/v1"; /// Legacy `deepseek-cn` provider alias. /// /// DeepSeek's official API host is the same worldwide. Keep this alias for @@ -161,9 +165,24 @@ pub enum ApiProvider { Sglang, Vllm, Ollama, + Huggingface, } impl ApiProvider { + #[must_use] + pub fn names_hint() -> String { + let mut names = Vec::with_capacity(Self::all().len() + 1); + names.push(Self::Deepseek.as_str()); + names.push(Self::DeepseekCN.as_str()); + names.extend( + Self::all() + .iter() + .filter(|provider| !matches!(provider, Self::Deepseek)) + .map(|provider| provider.as_str()), + ); + names.join(", ") + } + #[must_use] pub fn parse(value: &str) -> Option { match value.trim().to_ascii_lowercase().as_str() { @@ -194,6 +213,7 @@ impl ApiProvider { "sglang" | "sg-lang" => Some(Self::Sglang), "vllm" | "v-llm" => Some(Self::Vllm), "ollama" | "ollama-local" => Some(Self::Ollama), + "huggingface" | "hugging-face" | "hugging_face" | "hf" => Some(Self::Huggingface), _ => None, } } @@ -219,6 +239,7 @@ impl ApiProvider { Self::Sglang => "sglang", Self::Vllm => "vllm", Self::Ollama => "ollama", + Self::Huggingface => "huggingface", } } @@ -244,6 +265,7 @@ impl ApiProvider { Self::Sglang => "SGLang", Self::Vllm => "vLLM", Self::Ollama => "Ollama", + Self::Huggingface => "Hugging Face", } } @@ -268,6 +290,7 @@ impl ApiProvider { Self::Sglang, Self::Vllm, Self::Ollama, + Self::Huggingface, ] } } @@ -691,6 +714,10 @@ pub fn normalize_model_name_for_provider(provider: ApiProvider, model: &str) -> .or_else(|| normalize_custom_model_id(model)); } + if matches!(provider, ApiProvider::Huggingface) { + return normalize_custom_model_id(model); + } + let normalized = normalize_model_name(model)?; if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) && let Some(canonical) = canonical_official_deepseek_model_id(&normalized) @@ -754,6 +781,9 @@ pub fn model_completion_names_for_provider(provider: ApiProvider) -> Vec<&'stati } ApiProvider::Arcee => vec![DEFAULT_ARCEE_MODEL, ARCEE_TRINITY_LARGE_PREVIEW_MODEL], ApiProvider::Moonshot => vec![DEFAULT_MOONSHOT_MODEL], + ApiProvider::Huggingface => { + vec![DEFAULT_HUGGINGFACE_MODEL, DEFAULT_HUGGINGFACE_FLASH_MODEL] + } ApiProvider::WanjieArk => vec![DEFAULT_WANJIE_ARK_MODEL], ApiProvider::Sglang => vec![DEFAULT_SGLANG_MODEL, DEFAULT_SGLANG_FLASH_MODEL], ApiProvider::Vllm => vec![DEFAULT_VLLM_MODEL, DEFAULT_VLLM_FLASH_MODEL], @@ -1866,6 +1896,8 @@ pub struct ProvidersConfig { pub vllm: ProviderConfig, #[serde(default)] pub ollama: ProviderConfig, + #[serde(default, alias = "hugging-face", alias = "hf")] + pub huggingface: ProviderConfig, } #[derive(Debug, Clone, Deserialize, Default)] @@ -2027,6 +2059,7 @@ impl Config { ApiProvider::Vllm => "providers.vllm", ApiProvider::Ollama => "providers.ollama", ApiProvider::Volcengine => "providers.volcengine", + ApiProvider::Huggingface => "providers.huggingface", ApiProvider::NvidiaNim => "providers.nvidia_nim", ApiProvider::Deepseek | ApiProvider::DeepseekCN => return, }; @@ -2043,7 +2076,8 @@ impl Config { && ApiProvider::parse(provider).is_none() { anyhow::bail!( - "Invalid provider '{provider}': expected deepseek, deepseek-cn, nvidia-nim, openai, atlascloud, wanjie-ark, volcengine, openrouter, xiaomi-mimo, novita, fireworks, siliconflow, siliconflow-CN, arcee, moonshot, sglang, vllm, or ollama." + "Invalid provider '{provider}': expected {}.", + ApiProvider::names_hint() ); } if let Some(ref key) = self.api_key @@ -2173,6 +2207,7 @@ impl Config { ApiProvider::Vllm => &providers.vllm, ApiProvider::Ollama => &providers.ollama, ApiProvider::Volcengine => &providers.volcengine, + ApiProvider::Huggingface => &providers.huggingface, }) } @@ -2196,6 +2231,7 @@ impl Config { ApiProvider::Vllm => &mut providers.vllm, ApiProvider::Ollama => &mut providers.ollama, ApiProvider::Volcengine => &mut providers.volcengine, + ApiProvider::Huggingface => &mut providers.huggingface, } } @@ -2299,6 +2335,7 @@ impl Config { ApiProvider::Vllm => DEFAULT_VLLM_MODEL, ApiProvider::Ollama => DEFAULT_OLLAMA_MODEL, ApiProvider::Volcengine => DEFAULT_VOLCENGINE_MODEL, + ApiProvider::Huggingface => DEFAULT_HUGGINGFACE_MODEL, } .to_string() } @@ -2335,40 +2372,52 @@ impl Config { | ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama - | ApiProvider::Volcengine => None, + | ApiProvider::Volcengine + | ApiProvider::Huggingface => None, }; - let base = provider_base.or(root_base).unwrap_or_else(|| { - match provider { - ApiProvider::Deepseek => DEFAULT_DEEPSEEK_BASE_URL, - ApiProvider::DeepseekCN => DEFAULT_DEEPSEEKCN_BASE_URL, - ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL, - ApiProvider::Openai => DEFAULT_OPENAI_BASE_URL, - ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL, - ApiProvider::WanjieArk => DEFAULT_WANJIE_ARK_BASE_URL, - ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL, - ApiProvider::XiaomiMimo => DEFAULT_XIAOMI_MIMO_BASE_URL, - ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL, - ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL, - ApiProvider::Siliconflow => DEFAULT_SILICONFLOW_BASE_URL, - ApiProvider::SiliconflowCn => DEFAULT_SILICONFLOW_CN_BASE_URL, - ApiProvider::Arcee => DEFAULT_ARCEE_BASE_URL, - ApiProvider::Moonshot => { - if self - .provider_config() - .is_some_and(provider_config_uses_kimi_oauth) - { - DEFAULT_KIMI_CODE_BASE_URL - } else { - DEFAULT_MOONSHOT_BASE_URL + let configured_base_url = provider_base.or(root_base); + let base = if provider == ApiProvider::XiaomiMimo { + let env_api_key = xiaomi_mimo_env_api_key_for_base_url(); + let config_api_key = self + .provider_config_for(provider) + .and_then(|provider| provider.api_key.as_deref()); + let api_key = config_api_key.or(env_api_key.as_deref()); + resolve_xiaomi_mimo_base_url(configured_base_url, api_key) + } else { + configured_base_url.unwrap_or_else(|| { + match provider { + ApiProvider::Deepseek => DEFAULT_DEEPSEEK_BASE_URL, + ApiProvider::DeepseekCN => DEFAULT_DEEPSEEKCN_BASE_URL, + ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL, + ApiProvider::Openai => DEFAULT_OPENAI_BASE_URL, + ApiProvider::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL, + ApiProvider::WanjieArk => DEFAULT_WANJIE_ARK_BASE_URL, + ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL, + ApiProvider::XiaomiMimo => DEFAULT_XIAOMI_MIMO_BASE_URL, + ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL, + ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL, + ApiProvider::Siliconflow => DEFAULT_SILICONFLOW_BASE_URL, + ApiProvider::SiliconflowCn => DEFAULT_SILICONFLOW_CN_BASE_URL, + ApiProvider::Arcee => DEFAULT_ARCEE_BASE_URL, + ApiProvider::Moonshot => { + if self + .provider_config() + .is_some_and(provider_config_uses_kimi_oauth) + { + DEFAULT_KIMI_CODE_BASE_URL + } else { + DEFAULT_MOONSHOT_BASE_URL + } } + ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL, + ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL, + ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL, + ApiProvider::Volcengine => DEFAULT_VOLCENGINE_BASE_URL, + ApiProvider::Huggingface => DEFAULT_HUGGINGFACE_BASE_URL, } - ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL, - ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL, - ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL, - ApiProvider::Volcengine => DEFAULT_VOLCENGINE_BASE_URL, - } - .to_string() - }); + .to_string() + }) + }; normalize_base_url(&base) } @@ -2411,6 +2460,7 @@ impl Config { ApiProvider::Vllm => "vllm", ApiProvider::Ollama => "ollama", ApiProvider::Volcengine => "volcengine", + ApiProvider::Huggingface => "huggingface", }; // 0. DeepSeek compatibility slot. The legacy top-level `api_key` @@ -2450,6 +2500,20 @@ impl Config { return Ok(value); } + // Huggingface supports HF_TOKEN as a fallback env var. + if matches!(provider, ApiProvider::Huggingface) { + if let Ok(value) = std::env::var("HUGGINGFACE_API_KEY") + && !value.trim().is_empty() + { + return Ok(value); + } + if let Ok(value) = std::env::var("HF_TOKEN") + && !value.trim().is_empty() + { + return Ok(value); + } + } + if base_url_uses_local_host(&self.deepseek_base_url()) { return Ok(String::new()); } @@ -2486,6 +2550,11 @@ impl Config { set WANJIE_ARK_API_KEY/WANJIE_API_KEY/WANJIE_MAAS_API_KEY, or add \ [providers.wanjie_ark] api_key in ~/.codewhale/config.toml." ), + ApiProvider::Volcengine => anyhow::bail!( + "Volcengine Ark API key not found. Run 'codewhale auth set --provider volcengine', \ + set VOLCENGINE_API_KEY/VOLCENGINE_ARK_API_KEY/ARK_API_KEY, or add \ + [providers.volcengine] api_key in ~/.codewhale/config.toml." + ), ApiProvider::Openrouter => anyhow::bail!( "OpenRouter API key not found. Run 'codewhale auth set --provider openrouter', \ set OPENROUTER_API_KEY, or add [providers.openrouter] api_key in ~/.codewhale/config.toml." @@ -2510,6 +2579,10 @@ impl Config { "Arcee AI API key not found. Run 'codewhale auth set --provider arcee', \ set ARCEE_API_KEY, or add [providers.arcee] api_key in ~/.codewhale/config.toml." ), + ApiProvider::Huggingface => anyhow::bail!( + "Hugging Face API key not found. Run 'codewhale auth set --provider huggingface', \ + set HUGGINGFACE_API_KEY or HF_TOKEN, or add [providers.huggingface] api_key in ~/.codewhale/config.toml." + ), ApiProvider::Moonshot => anyhow::bail!( "Moonshot/Kimi API key not found. Run 'codewhale auth set --provider moonshot', \ set MOONSHOT_API_KEY/KIMI_API_KEY, or add [providers.moonshot] api_key. \ @@ -2518,10 +2591,7 @@ impl Config { ), // Self-hosted deployments commonly run without auth on localhost. // Return an empty key and let the client omit the Authorization header. - ApiProvider::Sglang - | ApiProvider::Vllm - | ApiProvider::Ollama - | ApiProvider::Volcengine => Ok(String::new()), + ApiProvider::Sglang | ApiProvider::Vllm | ApiProvider::Ollama => Ok(String::new()), } } @@ -3259,6 +3329,13 @@ fn apply_env_overrides(config: &mut Config) { .atlascloud .base_url = Some(value); } + ApiProvider::Huggingface => { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .huggingface + .base_url = Some(value); + } } } if matches!(config.api_provider(), ApiProvider::NvidiaNim) @@ -3328,6 +3405,18 @@ fn apply_env_overrides(config: &mut Config) { .wanjie_ark .base_url = Some(value); } + if matches!(config.api_provider(), ApiProvider::Volcengine) + && let Ok(value) = std::env::var("VOLCENGINE_BASE_URL") + .or_else(|_| std::env::var("VOLCENGINE_ARK_BASE_URL")) + .or_else(|_| std::env::var("ARK_BASE_URL")) + && !value.trim().is_empty() + { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .volcengine + .base_url = Some(value); + } if matches!(config.api_provider(), ApiProvider::Novita) && let Ok(value) = std::env::var("NOVITA_BASE_URL") && !value.trim().is_empty() @@ -3370,6 +3459,16 @@ fn apply_env_overrides(config: &mut Config) { .arcee .base_url = Some(value); } + if matches!(config.api_provider(), ApiProvider::Huggingface) + && let Ok(value) = std::env::var("HUGGINGFACE_BASE_URL") + && !value.trim().is_empty() + { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .huggingface + .base_url = Some(value); + } if matches!(config.api_provider(), ApiProvider::Moonshot) && let Ok(value) = std::env::var("MOONSHOT_BASE_URL").or_else(|_| std::env::var("KIMI_BASE_URL")) @@ -3431,6 +3530,7 @@ fn apply_env_overrides(config: &mut Config) { ApiProvider::Vllm => &mut providers.vllm, ApiProvider::Ollama => &mut providers.ollama, ApiProvider::Volcengine => &mut providers.volcengine, + ApiProvider::Huggingface => &mut providers.huggingface, }; let mut provider_headers = entry.http_headers.clone().unwrap_or_default(); provider_headers.extend(headers); @@ -3489,6 +3589,7 @@ fn apply_env_overrides(config: &mut Config) { && let Ok(value) = std::env::var("WANJIE_ARK_MODEL") .or_else(|_| std::env::var("WANJIE_MODEL")) .or_else(|_| std::env::var("WANJIE_MAAS_MODEL")) + && !value.trim().is_empty() { config .providers @@ -3496,10 +3597,52 @@ fn apply_env_overrides(config: &mut Config) { .wanjie_ark .model = Some(value); } + if matches!(config.api_provider(), ApiProvider::Openrouter) + && let Ok(value) = std::env::var("OPENROUTER_MODEL") + && !value.trim().is_empty() + { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .openrouter + .model = Some(value); + } + if matches!(config.api_provider(), ApiProvider::Volcengine) + && let Ok(value) = + std::env::var("VOLCENGINE_MODEL").or_else(|_| std::env::var("VOLCENGINE_ARK_MODEL")) + && !value.trim().is_empty() + { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .volcengine + .model = Some(value); + } + if matches!(config.api_provider(), ApiProvider::Novita) + && let Ok(value) = std::env::var("NOVITA_MODEL") + && !value.trim().is_empty() + { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .novita + .model = Some(value); + } + if matches!(config.api_provider(), ApiProvider::Fireworks) + && let Ok(value) = std::env::var("FIREWORKS_MODEL") + && !value.trim().is_empty() + { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .fireworks + .model = Some(value); + } if matches!(config.api_provider(), ApiProvider::Moonshot) && let Ok(value) = std::env::var("MOONSHOT_MODEL") .or_else(|_| std::env::var("KIMI_MODEL_NAME")) .or_else(|_| std::env::var("KIMI_MODEL")) + && !value.trim().is_empty() { config .providers @@ -3529,6 +3672,16 @@ fn apply_env_overrides(config: &mut Config) { .arcee .model = Some(value); } + if matches!(config.api_provider(), ApiProvider::Huggingface) + && let Ok(value) = std::env::var("HUGGINGFACE_MODEL") + && !value.trim().is_empty() + { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .huggingface + .model = Some(value); + } if let Some(value) = codewhale_env_var("CODEWHALE_MODEL", "DEEPSEEK_MODEL") .ok() .or_else(|| { @@ -3571,6 +3724,7 @@ fn apply_env_overrides(config: &mut Config) { ApiProvider::Vllm => &mut providers.vllm, ApiProvider::Ollama => &mut providers.ollama, ApiProvider::Volcengine => &mut providers.volcengine, + ApiProvider::Huggingface => &mut providers.huggingface, }; entry.model = Some(value); } @@ -3855,6 +4009,7 @@ pub(crate) fn provider_passes_model_through(provider: ApiProvider) -> bool { | ApiProvider::XiaomiMimo | ApiProvider::Moonshot | ApiProvider::Ollama + | ApiProvider::Huggingface ) } @@ -3885,9 +4040,41 @@ fn default_base_url_for_provider(provider: ApiProvider) -> &'static str { ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL, ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL, ApiProvider::Volcengine => DEFAULT_VOLCENGINE_BASE_URL, + ApiProvider::Huggingface => DEFAULT_HUGGINGFACE_BASE_URL, } } +fn resolve_xiaomi_mimo_base_url(configured: Option, api_key: Option<&str>) -> String { + let uses_token_plan = xiaomi_mimo_api_key_uses_token_plan(api_key); + match configured { + Some(base_url) if uses_token_plan && xiaomi_mimo_base_url_is_pay_as_you_go(&base_url) => { + DEFAULT_XIAOMI_MIMO_BASE_URL.to_string() + } + Some(base_url) => base_url, + None if uses_token_plan || api_key.is_none() => DEFAULT_XIAOMI_MIMO_BASE_URL.to_string(), + None => XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL.to_string(), + } +} + +fn xiaomi_mimo_env_api_key_for_base_url() -> Option { + std::env::var("XIAOMI_MIMO_API_KEY") + .or_else(|_| std::env::var("XIAOMI_API_KEY")) + .or_else(|_| std::env::var("MIMO_API_KEY")) + .ok() + .filter(|key| !key.trim().is_empty()) +} + +fn xiaomi_mimo_api_key_uses_token_plan(api_key: Option<&str>) -> bool { + api_key.is_some_and(|key| key.trim_start().starts_with("tp-")) +} + +fn xiaomi_mimo_base_url_is_pay_as_you_go(base_url: &str) -> bool { + matches!( + normalize_base_url(base_url).to_ascii_lowercase().as_str(), + "https://api.xiaomimimo.com" | "https://api.xiaomimimo.com/v1" + ) +} + fn base_url_is_custom_for_provider(provider: ApiProvider, base_url: &str) -> bool { if (provider == ApiProvider::Siliconflow || provider == ApiProvider::SiliconflowCn) && siliconflow_base_url_is_official(base_url) @@ -3968,10 +4155,6 @@ fn model_for_provider(provider: ApiProvider, normalized: String) -> String { (ApiProvider::Novita, "deepseek-v4-pro") => DEFAULT_NOVITA_MODEL.to_string(), (ApiProvider::Novita, "deepseek-v4-flash") => DEFAULT_NOVITA_FLASH_MODEL.to_string(), (ApiProvider::Fireworks, "deepseek-v4-pro") => DEFAULT_FIREWORKS_MODEL.to_string(), - (ApiProvider::Fireworks, "deepseek-v4-flash") => { - // Flash not yet available on Fireworks; fall through to normalized name - "accounts/fireworks/models/deepseek-v4-flash".to_string() - } ( ApiProvider::Siliconflow | ApiProvider::SiliconflowCn, "deepseek-v4-pro" | "deepseek-reasoner" | "deepseek-r1", @@ -4164,6 +4347,7 @@ fn merge_providers( vllm: merge_provider_config(base.vllm, override_cfg.vllm), ollama: merge_provider_config(base.ollama, override_cfg.ollama), volcengine: merge_provider_config(base.volcengine, override_cfg.volcengine), + huggingface: merge_provider_config(base.huggingface, override_cfg.huggingface), }), } } @@ -4578,6 +4762,11 @@ pub fn active_provider_has_config_api_key(config: &Config) -> bool { { return kimi_cli_credentials_present(); } + if matches!(provider, ApiProvider::Huggingface) + && std::env::var("HF_TOKEN").is_ok_and(|k| !k.trim().is_empty()) + { + return true; + } if config .provider_config_for(provider) @@ -4629,6 +4818,10 @@ pub fn active_provider_has_env_api_key(config: &Config) -> bool { std::env::var("SILICONFLOW_API_KEY").is_ok_and(|k| !k.trim().is_empty()) } ApiProvider::Arcee => std::env::var("ARCEE_API_KEY").is_ok_and(|k| !k.trim().is_empty()), + ApiProvider::Huggingface => { + std::env::var("HUGGINGFACE_API_KEY").is_ok_and(|k| !k.trim().is_empty()) + || std::env::var("HF_TOKEN").is_ok_and(|k| !k.trim().is_empty()) + } ApiProvider::Moonshot => { std::env::var("MOONSHOT_API_KEY").is_ok_and(|k| !k.trim().is_empty()) || std::env::var("KIMI_API_KEY").is_ok_and(|k| !k.trim().is_empty()) @@ -4666,6 +4859,7 @@ pub fn has_api_key_for(config: &Config, provider: ApiProvider) -> bool { ApiProvider::Fireworks => "FIREWORKS_API_KEY", ApiProvider::Siliconflow | ApiProvider::SiliconflowCn => "SILICONFLOW_API_KEY", ApiProvider::Arcee => "ARCEE_API_KEY", + ApiProvider::Huggingface => "HUGGINGFACE_API_KEY", ApiProvider::Moonshot => "MOONSHOT_API_KEY", ApiProvider::Sglang => "SGLANG_API_KEY", ApiProvider::Vllm => "VLLM_API_KEY", @@ -4686,6 +4880,12 @@ pub fn has_api_key_for(config: &Config, provider: ApiProvider) -> bool { { return true; } + if matches!(provider, ApiProvider::Volcengine) + && (std::env::var("VOLCENGINE_ARK_API_KEY").is_ok_and(|k| !k.trim().is_empty()) + || std::env::var("ARK_API_KEY").is_ok_and(|k| !k.trim().is_empty())) + { + return true; + } if matches!(provider, ApiProvider::XiaomiMimo) && (std::env::var("XIAOMI_API_KEY").is_ok_and(|k| !k.trim().is_empty()) || std::env::var("MIMO_API_KEY").is_ok_and(|k| !k.trim().is_empty())) @@ -4705,6 +4905,11 @@ pub fn has_api_key_for(config: &Config, provider: ApiProvider) -> bool { { return kimi_cli_credentials_present(); } + if matches!(provider, ApiProvider::Huggingface) + && std::env::var("HF_TOKEN").is_ok_and(|k| !k.trim().is_empty()) + { + return true; + } // Self-hosted providers typically run without authentication. if matches!( @@ -4770,6 +4975,7 @@ pub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result ApiProvider::Fireworks => "providers.fireworks", ApiProvider::Siliconflow | ApiProvider::SiliconflowCn => "providers.siliconflow", ApiProvider::Arcee => "providers.arcee", + ApiProvider::Huggingface => "providers.huggingface", ApiProvider::Moonshot => "providers.moonshot", ApiProvider::Sglang => "providers.sglang", ApiProvider::Vllm => "providers.vllm", @@ -4812,6 +5018,7 @@ pub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result ApiProvider::Siliconflow => "siliconflow", ApiProvider::SiliconflowCn => "siliconflow", ApiProvider::Arcee => "arcee", + ApiProvider::Huggingface => "huggingface", ApiProvider::Moonshot => "moonshot", ApiProvider::Sglang => "sglang", ApiProvider::Vllm => "vllm", @@ -4907,6 +5114,7 @@ fn provider_config_key(provider: ApiProvider) -> Result<&'static str> { ApiProvider::Siliconflow => Ok("siliconflow"), ApiProvider::SiliconflowCn => Ok("siliconflow"), ApiProvider::Arcee => Ok("arcee"), + ApiProvider::Huggingface => Ok("huggingface"), ApiProvider::Moonshot => Ok("moonshot"), ApiProvider::Sglang => Ok("sglang"), ApiProvider::Vllm => Ok("vllm"), @@ -5440,6 +5648,15 @@ mod tests { wanjie_maas_model: Option, openrouter_api_key: Option, openrouter_base_url: Option, + openrouter_model: Option, + volcengine_api_key: Option, + volcengine_ark_api_key: Option, + ark_api_key: Option, + volcengine_base_url: Option, + volcengine_ark_base_url: Option, + ark_base_url: Option, + volcengine_model: Option, + volcengine_ark_model: Option, xiaomi_mimo_api_key: Option, xiaomi_api_key: Option, mimo_api_key: Option, @@ -5449,8 +5666,10 @@ mod tests { mimo_model: Option, novita_api_key: Option, novita_base_url: Option, + novita_model: Option, fireworks_api_key: Option, fireworks_base_url: Option, + fireworks_model: Option, siliconflow_api_key: Option, siliconflow_base_url: Option, siliconflow_model: Option, @@ -5476,6 +5695,10 @@ mod tests { ollama_api_key: Option, ollama_base_url: Option, ollama_model: Option, + huggingface_api_key: Option, + huggingface_token: Option, + huggingface_base_url: Option, + huggingface_model: Option, } impl EnvGuard { @@ -5522,6 +5745,15 @@ mod tests { let wanjie_maas_model_prev = env::var_os("WANJIE_MAAS_MODEL"); let openrouter_api_key_prev = env::var_os("OPENROUTER_API_KEY"); let openrouter_base_url_prev = env::var_os("OPENROUTER_BASE_URL"); + let openrouter_model_prev = env::var_os("OPENROUTER_MODEL"); + let volcengine_api_key_prev = env::var_os("VOLCENGINE_API_KEY"); + let volcengine_ark_api_key_prev = env::var_os("VOLCENGINE_ARK_API_KEY"); + let ark_api_key_prev = env::var_os("ARK_API_KEY"); + let volcengine_base_url_prev = env::var_os("VOLCENGINE_BASE_URL"); + let volcengine_ark_base_url_prev = env::var_os("VOLCENGINE_ARK_BASE_URL"); + let ark_base_url_prev = env::var_os("ARK_BASE_URL"); + let volcengine_model_prev = env::var_os("VOLCENGINE_MODEL"); + let volcengine_ark_model_prev = env::var_os("VOLCENGINE_ARK_MODEL"); let xiaomi_mimo_api_key_prev = env::var_os("XIAOMI_MIMO_API_KEY"); let xiaomi_api_key_prev = env::var_os("XIAOMI_API_KEY"); let mimo_api_key_prev = env::var_os("MIMO_API_KEY"); @@ -5531,8 +5763,10 @@ mod tests { let mimo_model_prev = env::var_os("MIMO_MODEL"); let novita_api_key_prev = env::var_os("NOVITA_API_KEY"); let novita_base_url_prev = env::var_os("NOVITA_BASE_URL"); + let novita_model_prev = env::var_os("NOVITA_MODEL"); let fireworks_api_key_prev = env::var_os("FIREWORKS_API_KEY"); let fireworks_base_url_prev = env::var_os("FIREWORKS_BASE_URL"); + let fireworks_model_prev = env::var_os("FIREWORKS_MODEL"); let siliconflow_api_key_prev = env::var_os("SILICONFLOW_API_KEY"); let siliconflow_base_url_prev = env::var_os("SILICONFLOW_BASE_URL"); let siliconflow_model_prev = env::var_os("SILICONFLOW_MODEL"); @@ -5558,6 +5792,10 @@ mod tests { let ollama_api_key_prev = env::var_os("OLLAMA_API_KEY"); let ollama_base_url_prev = env::var_os("OLLAMA_BASE_URL"); let ollama_model_prev = env::var_os("OLLAMA_MODEL"); + let huggingface_api_key_prev = env::var_os("HUGGINGFACE_API_KEY"); + let huggingface_token_prev = env::var_os("HF_TOKEN"); + let huggingface_base_url_prev = env::var_os("HUGGINGFACE_BASE_URL"); + let huggingface_model_prev = env::var_os("HUGGINGFACE_MODEL"); // Safety: test-only environment mutation guarded by a global mutex. unsafe { env::set_var("HOME", &home_str); @@ -5599,6 +5837,15 @@ mod tests { env::remove_var("WANJIE_MAAS_MODEL"); env::remove_var("OPENROUTER_API_KEY"); env::remove_var("OPENROUTER_BASE_URL"); + env::remove_var("OPENROUTER_MODEL"); + env::remove_var("VOLCENGINE_API_KEY"); + env::remove_var("VOLCENGINE_ARK_API_KEY"); + env::remove_var("ARK_API_KEY"); + env::remove_var("VOLCENGINE_BASE_URL"); + env::remove_var("VOLCENGINE_ARK_BASE_URL"); + env::remove_var("ARK_BASE_URL"); + env::remove_var("VOLCENGINE_MODEL"); + env::remove_var("VOLCENGINE_ARK_MODEL"); env::remove_var("XIAOMI_MIMO_API_KEY"); env::remove_var("XIAOMI_API_KEY"); env::remove_var("MIMO_API_KEY"); @@ -5608,8 +5855,10 @@ mod tests { env::remove_var("MIMO_MODEL"); env::remove_var("NOVITA_API_KEY"); env::remove_var("NOVITA_BASE_URL"); + env::remove_var("NOVITA_MODEL"); env::remove_var("FIREWORKS_API_KEY"); env::remove_var("FIREWORKS_BASE_URL"); + env::remove_var("FIREWORKS_MODEL"); env::remove_var("SILICONFLOW_API_KEY"); env::remove_var("SILICONFLOW_BASE_URL"); env::remove_var("SILICONFLOW_MODEL"); @@ -5635,6 +5884,10 @@ mod tests { env::remove_var("OLLAMA_API_KEY"); env::remove_var("OLLAMA_BASE_URL"); env::remove_var("OLLAMA_MODEL"); + env::remove_var("HUGGINGFACE_API_KEY"); + env::remove_var("HF_TOKEN"); + env::remove_var("HUGGINGFACE_BASE_URL"); + env::remove_var("HUGGINGFACE_MODEL"); } Self { home: home_prev, @@ -5676,6 +5929,15 @@ mod tests { wanjie_maas_model: wanjie_maas_model_prev, openrouter_api_key: openrouter_api_key_prev, openrouter_base_url: openrouter_base_url_prev, + openrouter_model: openrouter_model_prev, + volcengine_api_key: volcengine_api_key_prev, + volcengine_ark_api_key: volcengine_ark_api_key_prev, + ark_api_key: ark_api_key_prev, + volcengine_base_url: volcengine_base_url_prev, + volcengine_ark_base_url: volcengine_ark_base_url_prev, + ark_base_url: ark_base_url_prev, + volcengine_model: volcengine_model_prev, + volcengine_ark_model: volcengine_ark_model_prev, xiaomi_mimo_api_key: xiaomi_mimo_api_key_prev, xiaomi_api_key: xiaomi_api_key_prev, mimo_api_key: mimo_api_key_prev, @@ -5685,8 +5947,10 @@ mod tests { mimo_model: mimo_model_prev, novita_api_key: novita_api_key_prev, novita_base_url: novita_base_url_prev, + novita_model: novita_model_prev, fireworks_api_key: fireworks_api_key_prev, fireworks_base_url: fireworks_base_url_prev, + fireworks_model: fireworks_model_prev, siliconflow_api_key: siliconflow_api_key_prev, siliconflow_base_url: siliconflow_base_url_prev, siliconflow_model: siliconflow_model_prev, @@ -5712,6 +5976,10 @@ mod tests { ollama_api_key: ollama_api_key_prev, ollama_base_url: ollama_base_url_prev, ollama_model: ollama_model_prev, + huggingface_api_key: huggingface_api_key_prev, + huggingface_token: huggingface_token_prev, + huggingface_base_url: huggingface_base_url_prev, + huggingface_model: huggingface_model_prev, } } } @@ -5768,6 +6036,18 @@ mod tests { Self::restore_var("WANJIE_MAAS_MODEL", self.wanjie_maas_model.take()); Self::restore_var("OPENROUTER_API_KEY", self.openrouter_api_key.take()); Self::restore_var("OPENROUTER_BASE_URL", self.openrouter_base_url.take()); + Self::restore_var("OPENROUTER_MODEL", self.openrouter_model.take()); + Self::restore_var("VOLCENGINE_API_KEY", self.volcengine_api_key.take()); + Self::restore_var("VOLCENGINE_ARK_API_KEY", self.volcengine_ark_api_key.take()); + Self::restore_var("ARK_API_KEY", self.ark_api_key.take()); + Self::restore_var("VOLCENGINE_BASE_URL", self.volcengine_base_url.take()); + Self::restore_var( + "VOLCENGINE_ARK_BASE_URL", + self.volcengine_ark_base_url.take(), + ); + Self::restore_var("ARK_BASE_URL", self.ark_base_url.take()); + Self::restore_var("VOLCENGINE_MODEL", self.volcengine_model.take()); + Self::restore_var("VOLCENGINE_ARK_MODEL", self.volcengine_ark_model.take()); Self::restore_var("XIAOMI_MIMO_API_KEY", self.xiaomi_mimo_api_key.take()); Self::restore_var("XIAOMI_API_KEY", self.xiaomi_api_key.take()); Self::restore_var("MIMO_API_KEY", self.mimo_api_key.take()); @@ -5777,8 +6057,10 @@ mod tests { Self::restore_var("MIMO_MODEL", self.mimo_model.take()); Self::restore_var("NOVITA_API_KEY", self.novita_api_key.take()); Self::restore_var("NOVITA_BASE_URL", self.novita_base_url.take()); + Self::restore_var("NOVITA_MODEL", self.novita_model.take()); Self::restore_var("FIREWORKS_API_KEY", self.fireworks_api_key.take()); Self::restore_var("FIREWORKS_BASE_URL", self.fireworks_base_url.take()); + Self::restore_var("FIREWORKS_MODEL", self.fireworks_model.take()); Self::restore_var("SILICONFLOW_API_KEY", self.siliconflow_api_key.take()); Self::restore_var("SILICONFLOW_BASE_URL", self.siliconflow_base_url.take()); Self::restore_var("SILICONFLOW_MODEL", self.siliconflow_model.take()); @@ -5804,6 +6086,10 @@ mod tests { Self::restore_var("OLLAMA_API_KEY", self.ollama_api_key.take()); Self::restore_var("OLLAMA_BASE_URL", self.ollama_base_url.take()); Self::restore_var("OLLAMA_MODEL", self.ollama_model.take()); + Self::restore_var("HUGGINGFACE_API_KEY", self.huggingface_api_key.take()); + Self::restore_var("HF_TOKEN", self.huggingface_token.take()); + Self::restore_var("HUGGINGFACE_BASE_URL", self.huggingface_base_url.take()); + Self::restore_var("HUGGINGFACE_MODEL", self.huggingface_model.take()); } } } @@ -6197,6 +6483,9 @@ mod tests { "ollama" => { providers.ollama.api_key = Some(api_key.to_string()); } + "huggingface" => { + providers.huggingface.api_key = Some(api_key.to_string()); + } _ => panic!("unexpected provider {provider}"), } @@ -7547,6 +7836,26 @@ model = "mimo-v2.5-pro" Ok(()) } + #[test] + fn xiaomi_token_plan_key_rewrites_saved_pay_as_you_go_base_url() -> Result<()> { + let config: Config = toml::from_str( + r#" +provider = "xiaomi-mimo" + +[providers.xiaomi_mimo] +api_key = "tp-test-token-plan-key" +base_url = "https://api.xiaomimimo.com/v1" +model = "mimo-v2.5-pro" +"#, + )?; + + config.validate()?; + assert_eq!(config.api_provider(), ApiProvider::XiaomiMimo); + assert_eq!(config.deepseek_base_url(), DEFAULT_XIAOMI_MIMO_BASE_URL); + assert_eq!(config.default_model(), DEFAULT_XIAOMI_MIMO_MODEL); + Ok(()) + } + #[test] fn xiaomi_mimo_env_overrides_provider_base_url_model_and_key() -> Result<()> { let _lock = lock_test_env(); @@ -7951,6 +8260,77 @@ model = "glm-5" Ok(()) } + #[test] + fn fireworks_flash_alias_is_not_mapped_to_undocumented_model() -> Result<()> { + let config = Config { + provider: Some("fireworks".to_string()), + default_text_model: Some("deepseek-v4-flash".to_string()), + ..Default::default() + }; + + config.validate()?; + assert_eq!(config.api_provider(), ApiProvider::Fireworks); + assert_eq!(config.default_model(), "deepseek-v4-flash"); + Ok(()) + } + + #[test] + fn volcengine_provider_requires_api_key() -> Result<()> { + let _lock = lock_test_env(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "codewhale-tui-volcengine-auth-test-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root)?; + let _guard = EnvGuard::new(&temp_root); + + let config = Config { + provider: Some("volcengine".to_string()), + ..Default::default() + }; + + config.validate()?; + let err = config.deepseek_api_key().expect_err("missing key"); + assert!(err.to_string().contains("Volcengine Ark API key not found")); + Ok(()) + } + + #[test] + fn volcengine_env_overrides_base_url_model_and_key() -> Result<()> { + let _lock = lock_test_env(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "codewhale-tui-volcengine-env-test-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root)?; + let _guard = EnvGuard::new(&temp_root); + + // Safety: test-only environment mutation guarded by a global mutex. + unsafe { + env::set_var("DEEPSEEK_PROVIDER", "volcengine"); + env::set_var("ARK_API_KEY", "volc-env-key"); + env::set_var("VOLCENGINE_ARK_BASE_URL", "https://volc.example/v1"); + env::set_var("VOLCENGINE_ARK_MODEL", "DeepSeek-V4-Flash"); + } + + let config = Config::load(None, None)?; + assert_eq!(config.api_provider(), ApiProvider::Volcengine); + assert_eq!(config.deepseek_api_key()?, "volc-env-key"); + assert_eq!(config.deepseek_base_url(), "https://volc.example/v1"); + assert_eq!(config.default_model(), "DeepSeek-V4-Flash"); + Ok(()) + } + #[test] fn siliconflow_provider_uses_canonical_defaults() -> Result<()> { let _lock = lock_test_env(); @@ -8183,11 +8563,13 @@ model = "qwen2.5-coder:7b" unsafe { env::set_var("DEEPSEEK_PROVIDER", "openrouter"); env::set_var("OPENROUTER_API_KEY", "or-env-key"); + env::set_var("OPENROUTER_MODEL", "deepseek-v4-flash"); } let config = Config::load(None, None)?; assert_eq!(config.api_provider(), ApiProvider::Openrouter); assert_eq!(config.deepseek_api_key()?, "or-env-key"); + assert_eq!(config.default_model(), DEFAULT_OPENROUTER_FLASH_MODEL); Ok(()) } @@ -8210,11 +8592,48 @@ model = "qwen2.5-coder:7b" unsafe { env::set_var("DEEPSEEK_PROVIDER", "novita"); env::set_var("NOVITA_API_KEY", "novita-env-key"); + env::set_var("NOVITA_MODEL", "deepseek-v4-flash"); } let config = Config::load(None, None)?; assert_eq!(config.api_provider(), ApiProvider::Novita); assert_eq!(config.deepseek_api_key()?, "novita-env-key"); + assert_eq!(config.default_model(), DEFAULT_NOVITA_FLASH_MODEL); + Ok(()) + } + + #[test] + fn fireworks_env_overrides_key_and_model() -> Result<()> { + let _lock = lock_test_env(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "codewhale-tui-fireworks-env-key-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root)?; + let _guard = EnvGuard::new(&temp_root); + + // Safety: test-only environment mutation guarded by a global mutex. + unsafe { + env::set_var("DEEPSEEK_PROVIDER", "fireworks"); + env::set_var("FIREWORKS_API_KEY", "fw-env-key"); + env::set_var( + "FIREWORKS_MODEL", + "accounts/fireworks/models/account-specific-model", + ); + } + + let config = Config::load(None, None)?; + assert_eq!(config.api_provider(), ApiProvider::Fireworks); + assert_eq!(config.deepseek_api_key()?, "fw-env-key"); + assert_eq!( + config.default_model(), + "accounts/fireworks/models/account-specific-model" + ); Ok(()) } @@ -8850,6 +9269,7 @@ api_key = "moonshot-platform-key" let mut config = Config::default(); assert!(!has_api_key_for(&config, ApiProvider::Openai)); assert!(!has_api_key_for(&config, ApiProvider::WanjieArk)); + assert!(!has_api_key_for(&config, ApiProvider::Volcengine)); assert!(!has_api_key_for(&config, ApiProvider::Openrouter)); assert!(!has_api_key_for(&config, ApiProvider::XiaomiMimo)); assert!(!has_api_key_for(&config, ApiProvider::Siliconflow)); @@ -8867,11 +9287,13 @@ api_key = "moonshot-platform-key" env::set_var("OPENROUTER_API_KEY", "or-env"); env::set_var("OPENAI_API_KEY", "openai-env"); env::set_var("WANJIE_API_KEY", "wanjie-env"); + env::set_var("ARK_API_KEY", "volc-env"); env::set_var("MIMO_API_KEY", "mimo-env"); env::set_var("SILICONFLOW_API_KEY", "sf-env"); } assert!(has_api_key_for(&config, ApiProvider::Openai)); assert!(has_api_key_for(&config, ApiProvider::WanjieArk)); + assert!(has_api_key_for(&config, ApiProvider::Volcengine)); assert!(has_api_key_for(&config, ApiProvider::Openrouter)); assert!(has_api_key_for(&config, ApiProvider::XiaomiMimo)); assert!(has_api_key_for(&config, ApiProvider::Siliconflow)); @@ -8882,6 +9304,7 @@ api_key = "moonshot-platform-key" env::remove_var("OPENROUTER_API_KEY"); env::remove_var("OPENAI_API_KEY"); env::remove_var("WANJIE_API_KEY"); + env::remove_var("ARK_API_KEY"); env::remove_var("MIMO_API_KEY"); env::remove_var("SILICONFLOW_API_KEY"); } @@ -9536,4 +9959,88 @@ model = "deepseek-ai/deepseek-v4-pro" let tui: TuiConfig = toml::from_str(toml_str).expect("missing status_items should parse"); assert_eq!(tui.status_items, None); } + + #[test] + fn huggingface_provider_uses_direct_defaults() -> Result<()> { + let _lock = lock_test_env(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "codewhale-tui-huggingface-defaults-test-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root)?; + let _guard = EnvGuard::new(&temp_root); + + unsafe { + env::set_var("CODEWHALE_PROVIDER", "huggingface"); + env::set_var("HUGGINGFACE_API_KEY", "hf-env-key"); + } + + let config = Config::load(None, None)?; + assert_eq!(config.api_provider(), ApiProvider::Huggingface); + assert_eq!(config.deepseek_api_key()?, "hf-env-key"); + assert_eq!(config.deepseek_base_url(), DEFAULT_HUGGINGFACE_BASE_URL); + assert_eq!(config.default_model(), DEFAULT_HUGGINGFACE_MODEL); + Ok(()) + } + + #[test] + fn huggingface_hf_token_env_api_key_resolves() -> Result<()> { + let _lock = lock_test_env(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "codewhale-tui-huggingface-hf-token-test-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root)?; + let _guard = EnvGuard::new(&temp_root); + + unsafe { + env::set_var("CODEWHALE_PROVIDER", "huggingface"); + env::set_var("HF_TOKEN", "hf-token-value"); + } + + let config = Config::load(None, None)?; + assert_eq!(config.api_provider(), ApiProvider::Huggingface); + assert_eq!(config.deepseek_api_key()?, "hf-token-value"); + Ok(()) + } + + #[test] + fn huggingface_env_overrides_key_base_url_and_model() -> Result<()> { + let _lock = lock_test_env(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "codewhale-tui-huggingface-env-test-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root)?; + let _guard = EnvGuard::new(&temp_root); + + unsafe { + env::set_var("CODEWHALE_PROVIDER", "huggingface"); + env::set_var("HUGGINGFACE_API_KEY", "hf-env-key"); + env::set_var("HUGGINGFACE_BASE_URL", "https://custom-hf.example/v1"); + env::set_var("HUGGINGFACE_MODEL", "meta-llama/Llama-3-70B"); + } + + let config = Config::load(None, None)?; + assert_eq!(config.api_provider(), ApiProvider::Huggingface); + assert_eq!(config.deepseek_api_key()?, "hf-env-key"); + assert_eq!(config.deepseek_base_url(), "https://custom-hf.example/v1"); + assert_eq!(config.default_model(), "meta-llama/Llama-3-70B"); + Ok(()) + } } diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 28ba4799..5dbe79eb 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -553,6 +553,7 @@ impl Engine { ApiProvider::Sglang => "SGLANG_API_KEY", ApiProvider::Vllm => "VLLM_API_KEY", ApiProvider::Ollama => "OLLAMA_API_KEY", + ApiProvider::Huggingface => "HUGGINGFACE_API_KEY/HF_TOKEN", }; Some(format!( diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index 820f0a13..bed3276a 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -3246,9 +3246,17 @@ fn missing_shell_tool_error_message_names_allow_shell_gate() { assert!(message.contains("not available in the current tool catalog")); assert!(message.contains("allow_shell"), "{tool_name}: {message}"); assert!( - message.contains("trusted workspaces"), + message.contains("/config allow_shell true"), "{tool_name}: {message}" ); + assert!(message.contains("--save"), "{tool_name}: {message}"); + assert!(message.contains("Agent mode"), "{tool_name}: {message}"); + assert!( + message.contains("approval gating"), + "{tool_name}: {message}" + ); + assert!(!message.contains("YOLO"), "{tool_name}: {message}"); + assert!(!message.contains("auto-approve"), "{tool_name}: {message}"); assert!( message.contains(TOOL_SEARCH_BM25_NAME), "{tool_name}: {message}" @@ -3265,7 +3273,11 @@ fn missing_shell_tool_error_message_keeps_allow_shell_hint_with_suggestions() { assert!(message.contains("Did you mean:")); assert!(message.contains("exec")); assert!(message.contains("allow_shell")); - assert!(message.contains("trusted workspaces")); + assert!(message.contains("/config allow_shell true")); + assert!(message.contains("--save")); + assert!(message.contains("Agent mode")); + assert!(!message.contains("YOLO")); + assert!(!message.contains("auto-approve")); assert!(message.contains(TOOL_SEARCH_BM25_NAME)); } diff --git a/crates/tui/src/core/engine/tool_catalog.rs b/crates/tui/src/core/engine/tool_catalog.rs index 69a5997a..9ddc0862 100644 --- a/crates/tui/src/core/engine/tool_catalog.rs +++ b/crates/tui/src/core/engine/tool_catalog.rs @@ -544,8 +544,9 @@ pub(super) fn missing_tool_error_message(tool_name: &str, catalog: &[Tool]) -> S } fn shell_tool_allow_shell_hint() -> &'static str { - "Shell tools are gated by `allow_shell`; enable `allow_shell = true` for trusted workspaces, \ - or switch to an auto-approve mode that permits shell access" + "Shell tools require top-level `allow_shell = true`. \ + In Agent mode, run `/config allow_shell true` for this session or add `--save` \ + for future sessions; the next turn will expose shell with approval gating" } fn is_shell_tool_name(tool_name: &str) -> bool { diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index db9499b3..e85990de 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -1145,9 +1145,7 @@ fn english(id: MessageId) -> &'static str { MessageId::CmdNetworkDescription => "Manage network allow and deny rules", MessageId::CmdNoteDescription => "Add, list, edit, or remove workspace notes", MessageId::CmdThemeDescription => "Switch theme or open the theme picker", - MessageId::CmdProviderDescription => { - "Switch or view the active LLM backend (deepseek | nvidia-nim | ollama)" - } + MessageId::CmdProviderDescription => "Switch the active provider and/or model", MessageId::CmdQueueDescription => "View or edit queued messages", MessageId::CmdQueueUsage => "Usage: /queue [list|edit |drop |clear]", MessageId::CmdQueueDraftHeader => "Editing queued message:", @@ -3983,12 +3981,12 @@ mod tests { } #[test] - fn provider_description_names_deepseek_backend() { + fn provider_description_is_present_for_all_locales() { for locale in Locale::shipped() { let description = tr(*locale, MessageId::CmdProviderDescription); assert!( - description.contains("deepseek"), - "{} provider description should mention deepseek: {description}", + !description.is_empty(), + "{} provider description should not be empty", locale.tag() ); assert!( diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index df867d98..9f29969b 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -1116,7 +1116,7 @@ async fn main() -> Result<()> { }; // Default: Interactive TUI - // --yolo starts in YOLO mode (shell + trust + auto-approve) + // --yolo starts in YOLO mode (auto-approve; shell if allow_shell=true) run_interactive(&cli, &config, resume_session_id, None).await } @@ -2028,6 +2028,10 @@ fn run_setup_status(config: &Config, workspace: &Path) -> Result<()> { "VOLCENGINE_API_KEY", "codewhale auth set --provider volcengine", ), + crate::config::ApiProvider::Huggingface => ( + "HUGGINGFACE_API_KEY/HF_TOKEN", + "codewhale auth set --provider huggingface", + ), crate::config::ApiProvider::Deepseek | crate::config::ApiProvider::DeepseekCN => { ("DEEPSEEK_API_KEY", "codewhale auth set --provider deepseek") } @@ -2052,6 +2056,7 @@ fn run_setup_status(config: &Config, workspace: &Path) -> Result<()> { crate::config::ApiProvider::Sglang => "sglang", crate::config::ApiProvider::Vllm => "vllm", crate::config::ApiProvider::Ollama => "ollama", + crate::config::ApiProvider::Huggingface => "huggingface", crate::config::ApiProvider::Deepseek | crate::config::ApiProvider::DeepseekCN => "deepseek", } diff --git a/crates/tui/src/project_context.rs b/crates/tui/src/project_context.rs index b474582e..affd746f 100644 --- a/crates/tui/src/project_context.rs +++ b/crates/tui/src/project_context.rs @@ -785,7 +785,8 @@ fn load_global_agents_context(workspace: &Path, home_dir: Option<&Path>) -> Opti if path.exists() && path.is_file() { match load_context_file(&path) { Ok(content) => { - if path.file_name().and_then(|n| n.to_str()) == Some(DEPRECATED_WHALE_FILENAME) { + if path.file_name().and_then(|n| n.to_str()) == Some(DEPRECATED_WHALE_FILENAME) + { tracing::warn!("{WHALE_DEPRECATION_WARNING}"); warnings.push(WHALE_DEPRECATION_WARNING.to_string()); } @@ -1182,7 +1183,9 @@ mod tests { assert!(!instructions.contains("WHALE legacy"), "{instructions}"); // No deprecation warning since AGENTS.md won. assert!( - !ctx.warnings.iter().any(|w| w.contains("WHALE.md is deprecated")), + !ctx.warnings + .iter() + .any(|w| w.contains("WHALE.md is deprecated")), "{:?}", ctx.warnings ); @@ -1199,7 +1202,9 @@ mod tests { "legacy WHALE.md must still be read" ); assert!( - ctx.warnings.iter().any(|w| w.contains("WHALE.md is deprecated")), + ctx.warnings + .iter() + .any(|w| w.contains("WHALE.md is deprecated")), "expected deprecation warning, got {:?}", ctx.warnings ); @@ -1255,7 +1260,10 @@ mod tests { .expect("write bad constitution"); let ctx = load_project_context_with_parents(tmp.path()); - assert!(ctx.constitution_block.is_none(), "no block for invalid JSON"); + assert!( + ctx.constitution_block.is_none(), + "no block for invalid JSON" + ); assert!( ctx.warnings.iter().any(|w| w.contains("Failed to parse")), "expected parse warning, got {:?}", diff --git a/crates/tui/src/tools/subagent/mod.rs b/crates/tui/src/tools/subagent/mod.rs index b9b48e75..9713a156 100644 --- a/crates/tui/src/tools/subagent/mod.rs +++ b/crates/tui/src/tools/subagent/mod.rs @@ -1698,9 +1698,7 @@ impl SubAgentManager { if let Some(role) = role { let normalized = normalize_role_alias(&role) .ok_or_else(|| { - anyhow!( - "Invalid role alias '{role}'. Use: {VALID_ROLE_ALIASES}" - ) + anyhow!("Invalid role alias '{role}'. Use: {VALID_ROLE_ALIASES}") })? .to_string(); if agent.assignment.role.as_deref() != Some(normalized.as_str()) { @@ -3719,7 +3717,10 @@ async fn run_subagent_task(task: SubAgentTask) { match &result { Ok(res) => manager.update_from_result(&agent_id, res.clone()), Err(err) => { - manager.update_failed(&agent_id, annotate_child_model_error(&err.to_string(), &model_id)); + manager.update_failed( + &agent_id, + annotate_child_model_error(&err.to_string(), &model_id), + ); } } diff --git a/crates/tui/src/tools/subagent/tests.rs b/crates/tui/src/tools/subagent/tests.rs index 72d50215..41d4bf10 100644 --- a/crates/tui/src/tools/subagent/tests.rs +++ b/crates/tui/src/tools/subagent/tests.rs @@ -1114,8 +1114,14 @@ async fn spawn_duplicate_session_name_error_names_conflicting_agent() { .expect_err("duplicate session name must error") }; let msg = err.to_string(); - assert!(msg.contains(&existing_id), "names the conflicting agent_id: {msg}"); - assert!(msg.contains("running"), "includes the conflicting status: {msg}"); + assert!( + msg.contains(&existing_id), + "names the conflicting agent_id: {msg}" + ); + assert!( + msg.contains("running"), + "includes the conflicting status: {msg}" + ); } #[tokio::test] @@ -1624,8 +1630,14 @@ fn annotate_child_model_error_adds_actionable_hint() { // recovery path, while unrelated errors pass through unchanged. let auth = annotate_child_model_error("403 Forbidden", "kimi-k2"); assert!(auth.contains("kimi-k2"), "names the model: {auth}"); - assert!(auth.contains("agent_open"), "names the recovery path: {auth}"); - assert!(auth.contains("403 Forbidden"), "preserves the original: {auth}"); + assert!( + auth.contains("agent_open"), + "names the recovery path: {auth}" + ); + assert!( + auth.contains("403 Forbidden"), + "preserves the original: {auth}" + ); let unrelated = annotate_child_model_error("connection reset by peer", "kimi-k2"); assert_eq!(unrelated, "connection reset by peer"); diff --git a/crates/tui/src/tools/web_search.rs b/crates/tui/src/tools/web_search.rs index 8516cabd..6cbafb9b 100644 --- a/crates/tui/src/tools/web_search.rs +++ b/crates/tui/src/tools/web_search.rs @@ -817,7 +817,7 @@ impl WebSearchTool { if !status.is_success() { let msg = match status.as_u16() { - 401 | 403 => "Volcengine API key rejected — check VOLCENGINE_API_KEY or `[search] api_key` in config.toml".to_string(), + 401 | 403 => "Volcengine API key rejected — check `[search] api_key` in config.toml or VOLCENGINE_API_KEY / VOLCENGINE_ARK_API_KEY / ARK_API_KEY".to_string(), 429 => "Volcengine API rate-limited — wait and retry, or check your quota".to_string(), _ => { let truncated = truncate_error_body(&body); @@ -1909,6 +1909,7 @@ mod tests { use crate::config::SearchProvider; use crate::tools::spec::{ToolContext, ToolSpec}; + let _guard = crate::test_support::lock_test_env(); let prev_volc = std::env::var_os("VOLCENGINE_API_KEY"); let prev_volc_ark = std::env::var_os("VOLCENGINE_ARK_API_KEY"); let prev_ark = std::env::var_os("ARK_API_KEY"); diff --git a/crates/tui/src/tui/provider_picker.rs b/crates/tui/src/tui/provider_picker.rs index 2f111319..495e8659 100644 --- a/crates/tui/src/tui/provider_picker.rs +++ b/crates/tui/src/tui/provider_picker.rs @@ -112,6 +112,7 @@ impl ProviderPickerView { ApiProvider::Sglang => "SGLANG_API_KEY", ApiProvider::Vllm => "VLLM_API_KEY", ApiProvider::Ollama => "OLLAMA_API_KEY", + ApiProvider::Huggingface => "HUGGINGFACE_API_KEY / HF_TOKEN", } } @@ -120,6 +121,10 @@ impl ProviderPickerView { ApiProvider::Moonshot if kimi_cli_credentials_present() => { "(Kimi CLI OAuth ready)".to_string() } + ApiProvider::XiaomiMimo if has_key => "(configured; token-plan endpoint)".to_string(), + ApiProvider::XiaomiMimo => { + "(needs API key; token-plan endpoint by default)".to_string() + } ApiProvider::Ollama => "self-hosted; defaults to http://localhost:11434".to_string(), ApiProvider::Sglang | ApiProvider::Vllm if has_key => { "(configured; optional key)".to_string() @@ -487,7 +492,8 @@ mod tests { "Moonshot/Kimi", "SGLang", "vLLM", - "Ollama" + "Ollama", + "Hugging Face" ] ); } @@ -522,7 +528,7 @@ mod tests { let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config); picker.handle_key(key(KeyCode::Up)); - assert_eq!(picker.selected_provider(), ApiProvider::Ollama); + assert_eq!(picker.selected_provider(), ApiProvider::Huggingface); picker.handle_key(key(KeyCode::Down)); assert_eq!(picker.selected_provider(), ApiProvider::Deepseek); diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index f040835b..edebdf53 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -5323,6 +5323,8 @@ async fn switch_provider( } let new_model = config.default_model(); + let new_base_url = config.deepseek_base_url(); + let new_endpoint = display_base_url_host(&new_base_url); let cache_scope_changed = previous_provider != target || previous_model != new_model; app.api_provider = target; app.model_ids_passthrough = config.model_ids_pass_through(); @@ -5363,14 +5365,19 @@ async fn switch_provider( app.add_message(HistoryCell::System { content: format!( - "Provider switched: {} → {}\nModel: {} → {}", + "Provider switched: {} → {}\nModel: {} → {}\nEndpoint: {}", previous_provider.as_str(), target.as_str(), previous_model, - new_model + new_model, + new_endpoint ), }); - app.status_message = Some(format!("Provider: {}", target.as_str())); + app.status_message = Some(format!( + "Provider: {} via {}", + target.as_str(), + new_endpoint + )); // Persist the provider choice so it survives restarts. if let Ok(mut settings) = crate::settings::Settings::load() { @@ -5406,6 +5413,18 @@ fn root_base_url_belongs_to_non_deepseek_provider(base_url: &str) -> bool { .any(|needle| lower.contains(needle)) } +fn display_base_url_host(base_url: &str) -> String { + let without_scheme = base_url + .split_once("://") + .map_or(base_url, |(_, rest)| rest); + without_scheme + .split('/') + .next() + .filter(|host| !host.is_empty()) + .unwrap_or(base_url) + .to_string() +} + fn sync_config_provider_from_app(config: &mut Config, app: &App) { config.provider = Some(app.api_provider.as_str().to_string()); } @@ -5519,29 +5538,21 @@ async fn apply_command_result( let _ = engine_handle.send(Op::ListSubAgents).await; } AppAction::FetchModels => { - if crate::config::provider_passes_model_through(config.api_provider()) { - app.add_message(HistoryCell::System { - content: format!( - "/models is not supported by the {} provider.", - config.api_provider().display_name() - ), - }); - } else { - app.status_message = Some("Fetching models...".to_string()); - match fetch_available_models(config).await { - Ok(models) => { - app.add_message(HistoryCell::System { - content: format_helpers::available_models_message( - &app.model, &models, - ), - }); - app.status_message = Some(format!("Found {} model(s)", models.len())); - } - Err(error) => { - app.add_message(HistoryCell::System { - content: format!("Failed to fetch models: {error}"), - }); - } + app.status_message = Some("Fetching models...".to_string()); + match fetch_available_models(config).await { + Ok(models) => { + app.add_message(HistoryCell::System { + content: format_helpers::available_models_message(&app.model, &models), + }); + app.status_message = Some(format!("Found {} model(s)", models.len())); + } + Err(error) => { + app.add_message(HistoryCell::System { + content: format!( + "Failed to fetch models from {}: {error}", + config.api_provider().display_name() + ), + }); } } } @@ -6613,6 +6624,7 @@ fn render(f: &mut Frame, app: &mut App) { crate::config::ApiProvider::Sglang => Some("SGLang"), crate::config::ApiProvider::Vllm => Some("vLLM"), crate::config::ApiProvider::Ollama => Some("Ollama"), + crate::config::ApiProvider::Huggingface => Some("HF"), }; let status_indicator_started_at = if app.low_motion { None @@ -7626,6 +7638,7 @@ async fn apply_provider_picker_api_key( ApiProvider::Sglang => &mut providers.sglang, ApiProvider::Vllm => &mut providers.vllm, ApiProvider::Ollama => &mut providers.ollama, + ApiProvider::Huggingface => &mut providers.huggingface, }; entry.api_key = Some(api_key); } @@ -7682,6 +7695,7 @@ fn set_provider_auth_mode_in_memory(config: &mut Config, provider: ApiProvider, ApiProvider::Sglang => &mut providers.sglang, ApiProvider::Vllm => &mut providers.vllm, ApiProvider::Ollama => &mut providers.ollama, + ApiProvider::Huggingface => &mut providers.huggingface, }; entry.auth_mode = Some(auth_mode); } diff --git a/crates/tui/src/tui/views/mod.rs b/crates/tui/src/tui/views/mod.rs index cd11a102..f11d846e 100644 --- a/crates/tui/src/tui/views/mod.rs +++ b/crates/tui/src/tui/views/mod.rs @@ -3,7 +3,7 @@ use ratatui::{buffer::Buffer, layout::Rect}; use std::cell::{Cell, RefCell}; use std::fmt; -use crate::config::Config; +use crate::config::{ApiProvider, Config}; use crate::localization::{Locale, MessageId, tr}; use crate::palette; use crate::settings::Settings; @@ -529,6 +529,7 @@ struct ConfigRow { #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ConfigSection { + Provider, Model, Permissions, Display, @@ -541,6 +542,7 @@ enum ConfigSection { impl ConfigSection { fn label(self) -> &'static str { match self { + ConfigSection::Provider => "Provider", ConfigSection::Model => "Model", ConfigSection::Permissions => "Permissions", ConfigSection::Display => "Display", @@ -592,6 +594,20 @@ impl ConfigView { pub fn new_for_app(app: &App) -> Self { let settings = Settings::load().unwrap_or_else(|_| Settings::default()); let rows = vec![ + ConfigRow { + section: ConfigSection::Provider, + key: "provider".to_string(), + value: app.api_provider.as_str().to_string(), + editable: true, + scope: ConfigScope::Session, + }, + ConfigRow { + section: ConfigSection::Provider, + key: config_base_url_row_key(app.api_provider).to_string(), + value: config_base_url_row_value(app), + editable: true, + scope: ConfigScope::Saved, + }, ConfigRow { section: ConfigSection::Model, key: "model".to_string(), @@ -621,15 +637,6 @@ impl ConfigView { editable: true, scope: ConfigScope::Saved, }, - ConfigRow { - section: ConfigSection::Model, - key: "base_url".to_string(), - value: Config::load(app.config_path.clone(), app.config_profile.as_deref()) - .map(|config| config.deepseek_base_url()) - .unwrap_or_else(|_| "(unavailable)".to_string()), - editable: true, - scope: ConfigScope::Saved, - }, ConfigRow { section: ConfigSection::Permissions, key: "approval_mode".to_string(), @@ -644,6 +651,13 @@ impl ConfigView { editable: true, scope: ConfigScope::Saved, }, + ConfigRow { + section: ConfigSection::Permissions, + key: "allow_shell".to_string(), + value: app.allow_shell.to_string(), + editable: true, + scope: ConfigScope::Saved, + }, ConfigRow { section: ConfigSection::Display, key: "theme".to_string(), @@ -1191,6 +1205,23 @@ impl ConfigView { } } +fn config_base_url_row_key(provider: ApiProvider) -> &'static str { + if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) { + "base_url" + } else { + "provider_url" + } +} + +fn config_base_url_row_value(app: &App) -> String { + Config::load(app.config_path.clone(), app.config_profile.as_deref()) + .map(|mut config| { + config.provider = Some(app.api_provider.as_str().to_string()); + config.deepseek_base_url() + }) + .unwrap_or_else(|_| "(unavailable)".to_string()) +} + fn cost_currency_config_value(app: &App) -> String { match app.cost_currency { crate::pricing::CostCurrency::Usd => "usd", @@ -1202,7 +1233,9 @@ fn cost_currency_config_value(app: &App) -> String { fn config_hint_for_key(key: &str) -> &'static str { match key { "model" => "deepseek-v4-pro | deepseek-v4-flash | deepseek-*", + "provider" => "deepseek | openrouter | xiaomi-mimo | fireworks | siliconflow | ...", "approval_mode" => "auto | suggest | never", + "allow_shell" => "true enables shell in Agent mode with approvals on the next turn", "auto_compact" | "calm_mode" | "low_motion" @@ -1214,7 +1247,10 @@ fn config_hint_for_key(key: &str) -> &'static str { "theme" => "system | dark | light | grayscale", "locale" => "auto | en | ja | zh-Hans | pt-BR", "background_color" => "#RRGGBB | default", - "base_url" => "save user config; e.g. https://api.deepseek.com/beta or https://gateway/v1", + "base_url" => "global DeepSeek/root fallback; e.g. https://api.deepseek.com/beta", + "provider_url" => { + "current provider endpoint; Xiaomi: token-plan | pay-as-you-go | custom URL" + } "cost_currency" => "usd | cny", "default_mode" => "agent | plan | yolo", "sidebar_width" => "10..=50", @@ -2184,7 +2220,9 @@ mod tests { resume_session_id: None, initial_input: None, }; - App::new(options, &Config::default()) + let mut app = App::new(options, &Config::default()); + app.api_provider = crate::config::ApiProvider::Deepseek; + app } fn cost_currency_row_for_settings( @@ -2320,6 +2358,7 @@ mod tests { assert_eq!( visible_section_labels(&view), vec![ + ConfigSection::Provider.label(), ConfigSection::Model.label(), ConfigSection::Permissions.label(), ConfigSection::Display.label(), @@ -2340,10 +2379,12 @@ mod tests { .iter() .map(|row| row.key.as_str()) .collect::>(); + assert!(keys.contains(&"provider")); assert!(keys.contains(&"model")); assert!(keys.contains(&"reasoning_effort")); assert!(keys.contains(&"base_url")); assert!(keys.contains(&"approval_mode")); + assert!(keys.contains(&"allow_shell")); assert!(keys.contains(&"theme")); assert!(keys.contains(&"locale")); assert!(keys.contains(&"background_color")); @@ -2387,6 +2428,40 @@ mod tests { assert_eq!(row.value, "https://ui-config-view.local/v1"); } + #[test] + fn config_view_uses_provider_url_for_non_deepseek_provider() { + let temp_root = std::env::temp_dir().join(format!( + "codewhale-provider-url-view-test-{}", + std::process::id() + )); + fs::create_dir_all(&temp_root).unwrap(); + let config_path = temp_root.join("config.toml"); + fs::write( + &config_path, + r#" +provider = "xiaomi-mimo" + +[providers.xiaomi_mimo] +api_key = "tp-test-token-plan-key" +base_url = "https://api.xiaomimimo.com/v1" +"#, + ) + .unwrap(); + + let mut app = create_test_app(); + app.api_provider = crate::config::ApiProvider::XiaomiMimo; + app.config_path = Some(config_path.clone()); + let view = ConfigView::new_for_app(&app); + + let row = view + .rows + .iter() + .find(|row| row.key == "provider_url") + .expect("provider_url row missing"); + assert_eq!(row.value, crate::config::DEFAULT_XIAOMI_MIMO_BASE_URL); + assert!(!view.rows.iter().any(|row| row.key == "base_url")); + } + #[test] fn config_view_cost_currency_shows_saved_and_effective_runtime_currency() { let _guard = ConfigSettingsEnvGuard::new("locale = \"zh-Hans\"\ncost_currency = \"usd\"\n"); @@ -2482,7 +2557,7 @@ mod tests { view.clear_filter(); view.rows[0].value = "CAFÉ".to_string(); type_filter(&mut view, "café"); - assert_eq!(visible_row_keys(&view), vec!["model"]); + assert_eq!(visible_row_keys(&view), vec!["provider"]); } #[test] @@ -2591,6 +2666,12 @@ mod tests { let app = create_test_app(); let mut view = ConfigView::new_for_app(&app); + // Navigate to the "model" row (index 2, after provider and base_url) + for _ in 0..2 { + view.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + } + assert_eq!(view.rows[view.selected].key, "model"); + let start = view.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert!(matches!(start, ViewAction::None)); assert!(view.editing.is_some()); diff --git a/crates/tui/src/tui/views/mode_picker.rs b/crates/tui/src/tui/views/mode_picker.rs index 8dbc181a..e84cd043 100644 --- a/crates/tui/src/tui/views/mode_picker.rs +++ b/crates/tui/src/tui/views/mode_picker.rs @@ -38,7 +38,7 @@ const MODE_ROWS: &[ModeRow] = &[ mode: AppMode::Yolo, number: '3', name: "YOLO", - hint: "Shell + trust + auto-approve", + hint: "Auto-approve; shell enabled", }, ]; diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index aa6ed750..4c99e882 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -7,10 +7,9 @@ and capability metadata that the code already knows about. DeepSeek remains the first-class default provider. NVIDIA NIM, OpenRouter, Volcengine Ark, Xiaomi MiMo, Novita, Fireworks, SiliconFlow, Arcee AI, generic -OpenAI-compatible endpoints, self-hosted runtimes, and Moonshot/Kimi are -additive routes for running the same terminal harness against other hosted or -local model endpoints. Hugging Face Inference Providers are a planned additive -open-model routing layer; they are not a native provider in this checkout yet. +OpenAI-compatible endpoints, self-hosted runtimes, Moonshot/Kimi, and Hugging +Face Inference Providers are additive routes for running the same terminal +harness against other hosted or local model endpoints. Sources to keep in sync: @@ -31,7 +30,8 @@ The canonical provider IDs are: `deepseek`, `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `volcengine`, `openrouter`, `xiaomi-mimo`, `novita`, `fireworks`, `siliconflow`, -`siliconflow-CN`, `arcee`, `moonshot`, `sglang`, `vllm`, and `ollama`. +`siliconflow-CN`, `arcee`, `moonshot`, `sglang`, `vllm`, `ollama`, and +`huggingface`. Use any of these surfaces to select a provider: @@ -118,7 +118,7 @@ endpoint. | `wanjie-ark` | `[providers.wanjie_ark]` | `WANJIE_ARK_API_KEY`, `WANJIE_API_KEY`, `WANJIE_MAAS_API_KEY` | `WANJIE_ARK_BASE_URL`, `WANJIE_BASE_URL`, `WANJIE_MAAS_BASE_URL`; default `https://maas-openapi.wanjiedata.com/api/v1` | `deepseek-reasoner` | OpenAI-compatible hosted route. `WANJIE_ARK_MODEL`, `WANJIE_MODEL`, and `WANJIE_MAAS_MODEL` are accepted. | | `volcengine` | `[providers.volcengine]` | `VOLCENGINE_API_KEY`, `VOLCENGINE_ARK_API_KEY`, `ARK_API_KEY` | `VOLCENGINE_BASE_URL`, `VOLCENGINE_ARK_BASE_URL`, `ARK_BASE_URL`; default `https://ark.cn-beijing.volces.com/api/coding/v3` | `DeepSeek-V4-Pro`, `DeepSeek-V4-Flash` | Volcengine/Volcano Engine Ark OpenAI-compatible coding endpoint. `VOLCENGINE_MODEL` and `VOLCENGINE_ARK_MODEL` are accepted. | | `openrouter` | `[providers.openrouter]` | `OPENROUTER_API_KEY` | `OPENROUTER_BASE_URL`; default `https://openrouter.ai/api/v1` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash`; recent large IDs include `arcee-ai/trinity-large-thinking`, `minimax/minimax-m3`, `xiaomi/mimo-v2.5-pro`, `qwen/qwen3.6-flash`, `qwen/qwen3.6-35b-a3b`, `qwen/qwen3.6-max-preview`, `qwen/qwen3.6-27b`, `qwen/qwen3.6-plus`, `google/gemma-4-31b-it`, `z-ai/glm-5.1`, `moonshotai/kimi-k2.6` | Additive open-model routing layer. It does not replace DeepSeek; it lets users route supported model IDs through OpenRouter when they choose it. | -| `xiaomi-mimo` | `[providers.xiaomi_mimo]` | `XIAOMI_MIMO_API_KEY`, `XIAOMI_API_KEY`, `MIMO_API_KEY` | `XIAOMI_MIMO_BASE_URL`, `MIMO_BASE_URL`; default `https://api.xiaomimimo.com/v1` | Chat: `mimo-v2.5-pro`, `mimo-v2.5`; speech/TTS: `mimo-v2.5-tts`, `mimo-v2.5-tts-voicedesign`, `mimo-v2.5-tts-voiceclone`, `mimo-v2-tts` | Xiaomi MiMo OpenAI-compatible chat completions route. It sends `max_completion_tokens` and uses MiMo's `thinking` field for reasoning control. `codewhale speech` / `tts` uses the TTS models. | +| `xiaomi-mimo` | `[providers.xiaomi_mimo]` | `XIAOMI_MIMO_API_KEY`, `XIAOMI_API_KEY`, `MIMO_API_KEY` | `XIAOMI_MIMO_BASE_URL`, `MIMO_BASE_URL`; default `https://token-plan-sgp.xiaomimimo.com/v1` | Chat: `mimo-v2.5-pro`, `mimo-v2.5`; speech/TTS: `mimo-v2.5-tts`, `mimo-v2.5-tts-voicedesign`, `mimo-v2.5-tts-voiceclone`, `mimo-v2-tts` | Xiaomi MiMo OpenAI-compatible chat completions route. Token Plan keys (`tp-...`) use the token-plan endpoint by default; pay-as-you-go keys can set `base_url = "https://api.xiaomimimo.com/v1"`. It sends `max_completion_tokens` and uses MiMo's `thinking` field for reasoning control. `codewhale speech` / `tts` uses the TTS models. | | `novita` | `[providers.novita]` | `NOVITA_API_KEY` | `NOVITA_BASE_URL`; default `https://api.novita.ai/v1` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash` | OpenAI-compatible hosted route for DeepSeek model IDs. Use config or `CODEWHALE_MODEL` / `DEEPSEEK_MODEL` for model overrides. | | `fireworks` | `[providers.fireworks]` | `FIREWORKS_API_KEY` | `FIREWORKS_BASE_URL`; default `https://api.fireworks.ai/inference/v1` | `accounts/fireworks/models/deepseek-v4-pro` | OpenAI-compatible hosted route. Use config or `CODEWHALE_MODEL` / `DEEPSEEK_MODEL` for model overrides. | | `siliconflow` | `[providers.siliconflow]` | `SILICONFLOW_API_KEY` | `SILICONFLOW_BASE_URL`; default `https://api.siliconflow.com/v1` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | OpenAI-compatible hosted route. Official docs use the `.com` endpoint. `SILICONFLOW_MODEL` is accepted. Reasoning aliases `deepseek-reasoner` and `deepseek-r1` map to Pro; `deepseek-chat` and `deepseek-v3` map to Flash. | @@ -128,6 +128,7 @@ endpoint. | `sglang` | `[providers.sglang]` | Optional `SGLANG_API_KEY` | `SGLANG_BASE_URL`; default `http://localhost:30000/v1` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | Self-hosted OpenAI-compatible route. Localhost deployments commonly omit auth. `SGLANG_MODEL` is accepted. | | `vllm` | `[providers.vllm]` | Optional `VLLM_API_KEY` | `VLLM_BASE_URL`; default `http://localhost:8000/v1` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | Self-hosted vLLM OpenAI-compatible route. Localhost deployments commonly omit auth. `VLLM_MODEL` is accepted. | | `ollama` | `[providers.ollama]` | Optional `OLLAMA_API_KEY` | `OLLAMA_BASE_URL`; default `http://localhost:11434/v1` | `deepseek-coder:1.3b`; provider-hinted custom tags pass through | Self-hosted Ollama OpenAI-compatible route. Localhost deployments commonly omit auth. `OLLAMA_MODEL` is accepted. | +| `huggingface` | `[providers.huggingface]` | `HUGGINGFACE_API_KEY`, `HF_TOKEN` | `HUGGINGFACE_BASE_URL`; default `https://router.huggingface.co/v1` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | Hugging Face Inference Providers OpenAI-compatible route. Org-prefixed model IDs pass through. | ### Xiaomi MiMo Notes @@ -181,6 +182,7 @@ endpoint when the endpoint supports model listing. | `sglang` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | yes | yes | | `vllm` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | yes | yes | | `ollama` | `deepseek-coder:1.3b`; custom tags pass through when provider hint is `ollama` | yes | no | +| `huggingface` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | yes | no | AtlasCloud keeps the same default model as the config layer and adds provider-scoped aliases for the Pro and Flash rows. Other AtlasCloud model IDs @@ -213,6 +215,7 @@ All shipped providers use the Chat Completions request payload mode today. | Direct Arcee API `trinity-large-preview` | 262,144 | 4,096 | no in doctor capability metadata | no | not documented in code | | Generic `openai`, AtlasCloud, and Moonshot/Kimi | 128,000 | 4,096 | no in doctor capability metadata | no | not documented in code | | Ollama | 8,192 | 4,096 | no | no | not documented in code | +| Hugging Face Inference Providers V4 model IDs | 131,072 | 4,096 | yes | no | not documented in code | | Other recognized DeepSeek model IDs | 128,000 unless the model name carries an explicit `Nk` hint | 4,096 | no unless V4/reasoner logic matches | DeepSeek/NIM only | DeepSeek beta only | Tool-call support is tracked separately by the static `ModelRegistry` and by @@ -273,15 +276,6 @@ provider docs work, but they are not native shipped behavior in this checkout: secret resolution, base URL normalization, auth-header construction, and provider metadata. Those responsibilities are still split across `crates/config`, `crates/secrets`, and `crates/tui/src/client.rs`. -- A native Hugging Face provider such as `[providers.huggingface]`. -- Native Hugging Face auth envs such as `HF_TOKEN` or `HUGGINGFACE_API_KEY`. -- A default Hugging Face router base URL such as - `https://router.huggingface.co/v1`. - Hugging Face model passport metadata in the picker, including license, base model, context length, chat template, tool-call support, reasoning support, and gated/private status. - -Until native Hugging Face support lands, users can only reach an explicitly -configured Hugging Face-compatible OpenAI route through the generic `openai` -provider. That is an explicit user-selected route, not built-in Hub discovery -or a replacement for DeepSeek.