fix(provider): polish v0.8.53 routing and shell gating

This commit is contained in:
Hunter Bown
2026-06-03 14:40:25 -07:00
parent 5786584767
commit 28a0f19c13
25 changed files with 1517 additions and 146 deletions
+18
View File
@@ -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
+23 -3
View File
@@ -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 |
+24 -6
View File
@@ -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
+22
View File
@@ -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)
}
+4 -1
View File
@@ -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 => &[
+294 -9
View File
@@ -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<String>, 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<String>,
volcengine_model: Option<String>,
wanjie_ark_model: Option<String>,
openrouter_model: Option<String>,
moonshot_model: Option<String>,
xiaomi_mimo_model: Option<String>,
novita_model: Option<String>,
fireworks_model: Option<String>,
arcee_model: Option<String>,
output_mode: Option<String>,
auth_mode: Option<String>,
@@ -2345,6 +2454,8 @@ struct EnvRuntimeOverrides {
sglang_base_url: Option<String>,
vllm_base_url: Option<String>,
ollama_base_url: Option<String>,
huggingface_base_url: Option<String>,
huggingface_model: Option<String>,
}
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<OsString>,
openrouter_api_key: Option<OsString>,
openrouter_base_url: Option<OsString>,
openrouter_model: Option<OsString>,
xiaomi_mimo_api_key: Option<OsString>,
xiaomi_api_key: Option<OsString>,
mimo_api_key: Option<OsString>,
@@ -2678,17 +2812,26 @@ mod tests {
xiaomi_mimo_model: Option<OsString>,
mimo_model: Option<OsString>,
wanjie_ark_api_key: Option<OsString>,
volcengine_api_key: Option<OsString>,
volcengine_ark_api_key: Option<OsString>,
ark_api_key: Option<OsString>,
volcengine_base_url: Option<OsString>,
volcengine_ark_base_url: Option<OsString>,
ark_base_url: Option<OsString>,
wanjie_ark_base_url: Option<OsString>,
wanjie_base_url: Option<OsString>,
wanjie_maas_base_url: Option<OsString>,
volcengine_model: Option<OsString>,
volcengine_ark_model: Option<OsString>,
wanjie_ark_model: Option<OsString>,
wanjie_model: Option<OsString>,
wanjie_maas_model: Option<OsString>,
novita_api_key: Option<OsString>,
novita_base_url: Option<OsString>,
novita_model: Option<OsString>,
fireworks_api_key: Option<OsString>,
fireworks_base_url: Option<OsString>,
fireworks_model: Option<OsString>,
siliconflow_api_key: Option<OsString>,
siliconflow_base_url: Option<OsString>,
siliconflow_model: Option<OsString>,
@@ -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();
+3 -2
View File
@@ -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 => {
+363 -1
View File
@@ -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<PathBuf> {
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<PathBuf> {
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<String, String> {
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<bool, String> {
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()
+1 -1
View File
@@ -179,7 +179,7 @@ pub const COMMANDS: &[CommandInfo] = &[
CommandInfo {
name: "provider",
aliases: &[],
usage: "/provider [name]",
usage: "/provider [name] [model]",
description_id: MessageId::CmdProviderDescription,
},
CommandInfo {
+2 -1
View File
@@ -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()
));
};
+548 -41
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -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!(
+14 -2
View File
@@ -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));
}
+3 -2
View File
@@ -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 {
+4 -6
View File
@@ -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 <n>|drop <n>|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!(
+6 -1
View File
@@ -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",
}
+12 -4
View File
@@ -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 {:?}",
+5 -4
View File
@@ -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),
);
}
}
+16 -4
View File
@@ -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");
+2 -1
View File
@@ -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");
+8 -2
View File
@@ -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);
+40 -26
View File
@@ -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);
}
+94 -13
View File
@@ -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::<Vec<_>>();
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());
+1 -1
View File
@@ -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",
},
];
+9 -15
View File
@@ -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.