fix(provider): polish v0.8.53 routing and shell gating
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -179,7 +179,7 @@ pub const COMMANDS: &[CommandInfo] = &[
|
||||
CommandInfo {
|
||||
name: "provider",
|
||||
aliases: &[],
|
||||
usage: "/provider [name]",
|
||||
usage: "/provider [name] [model]",
|
||||
description_id: MessageId::CmdProviderDescription,
|
||||
},
|
||||
CommandInfo {
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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!(
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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 {:?}",
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user