diff --git a/README.md b/README.md index 3213ee154..aaf68c4b7 100644 --- a/README.md +++ b/README.md @@ -319,6 +319,10 @@ codewhale --provider novita --model deepseek/deepseek-v4-pro codewhale auth set --provider fireworks --api-key "YOUR_FIREWORKS_API_KEY" codewhale --provider fireworks --model deepseek-v4-pro +# SiliconFlow +codewhale auth set --provider siliconflow --api-key "YOUR_SILICONFLOW_API_KEY" +codewhale --provider siliconflow --model deepseek-ai/DeepSeek-V4-Pro + # Generic OpenAI-compatible endpoint codewhale auth set --provider openai --api-key "YOUR_OPENAI_COMPATIBLE_API_KEY" OPENAI_BASE_URL="https://openai-compatible.example/v4" codewhale --provider openai --model glm-5 @@ -477,17 +481,18 @@ 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`, `openrouter`, `novita`, `fireworks`, `moonshot`, `sglang`, `vllm`, `ollama` | +| `CODEWHALE_PROVIDER` / `DEEPSEEK_PROVIDER` | `deepseek` (default), `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `openrouter`, `novita`, `fireworks`, `siliconflow`, `moonshot`, `sglang`, `vllm`, `ollama` | | `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` / `OPENROUTER_API_KEY` / `NOVITA_API_KEY` / `FIREWORKS_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` / `OPENROUTER_API_KEY` / `NOVITA_API_KEY` / `FIREWORKS_API_KEY` / `SILICONFLOW_API_KEY` / `MOONSHOT_API_KEY` / `KIMI_API_KEY` / `SGLANG_API_KEY` / `VLLM_API_KEY` / `OLLAMA_API_KEY` | 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 | | `OPENROUTER_BASE_URL` | OpenRouter endpoint override | | `NOVITA_BASE_URL` | Novita endpoint override | | `FIREWORKS_BASE_URL` | Fireworks endpoint override | +| `SILICONFLOW_BASE_URL` / `SILICONFLOW_MODEL` | SiliconFlow endpoint and model override | | `SGLANG_BASE_URL` | Self-hosted SGLang endpoint | | `SGLANG_MODEL` | Self-hosted SGLang model ID | | `VLLM_BASE_URL` | Self-hosted vLLM endpoint | diff --git a/README.zh-CN.md b/README.zh-CN.md index f079cc800..62466b879 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -267,6 +267,10 @@ codewhale --provider novita --model deepseek/deepseek-v4-pro codewhale auth set --provider fireworks --api-key "YOUR_FIREWORKS_API_KEY" codewhale --provider fireworks --model deepseek-v4-pro +# SiliconFlow +codewhale auth set --provider siliconflow --api-key "YOUR_SILICONFLOW_API_KEY" +codewhale --provider siliconflow --model deepseek-ai/DeepSeek-V4-Pro + # 通用 OpenAI 兼容端点 codewhale auth set --provider openai --api-key "YOUR_OPENAI_COMPATIBLE_API_KEY" OPENAI_BASE_URL="https://openai-compatible.example/v4" codewhale --provider openai --model glm-5 @@ -400,17 +404,18 @@ DeepSeek 可作为自定义 Agent Client Protocol 服务器运行,供 Zed 等 | `DEEPSEEK_HTTP_HEADERS` | 可选模型请求头,例如 `X-Model-Provider-Id=your-model-provider` | | `DEEPSEEK_MODEL` | 默认模型 | | `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` | 流式响应空闲超时秒数,默认 `300`,限制在 `1..=3600` | -| `DEEPSEEK_PROVIDER` | `codewhale`(默认)、`nvidia-nim`、`openai`、`atlascloud`、`wanjie-ark`、`openrouter`、`novita`、`fireworks`、`sglang`、`vllm`、`ollama` | +| `DEEPSEEK_PROVIDER` | `deepseek`(默认)、`nvidia-nim`、`openai`、`atlascloud`、`wanjie-ark`、`openrouter`、`novita`、`fireworks`、`siliconflow`、`moonshot`、`sglang`、`vllm`、`ollama` | | `DEEPSEEK_PROFILE` | 配置 profile 名称 | | `DEEPSEEK_MEMORY` | 设为 `on` 启用用户记忆 | | `DEEPSEEK_ALLOW_INSECURE_HTTP=1` | 在可信网络上允许非本机 `http://` API base URL | -| `NVIDIA_API_KEY` / `OPENAI_API_KEY` / `ATLASCLOUD_API_KEY` / `WANJIE_ARK_API_KEY` / `OPENROUTER_API_KEY` / `NOVITA_API_KEY` / `FIREWORKS_API_KEY` / `SGLANG_API_KEY` / `VLLM_API_KEY` / `OLLAMA_API_KEY` | 提供商认证 | +| `NVIDIA_API_KEY` / `OPENAI_API_KEY` / `ATLASCLOUD_API_KEY` / `WANJIE_ARK_API_KEY` / `OPENROUTER_API_KEY` / `NOVITA_API_KEY` / `FIREWORKS_API_KEY` / `SILICONFLOW_API_KEY` / `MOONSHOT_API_KEY` / `KIMI_API_KEY` / `SGLANG_API_KEY` / `VLLM_API_KEY` / `OLLAMA_API_KEY` | 提供商认证 | | `OPENAI_BASE_URL` / `OPENAI_MODEL` | 通用 OpenAI 兼容端点和模型 ID | | `ATLASCLOUD_BASE_URL` / `ATLASCLOUD_MODEL` | AtlasCloud 端点和模型覆盖 | | `WANJIE_ARK_BASE_URL` / `WANJIE_ARK_MODEL` | Wanjie Ark 端点和模型覆盖 | | `OPENROUTER_BASE_URL` | OpenRouter 端点覆盖 | | `NOVITA_BASE_URL` | Novita 端点覆盖 | | `FIREWORKS_BASE_URL` | Fireworks 端点覆盖 | +| `SILICONFLOW_BASE_URL` / `SILICONFLOW_MODEL` | SiliconFlow 端点和模型覆盖 | | `SGLANG_BASE_URL` | 自托管 SGLang 端点 | | `SGLANG_MODEL` | 自托管 SGLang 模型 ID | | `VLLM_BASE_URL` | 自托管 vLLM 端点 | diff --git a/config.example.toml b/config.example.toml index c8a7155bc..a571f2495 100644 --- a/config.example.toml +++ b/config.example.toml @@ -13,11 +13,12 @@ # `[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 fireworks`, `/provider sglang`, `/provider vllm`, `/provider ollama`) +# `--provider fireworks`, `--provider siliconflow`, `/provider sglang`, +# `/provider vllm`, `/provider ollama`) # 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 | openrouter | novita | fireworks | sglang | vllm | ollama +provider = "deepseek" # deepseek | deepseek-cn | nvidia-nim | openai | atlascloud | wanjie-ark | openrouter | novita | fireworks | siliconflow | sglang | vllm | ollama 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) @@ -38,6 +39,8 @@ base_url = "https://api.deepseek.com/beta" # deepseek-ai/deepseek-v4-flash — default AtlasCloud model ID # deepseek-reasoner — default Wanjie Ark model ID # accounts/fireworks/models/deepseek-v4-pro — Fireworks AI Pro model ID +# deepseek-ai/DeepSeek-V4-Pro — SiliconFlow Pro model ID +# deepseek-ai/DeepSeek-V4-Flash — SiliconFlow Flash model ID # deepseek-ai/DeepSeek-V4-Pro — SGLang self-hosted Pro model ID # deepseek-ai/DeepSeek-V4-Flash — SGLang self-hosted Flash model ID default_text_model = "deepseek-v4-pro" @@ -178,8 +181,8 @@ max_subagents = 10 # optional (1-20) # ───────────────────────────────────────────────────────────────────────────────── # Providers can be stored at once; `provider = "..."` (top of file) or # `/provider deepseek` / `/provider nvidia-nim` / `--provider openai` / -# `--provider wanjie-ark` / `/provider fireworks` switches between them without -# having to re-enter keys. Env vars override anything set here: +# `--provider wanjie-ark` / `/provider fireworks` / `--provider siliconflow` +# switches between them without having to re-enter keys. Env vars override anything set here: # DeepSeek: DEEPSEEK_API_KEY, DEEPSEEK_BASE_URL, DEEPSEEK_MODEL # NIM: NVIDIA_API_KEY (or NVIDIA_NIM_API_KEY), NIM_BASE_URL # (or NVIDIA_NIM_BASE_URL / NVIDIA_BASE_URL), NVIDIA_NIM_MODEL @@ -188,6 +191,7 @@ max_subagents = 10 # optional (1-20) # OpenRouter: OPENROUTER_API_KEY, OPENROUTER_BASE_URL, OPENROUTER_MODEL # 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 # 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 @@ -244,6 +248,12 @@ max_subagents = 10 # optional (1-20) # base_url = "https://api.fireworks.ai/inference/v1" # model = "accounts/fireworks/models/deepseek-v4-pro" +# SiliconFlow-hosted DeepSeek V4 (https://siliconflow.com) +[providers.siliconflow] +# api_key = "YOUR_SILICONFLOW_API_KEY" +# base_url = "https://api.siliconflow.com/v1" +# model = "deepseek-ai/DeepSeek-V4-Pro" # or deepseek-ai/DeepSeek-V4-Flash + # Self-hosted SGLang OpenAI-compatible server [providers.sglang] # api_key = "OPTIONAL_SGLANG_TOKEN" diff --git a/crates/agent/src/lib.rs b/crates/agent/src/lib.rs index 349951ce8..81371b525 100644 --- a/crates/agent/src/lib.rs +++ b/crates/agent/src/lib.rs @@ -151,6 +151,30 @@ impl Default for ModelRegistry { supports_tools: true, supports_reasoning: true, }, + ModelInfo { + id: "deepseek-ai/DeepSeek-V4-Pro".to_string(), + provider: ProviderKind::Siliconflow, + aliases: vec![ + "deepseek-v4-pro".to_string(), + "deepseek-reasoner".to_string(), + "deepseek-r1".to_string(), + "siliconflow-deepseek-v4-pro".to_string(), + ], + supports_tools: true, + supports_reasoning: true, + }, + ModelInfo { + id: "deepseek-ai/DeepSeek-V4-Flash".to_string(), + provider: ProviderKind::Siliconflow, + aliases: vec![ + "deepseek-v4-flash".to_string(), + "deepseek-chat".to_string(), + "deepseek-v3".to_string(), + "siliconflow-deepseek-v4-flash".to_string(), + ], + supports_tools: true, + supports_reasoning: true, + }, ModelInfo { id: "kimi-k2.6".to_string(), provider: ProviderKind::Moonshot, @@ -413,6 +437,34 @@ mod tests { ); } + #[test] + fn siliconflow_default_uses_canonical_pro_model_id() { + let registry = ModelRegistry::default(); + let resolved = registry.resolve(None, Some(ProviderKind::Siliconflow)); + + assert_eq!(resolved.resolved.provider, ProviderKind::Siliconflow); + assert_eq!(resolved.resolved.id, "deepseek-ai/DeepSeek-V4-Pro"); + assert!(resolved.resolved.supports_reasoning); + } + + #[test] + fn deepseek_reasoner_alias_resolves_to_siliconflow_pro_when_provider_hinted() { + let registry = ModelRegistry::default(); + let resolved = registry.resolve(Some("deepseek-reasoner"), Some(ProviderKind::Siliconflow)); + + assert_eq!(resolved.resolved.provider, ProviderKind::Siliconflow); + assert_eq!(resolved.resolved.id, "deepseek-ai/DeepSeek-V4-Pro"); + } + + #[test] + fn deepseek_v4_flash_alias_resolves_to_siliconflow_flash_when_provider_hinted() { + let registry = ModelRegistry::default(); + let resolved = registry.resolve(Some("deepseek-v4-flash"), Some(ProviderKind::Siliconflow)); + + assert_eq!(resolved.resolved.provider, ProviderKind::Siliconflow); + assert_eq!(resolved.resolved.id, "deepseek-ai/DeepSeek-V4-Flash"); + } + #[test] fn sglang_default_uses_canonical_model_id() { let registry = ModelRegistry::default(); diff --git a/crates/cli/src/bin/codew_legacy_shim.rs b/crates/cli/src/bin/codew_legacy_shim.rs index 165e05a9a..870128fb9 100644 --- a/crates/cli/src/bin/codew_legacy_shim.rs +++ b/crates/cli/src/bin/codew_legacy_shim.rs @@ -37,12 +37,12 @@ fn spawn_codewhale(args: &[String]) -> std::io::Result // same directory as this shim but not on PATH (#2006). #[cfg(windows)] { - if let Ok(exe_path) = env::current_exe() { - if let Some(dir) = exe_path.parent() { - let sibling = dir.join("codewhale.exe"); - if sibling.is_file() { - return Command::new(sibling).args(args).status(); - } + if let Ok(exe_path) = env::current_exe() + && let Some(dir) = exe_path.parent() + { + let sibling = dir.join("codewhale.exe"); + if sibling.is_file() { + return Command::new(sibling).args(args).status(); } } } diff --git a/crates/cli/src/bin/deepseek_legacy_shim.rs b/crates/cli/src/bin/deepseek_legacy_shim.rs index b47c9d922..abd00896c 100644 --- a/crates/cli/src/bin/deepseek_legacy_shim.rs +++ b/crates/cli/src/bin/deepseek_legacy_shim.rs @@ -44,12 +44,12 @@ fn spawn_codewhale(args: &[String]) -> std::io::Result // same directory as this shim but not on PATH (#2006). #[cfg(windows)] { - if let Ok(exe_path) = env::current_exe() { - if let Some(dir) = exe_path.parent() { - let sibling = dir.join("codewhale.exe"); - if sibling.is_file() { - return Command::new(sibling).args(args).status(); - } + if let Ok(exe_path) = env::current_exe() + && let Some(dir) = exe_path.parent() + { + let sibling = dir.join("codewhale.exe"); + if sibling.is_file() { + return Command::new(sibling).args(args).status(); } } } diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 9f98eebff..01465a578 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -31,6 +31,7 @@ enum ProviderArg { Openrouter, Novita, Fireworks, + Siliconflow, Moonshot, Sglang, Vllm, @@ -48,6 +49,7 @@ impl From for ProviderKind { ProviderArg::Openrouter => ProviderKind::Openrouter, ProviderArg::Novita => ProviderKind::Novita, ProviderArg::Fireworks => ProviderKind::Fireworks, + ProviderArg::Siliconflow => ProviderKind::Siliconflow, ProviderArg::Moonshot => ProviderKind::Moonshot, ProviderArg::Sglang => ProviderKind::Sglang, ProviderArg::Vllm => ProviderKind::Vllm, @@ -720,6 +722,7 @@ fn provider_slot(provider: ProviderKind) -> &'static str { ProviderKind::Openrouter => "openrouter", ProviderKind::Novita => "novita", ProviderKind::Fireworks => "fireworks", + ProviderKind::Siliconflow => "siliconflow", ProviderKind::Moonshot => "moonshot", ProviderKind::Sglang => "sglang", ProviderKind::Vllm => "vllm", @@ -728,7 +731,7 @@ fn provider_slot(provider: ProviderKind) -> &'static str { } /// Provider order used by the `auth list` and `auth status` outputs. -const PROVIDER_LIST: [ProviderKind; 12] = [ +const PROVIDER_LIST: [ProviderKind; 13] = [ ProviderKind::Deepseek, ProviderKind::NvidiaNim, ProviderKind::Openai, @@ -737,6 +740,7 @@ const PROVIDER_LIST: [ProviderKind; 12] = [ ProviderKind::Openrouter, ProviderKind::Novita, ProviderKind::Fireworks, + ProviderKind::Siliconflow, ProviderKind::Moonshot, ProviderKind::Sglang, ProviderKind::Vllm, @@ -792,6 +796,7 @@ fn provider_env_vars(provider: ProviderKind) -> &'static [&'static str] { ProviderKind::Novita => &["NOVITA_API_KEY"], ProviderKind::NvidiaNim => &["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY", "DEEPSEEK_API_KEY"], ProviderKind::Fireworks => &["FIREWORKS_API_KEY"], + ProviderKind::Siliconflow => &["SILICONFLOW_API_KEY"], ProviderKind::Moonshot => &["MOONSHOT_API_KEY", "KIMI_API_KEY"], ProviderKind::Sglang => &["SGLANG_API_KEY"], ProviderKind::Vllm => &["VLLM_API_KEY"], @@ -1475,13 +1480,14 @@ fn build_tui_command( | ProviderKind::Openrouter | ProviderKind::Novita | ProviderKind::Fireworks + | ProviderKind::Siliconflow | ProviderKind::Moonshot | ProviderKind::Sglang | ProviderKind::Vllm | ProviderKind::Ollama ) { bail!( - "The interactive TUI supports DeepSeek, NVIDIA NIM, OpenAI-compatible, AtlasCloud, Wanjie Ark, OpenRouter, Novita, Fireworks, Moonshot/Kimi, SGLang, vLLM, and Ollama providers. Remove --provider {} or use `codewhale model ...` for provider registry inspection.", + "The interactive TUI supports DeepSeek, NVIDIA NIM, OpenAI-compatible, AtlasCloud, Wanjie Ark, OpenRouter, Novita, Fireworks, SiliconFlow, Moonshot/Kimi, SGLang, vLLM, and Ollama providers. Remove --provider {} or use `codewhale model ...` for provider registry inspection.", resolved_runtime.provider.as_str() ); } @@ -1542,6 +1548,9 @@ fn build_tui_command( if resolved_runtime.provider == ProviderKind::WanjieArk { cmd.env("WANJIE_ARK_API_KEY", api_key); } + if resolved_runtime.provider == ProviderKind::Siliconflow { + cmd.env("SILICONFLOW_API_KEY", api_key); + } cmd.env("DEEPSEEK_API_KEY_SOURCE", "cli"); } if let Some(base_url) = cli.base_url.as_ref() { @@ -2150,6 +2159,18 @@ mod tests { })) )); + let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "siliconflow"]); + assert!(matches!( + cli.command, + Some(Commands::Auth(AuthArgs { + command: AuthCommand::Set { + provider: ProviderArg::Siliconflow, + api_key: None, + api_key_stdin: false, + } + })) + )); + let cli = parse_ok(&["deepseek", "auth", "set", "--provider", "moonshot"]); assert!(matches!( cli.command, @@ -2886,6 +2907,11 @@ mod tests { &["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY"], ), (ProviderKind::Fireworks, "fireworks", &["FIREWORKS_API_KEY"]), + ( + ProviderKind::Siliconflow, + "siliconflow", + &["SILICONFLOW_API_KEY"], + ), (ProviderKind::Sglang, "sglang", &["SGLANG_API_KEY"]), (ProviderKind::Vllm, "vllm", &["VLLM_API_KEY"]), (ProviderKind::Ollama, "ollama", &["OLLAMA_API_KEY"]), diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index bb2c339bb..83c3999a1 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -30,6 +30,8 @@ const DEFAULT_OPENROUTER_FLASH_MODEL: &str = "deepseek/deepseek-v4-flash"; const DEFAULT_NOVITA_MODEL: &str = "deepseek/deepseek-v4-pro"; const DEFAULT_NOVITA_FLASH_MODEL: &str = "deepseek/deepseek-v4-flash"; const DEFAULT_FIREWORKS_MODEL: &str = "accounts/fireworks/models/deepseek-v4-pro"; +const DEFAULT_SILICONFLOW_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro"; +const DEFAULT_SILICONFLOW_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash"; const DEFAULT_MOONSHOT_MODEL: &str = "kimi-k2.6"; const DEFAULT_MOONSHOT_BASE_URL: &str = "https://api.moonshot.ai/v1"; const DEFAULT_KIMI_CODE_MODEL: &str = "kimi-for-coding"; @@ -39,6 +41,7 @@ const DEFAULT_SGLANG_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash"; const DEFAULT_OPENROUTER_BASE_URL: &str = "https://openrouter.ai/api/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_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"; @@ -73,6 +76,8 @@ pub enum ProviderKind { Openrouter, Novita, Fireworks, + #[serde(alias = "silicon-flow", alias = "silicon_flow")] + Siliconflow, Moonshot, Sglang, Vllm, @@ -91,6 +96,7 @@ impl ProviderKind { Self::Openrouter => "openrouter", Self::Novita => "novita", Self::Fireworks => "fireworks", + Self::Siliconflow => "siliconflow", Self::Moonshot => "moonshot", Self::Sglang => "sglang", Self::Vllm => "vllm", @@ -111,6 +117,7 @@ impl ProviderKind { "openrouter" | "open_router" => Some(Self::Openrouter), "novita" => Some(Self::Novita), "fireworks" | "fireworks-ai" => Some(Self::Fireworks), + "siliconflow" | "silicon-flow" | "silicon_flow" => Some(Self::Siliconflow), "moonshot" | "moonshot-ai" | "kimi" | "kimi-k2" => Some(Self::Moonshot), "sglang" | "sg-lang" => Some(Self::Sglang), "vllm" | "v-llm" => Some(Self::Vllm), @@ -149,6 +156,8 @@ pub struct ProvidersToml { #[serde(default)] pub fireworks: ProviderConfigToml, #[serde(default)] + pub siliconflow: ProviderConfigToml, + #[serde(default)] pub moonshot: ProviderConfigToml, #[serde(default)] pub sglang: ProviderConfigToml, @@ -170,6 +179,7 @@ impl ProvidersToml { ProviderKind::Openrouter => &self.openrouter, ProviderKind::Novita => &self.novita, ProviderKind::Fireworks => &self.fireworks, + ProviderKind::Siliconflow => &self.siliconflow, ProviderKind::Moonshot => &self.moonshot, ProviderKind::Sglang => &self.sglang, ProviderKind::Vllm => &self.vllm, @@ -187,6 +197,7 @@ impl ProvidersToml { ProviderKind::Openrouter => &mut self.openrouter, ProviderKind::Novita => &mut self.novita, ProviderKind::Fireworks => &mut self.fireworks, + ProviderKind::Siliconflow => &mut self.siliconflow, ProviderKind::Moonshot => &mut self.moonshot, ProviderKind::Sglang => &mut self.sglang, ProviderKind::Vllm => &mut self.vllm, @@ -407,6 +418,10 @@ impl ConfigToml { ); merge_project_provider_config(&mut self.providers.novita, &project.providers.novita); merge_project_provider_config(&mut self.providers.fireworks, &project.providers.fireworks); + merge_project_provider_config( + &mut self.providers.siliconflow, + &project.providers.siliconflow, + ); 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); @@ -476,6 +491,12 @@ impl ConfigToml { "providers.fireworks.http_headers" => { serialize_http_headers(&self.providers.fireworks.http_headers) } + "providers.siliconflow.api_key" => self.providers.siliconflow.api_key.clone(), + "providers.siliconflow.base_url" => self.providers.siliconflow.base_url.clone(), + "providers.siliconflow.model" => self.providers.siliconflow.model.clone(), + "providers.siliconflow.http_headers" => { + serialize_http_headers(&self.providers.siliconflow.http_headers) + } "providers.moonshot.api_key" => self.providers.moonshot.api_key.clone(), "providers.moonshot.base_url" => self.providers.moonshot.base_url.clone(), "providers.moonshot.model" => self.providers.moonshot.model.clone(), @@ -633,6 +654,18 @@ impl ConfigToml { "providers.fireworks.http_headers" => { self.providers.fireworks.http_headers = parse_http_headers(value)?; } + "providers.siliconflow.api_key" => { + self.providers.siliconflow.api_key = Some(value.to_string()); + } + "providers.siliconflow.base_url" => { + self.providers.siliconflow.base_url = Some(value.to_string()); + } + "providers.siliconflow.model" => { + self.providers.siliconflow.model = Some(value.to_string()); + } + "providers.siliconflow.http_headers" => { + self.providers.siliconflow.http_headers = parse_http_headers(value)?; + } "providers.moonshot.api_key" => { self.providers.moonshot.api_key = Some(value.to_string()); } @@ -752,6 +785,12 @@ impl ConfigToml { "providers.fireworks.base_url" => self.providers.fireworks.base_url = None, "providers.fireworks.model" => self.providers.fireworks.model = None, "providers.fireworks.http_headers" => self.providers.fireworks.http_headers.clear(), + "providers.siliconflow.api_key" => self.providers.siliconflow.api_key = None, + "providers.siliconflow.base_url" => self.providers.siliconflow.base_url = None, + "providers.siliconflow.model" => self.providers.siliconflow.model = None, + "providers.siliconflow.http_headers" => { + self.providers.siliconflow.http_headers.clear(); + } "providers.moonshot.api_key" => self.providers.moonshot.api_key = None, "providers.moonshot.base_url" => self.providers.moonshot.base_url = None, "providers.moonshot.model" => self.providers.moonshot.model = None, @@ -910,6 +949,21 @@ impl ConfigToml { if let Some(v) = serialize_http_headers(&self.providers.fireworks.http_headers) { out.insert("providers.fireworks.http_headers".to_string(), v); } + if let Some(v) = self.providers.siliconflow.api_key.as_ref() { + out.insert( + "providers.siliconflow.api_key".to_string(), + redact_secret(v), + ); + } + if let Some(v) = self.providers.siliconflow.base_url.as_ref() { + out.insert("providers.siliconflow.base_url".to_string(), v.clone()); + } + if let Some(v) = self.providers.siliconflow.model.as_ref() { + out.insert("providers.siliconflow.model".to_string(), v.clone()); + } + if let Some(v) = serialize_http_headers(&self.providers.siliconflow.http_headers) { + out.insert("providers.siliconflow.http_headers".to_string(), v); + } if let Some(v) = self.providers.moonshot.api_key.as_ref() { out.insert("providers.moonshot.api_key".to_string(), redact_secret(v)); } @@ -1025,6 +1079,7 @@ impl ConfigToml { ProviderKind::Openrouter => DEFAULT_OPENROUTER_BASE_URL.to_string(), ProviderKind::Novita => DEFAULT_NOVITA_BASE_URL.to_string(), ProviderKind::Fireworks => DEFAULT_FIREWORKS_BASE_URL.to_string(), + ProviderKind::Siliconflow => DEFAULT_SILICONFLOW_BASE_URL.to_string(), ProviderKind::Moonshot => { if auth_mode.as_deref().is_some_and(auth_mode_uses_kimi_oauth) { DEFAULT_KIMI_CODE_BASE_URL.to_string() @@ -1068,9 +1123,10 @@ impl ConfigToml { } }; + let env_provider_model = env.model_for(provider, &base_url); let explicit_model = cli.model.is_some() || env.model.is_some() - || env.model_for(provider).is_some() + || env_provider_model.is_some() || provider_cfg.model.is_some() || root_deepseek_model.is_some() || self.model.is_some(); @@ -1078,7 +1134,7 @@ impl ConfigToml { .model .clone() .or_else(|| env.model.clone()) - .or_else(|| env.model_for(provider)) + .or(env_provider_model) .or_else(|| provider_cfg.model.clone()) .or(root_deepseek_model) .or_else(|| self.model.clone()) @@ -1259,6 +1315,14 @@ fn normalize_model_for_provider(provider: ProviderKind, model: &str) -> String { (ProviderKind::Fireworks, "deepseek-v4-pro" | "deepseek-v4pro") => { DEFAULT_FIREWORKS_MODEL.to_string() } + ( + ProviderKind::Siliconflow, + "deepseek-v4-pro" | "deepseek-v4pro" | "deepseek-reasoner" | "deepseek-r1", + ) => DEFAULT_SILICONFLOW_MODEL.to_string(), + ( + ProviderKind::Siliconflow, + "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-v3", + ) => DEFAULT_SILICONFLOW_FLASH_MODEL.to_string(), (ProviderKind::Moonshot, "kimi-k2.6" | "kimi-k2") => DEFAULT_MOONSHOT_MODEL.to_string(), (ProviderKind::Sglang, "deepseek-v4-pro" | "deepseek-v4pro") => { DEFAULT_SGLANG_MODEL.to_string() @@ -1290,6 +1354,7 @@ fn default_model_for_provider(provider: ProviderKind) -> &'static str { ProviderKind::Openrouter => DEFAULT_OPENROUTER_MODEL, ProviderKind::Novita => DEFAULT_NOVITA_MODEL, ProviderKind::Fireworks => DEFAULT_FIREWORKS_MODEL, + ProviderKind::Siliconflow => DEFAULT_SILICONFLOW_MODEL, ProviderKind::Moonshot => DEFAULT_MOONSHOT_MODEL, ProviderKind::Sglang => DEFAULT_SGLANG_MODEL, ProviderKind::Vllm => DEFAULT_VLLM_MODEL, @@ -1307,6 +1372,7 @@ fn default_base_url_for_provider(provider: ProviderKind) -> &'static str { ProviderKind::Openrouter => DEFAULT_OPENROUTER_BASE_URL, ProviderKind::Novita => DEFAULT_NOVITA_BASE_URL, ProviderKind::Fireworks => DEFAULT_FIREWORKS_BASE_URL, + ProviderKind::Siliconflow => DEFAULT_SILICONFLOW_BASE_URL, ProviderKind::Moonshot => DEFAULT_MOONSHOT_BASE_URL, ProviderKind::Sglang => DEFAULT_SGLANG_BASE_URL, ProviderKind::Vllm => DEFAULT_VLLM_BASE_URL, @@ -1322,11 +1388,21 @@ fn moonshot_base_url_uses_kimi_code(base_url: &str) -> bool { } fn base_url_is_custom_for_provider(provider: ProviderKind, base_url: &str) -> bool { + if provider == ProviderKind::Siliconflow && siliconflow_base_url_is_official(base_url) { + return false; + } let actual = base_url.trim_end_matches('/'); let default = default_base_url_for_provider(provider).trim_end_matches('/'); actual != default } +fn siliconflow_base_url_is_official(base_url: &str) -> bool { + matches!( + base_url.trim_end_matches('/').to_ascii_lowercase().as_str(), + "https://api.siliconflow.com/v1" | "https://api.siliconflow.cn/v1" + ) +} + fn provider_preserves_custom_base_url_model(provider: ProviderKind, base_url: &str) -> bool { base_url_is_custom_for_provider(provider, base_url) } @@ -1816,6 +1892,8 @@ struct EnvRuntimeOverrides { openrouter_base_url: Option, novita_base_url: Option, fireworks_base_url: Option, + siliconflow_base_url: Option, + siliconflow_model: Option, moonshot_base_url: Option, sglang_base_url: Option, vllm_base_url: Option, @@ -1888,6 +1966,12 @@ impl EnvRuntimeOverrides { fireworks_base_url: std::env::var("FIREWORKS_BASE_URL") .ok() .filter(|v| !v.trim().is_empty()), + siliconflow_base_url: std::env::var("SILICONFLOW_BASE_URL") + .ok() + .filter(|v| !v.trim().is_empty()), + siliconflow_model: std::env::var("SILICONFLOW_MODEL") + .ok() + .filter(|v| !v.trim().is_empty()), moonshot_base_url: std::env::var("MOONSHOT_BASE_URL") .or_else(|_| std::env::var("KIMI_BASE_URL")) .ok() @@ -1916,6 +2000,7 @@ impl EnvRuntimeOverrides { ProviderKind::Openrouter => self.openrouter_base_url.clone(), ProviderKind::Novita => self.novita_base_url.clone(), ProviderKind::Fireworks => self.fireworks_base_url.clone(), + ProviderKind::Siliconflow => self.siliconflow_base_url.clone(), ProviderKind::Moonshot => self.moonshot_base_url.clone(), ProviderKind::Sglang => self.sglang_base_url.clone(), ProviderKind::Vllm => self.vllm_base_url.clone(), @@ -1923,11 +2008,18 @@ impl EnvRuntimeOverrides { } } - fn model_for(&self, provider: ProviderKind) -> Option { - match provider { + fn model_for(&self, provider: ProviderKind, base_url: &str) -> Option { + let model = match provider { ProviderKind::WanjieArk => self.wanjie_ark_model.clone(), + ProviderKind::Siliconflow => self.siliconflow_model.clone(), ProviderKind::Moonshot => self.moonshot_model.clone(), _ => None, + }?; + + if provider_preserves_custom_base_url_model(provider, base_url) { + Some(model.trim().to_string()) + } else { + Some(normalize_model_for_provider(provider, &model)) } } } @@ -1986,6 +2078,9 @@ mod tests { novita_base_url: Option, fireworks_api_key: Option, fireworks_base_url: Option, + siliconflow_api_key: Option, + siliconflow_base_url: Option, + siliconflow_model: Option, moonshot_api_key: Option, moonshot_base_url: Option, moonshot_model: Option, @@ -2035,6 +2130,9 @@ mod tests { novita_base_url: env::var_os("NOVITA_BASE_URL"), fireworks_api_key: env::var_os("FIREWORKS_API_KEY"), fireworks_base_url: env::var_os("FIREWORKS_BASE_URL"), + 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"), moonshot_api_key: env::var_os("MOONSHOT_API_KEY"), moonshot_base_url: env::var_os("MOONSHOT_BASE_URL"), moonshot_model: env::var_os("MOONSHOT_MODEL"), @@ -2079,6 +2177,9 @@ mod tests { env::remove_var("NOVITA_BASE_URL"); env::remove_var("FIREWORKS_API_KEY"); env::remove_var("FIREWORKS_BASE_URL"); + env::remove_var("SILICONFLOW_API_KEY"); + env::remove_var("SILICONFLOW_BASE_URL"); + env::remove_var("SILICONFLOW_MODEL"); env::remove_var("MOONSHOT_API_KEY"); env::remove_var("MOONSHOT_BASE_URL"); env::remove_var("MOONSHOT_MODEL"); @@ -2140,6 +2241,9 @@ mod tests { Self::restore_var("NOVITA_BASE_URL", self.novita_base_url.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("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()); Self::restore_var("MOONSHOT_API_KEY", self.moonshot_api_key.take()); Self::restore_var("MOONSHOT_BASE_URL", self.moonshot_base_url.take()); Self::restore_var("MOONSHOT_MODEL", self.moonshot_model.take()); @@ -2718,6 +2822,14 @@ mod tests { ProviderKind::parse("fireworks-ai"), Some(ProviderKind::Fireworks) ); + assert_eq!( + ProviderKind::parse("silicon-flow"), + Some(ProviderKind::Siliconflow) + ); + assert_eq!( + ProviderKind::parse("silicon_flow"), + Some(ProviderKind::Siliconflow) + ); assert_eq!(ProviderKind::parse("kimi"), Some(ProviderKind::Moonshot)); assert_eq!( ProviderKind::parse("moonshot-ai"), @@ -2743,6 +2855,10 @@ mod tests { let parsed: ConfigToml = toml::from_str("provider = \"ark-wanjie\"").expect("wanjie provider alias"); assert_eq!(parsed.provider, ProviderKind::WanjieArk); + + let parsed: ConfigToml = + toml::from_str("provider = \"silicon-flow\"").expect("siliconflow provider alias"); + assert_eq!(parsed.provider, ProviderKind::Siliconflow); } #[test] @@ -2809,6 +2925,22 @@ mod tests { assert_eq!(resolved.model, DEFAULT_FIREWORKS_MODEL); } + #[test] + fn siliconflow_provider_defaults_to_canonical_endpoint_and_model() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let config = ConfigToml { + provider: ProviderKind::Siliconflow, + ..ConfigToml::default() + }; + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Siliconflow); + assert_eq!(resolved.base_url, DEFAULT_SILICONFLOW_BASE_URL); + assert_eq!(resolved.model, DEFAULT_SILICONFLOW_MODEL); + } + #[test] fn moonshot_provider_defaults_to_kimi_k2() { let _lock = env_lock(); @@ -3217,6 +3349,56 @@ mod tests { assert_eq!(resolved.base_url, DEFAULT_FIREWORKS_BASE_URL); } + #[test] + fn siliconflow_env_overrides_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("CODEWHALE_PROVIDER", "siliconflow"); + env::set_var("SILICONFLOW_API_KEY", "sf-env-key"); + env::set_var("SILICONFLOW_BASE_URL", "https://sf-mirror.example/v1"); + env::set_var("SILICONFLOW_MODEL", "deepseek-v4-flash"); + } + + let resolved = + ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Siliconflow); + assert_eq!(resolved.api_key.as_deref(), Some("sf-env-key")); + assert_eq!(resolved.base_url, "https://sf-mirror.example/v1"); + assert_eq!(resolved.model, "deepseek-v4-flash"); + } + + #[test] + fn siliconflow_cn_base_url_env_normalizes_model_aliases() { + 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("CODEWHALE_PROVIDER", "siliconflow"); + env::set_var("SILICONFLOW_API_KEY", "sf-env-key"); + env::set_var("SILICONFLOW_BASE_URL", "https://api.siliconflow.cn/v1"); + } + + for (alias, expected) in [ + ("deepseek-v4-flash", DEFAULT_SILICONFLOW_FLASH_MODEL), + ("deepseek-reasoner", DEFAULT_SILICONFLOW_MODEL), + ] { + // Safety: test-only environment mutation guarded by a module mutex. + unsafe { + env::set_var("SILICONFLOW_MODEL", alias); + } + + let resolved = + ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Siliconflow); + assert_eq!(resolved.base_url, "https://api.siliconflow.cn/v1"); + assert_eq!(resolved.model, expected); + } + } + #[test] fn wanjie_ark_env_api_key_and_base_url_fall_back_when_config_missing() { let _lock = env_lock(); @@ -3270,6 +3452,57 @@ mod tests { assert_eq!(resolved.model, DEFAULT_NOVITA_FLASH_MODEL); } + #[test] + fn siliconflow_provider_normalizes_flash_aliases() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let cli = CliRuntimeOverrides { + provider: Some(ProviderKind::Siliconflow), + model: Some("deepseek-v4-flash".to_string()), + ..CliRuntimeOverrides::default() + }; + + let resolved = ConfigToml::default().resolve_runtime_options(&cli); + + assert_eq!(resolved.provider, ProviderKind::Siliconflow); + assert_eq!(resolved.model, DEFAULT_SILICONFLOW_FLASH_MODEL); + } + + #[test] + fn siliconflow_provider_normalizes_reasoning_aliases_to_pro() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + + for alias in ["deepseek-reasoner", "deepseek-r1"] { + let cli = CliRuntimeOverrides { + provider: Some(ProviderKind::Siliconflow), + model: Some(alias.to_string()), + ..CliRuntimeOverrides::default() + }; + + let resolved = ConfigToml::default().resolve_runtime_options(&cli); + + assert_eq!(resolved.provider, ProviderKind::Siliconflow); + assert_eq!(resolved.model, DEFAULT_SILICONFLOW_MODEL); + } + } + + #[test] + fn siliconflow_provider_preserves_deepseek_v3_2_alias() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let cli = CliRuntimeOverrides { + provider: Some(ProviderKind::Siliconflow), + model: Some("deepseek-v3.2".to_string()), + ..CliRuntimeOverrides::default() + }; + + let resolved = ConfigToml::default().resolve_runtime_options(&cli); + + assert_eq!(resolved.provider, ProviderKind::Siliconflow); + assert_eq!(resolved.model, "deepseek-v3.2"); + } + #[test] fn sglang_provider_normalizes_flash_aliases() { let _lock = env_lock(); @@ -3356,6 +3589,24 @@ mod tests { assert_eq!(resolved.model, "DeepSeek-V4-Pro"); } + #[test] + fn siliconflow_custom_base_url_preserves_provider_model() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let mut config = ConfigToml { + provider: ProviderKind::Siliconflow, + ..ConfigToml::default() + }; + config.providers.siliconflow.base_url = Some("https://my-gateway.example/v1".to_string()); + config.providers.siliconflow.model = Some("DeepSeek-V4-Pro".to_string()); + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Siliconflow); + assert_eq!(resolved.base_url, "https://my-gateway.example/v1"); + assert_eq!(resolved.model, "DeepSeek-V4-Pro"); + } + #[test] fn config_file_resolves_above_env_and_keyring() { use codewhale_secrets::KeyringStore; diff --git a/crates/secrets/src/lib.rs b/crates/secrets/src/lib.rs index 69c3fc9a3..2d65c67c7 100644 --- a/crates/secrets/src/lib.rs +++ b/crates/secrets/src/lib.rs @@ -533,6 +533,7 @@ pub fn env_for(name: &str) -> Option { &["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY", "DEEPSEEK_API_KEY"] } "fireworks" | "fireworks-ai" => &["FIREWORKS_API_KEY"], + "siliconflow" | "silicon-flow" | "silicon_flow" => &["SILICONFLOW_API_KEY"], "moonshot" | "moonshot-ai" | "kimi" | "kimi-k2" => &["MOONSHOT_API_KEY", "KIMI_API_KEY"], "sglang" | "sg-lang" => &["SGLANG_API_KEY"], "vllm" | "v-llm" => &["VLLM_API_KEY"], @@ -579,6 +580,7 @@ mod tests { "NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY", "FIREWORKS_API_KEY", + "SILICONFLOW_API_KEY", "SGLANG_API_KEY", "VLLM_API_KEY", "OLLAMA_API_KEY", @@ -777,6 +779,20 @@ mod tests { unsafe { std::env::remove_var("FIREWORKS_API_KEY") }; } + #[test] + fn siliconflow_env_aliases_resolve() { + let _lock = env_lock(); + clear_known_envs(); + // Safety: env mutation guarded by env_lock(). + unsafe { std::env::set_var("SILICONFLOW_API_KEY", "sf-key") }; + + assert_eq!(env_for("siliconflow").as_deref(), Some("sf-key")); + assert_eq!(env_for("silicon-flow").as_deref(), Some("sf-key")); + assert_eq!(env_for("silicon_flow").as_deref(), Some("sf-key")); + // Safety: env mutation guarded by env_lock(). + unsafe { std::env::remove_var("SILICONFLOW_API_KEY") }; + } + #[test] fn moonshot_kimi_env_aliases_resolve() { let _lock = env_lock(); diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index ad9ae2e0f..f7aa8b180 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -883,6 +883,7 @@ pub(super) fn apply_reasoning_effort( | ApiProvider::DeepseekCN | ApiProvider::Openrouter | ApiProvider::Novita + | ApiProvider::Siliconflow | ApiProvider::Sglang => { body["thinking"] = json!({ "type": "disabled" }); } @@ -914,7 +915,10 @@ pub(super) fn apply_reasoning_effort( }, "low" | "minimal" | "medium" | "mid" | "high" | "" => match provider { // DeepSeek compatibility: low/medium both map to high - ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::Sglang => { + ApiProvider::Deepseek + | ApiProvider::DeepseekCN + | ApiProvider::Siliconflow + | ApiProvider::Sglang => { body["reasoning_effort"] = json!("high"); body["thinking"] = json!({ "type": "enabled" }); } @@ -959,7 +963,10 @@ pub(super) fn apply_reasoning_effort( } }, "xhigh" | "max" | "highest" => match provider { - ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::Sglang => { + ApiProvider::Deepseek + | ApiProvider::DeepseekCN + | ApiProvider::Siliconflow + | ApiProvider::Sglang => { body["reasoning_effort"] = json!("max"); body["thinking"] = json!({ "type": "enabled" }); } diff --git a/crates/tui/src/client/chat.rs b/crates/tui/src/client/chat.rs index 656330fc8..3bb3bcb13 100644 --- a/crates/tui/src/client/chat.rs +++ b/crates/tui/src/client/chat.rs @@ -1731,6 +1731,7 @@ fn provider_accepts_reasoning_content(provider: ApiProvider) -> bool { | ApiProvider::Openrouter | ApiProvider::Novita | ApiProvider::Fireworks + | ApiProvider::Siliconflow | ApiProvider::Sglang ) } diff --git a/crates/tui/src/commands/config.rs b/crates/tui/src/commands/config.rs index 651b4d5d4..abd37f395 100644 --- a/crates/tui/src/commands/config.rs +++ b/crates/tui/src/commands/config.rs @@ -473,9 +473,10 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> Err(err) => return CommandResult::error(format!("Failed to save: {err}")), } } - return CommandResult::error(format!( + return CommandResult::error( "base_url must be saved with --save; client base URL is loaded from config on startup. Restart and re-open your session after saving." - )); + .to_string(), + ); } _ => {} } diff --git a/crates/tui/src/commands/provider.rs b/crates/tui/src/commands/provider.rs index 915cce8c5..63b8dc16d 100644 --- a/crates/tui/src/commands/provider.rs +++ b/crates/tui/src/commands/provider.rs @@ -27,7 +27,7 @@ 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, novita, fireworks, sglang, vllm, or ollama." + "Unknown provider '{name}'. Expected: deepseek, nvidia-nim, openai, atlascloud, wanjie-ark, openrouter, novita, fireworks, siliconflow, moonshot, sglang, vllm, or ollama." )); }; @@ -181,6 +181,19 @@ mod tests { } } + #[test] + fn switch_to_siliconflow_emits_action() { + let mut app = create_test_app(); + let result = provider(&mut app, Some("siliconflow flash")); + match result.action { + Some(AppAction::SwitchProvider { provider, model }) => { + assert_eq!(provider, ApiProvider::Siliconflow); + assert_eq!(model.as_deref(), Some("deepseek-v4-flash")); + } + other => panic!("expected SwitchProvider, got {other:?}"), + } + } + #[test] fn switch_to_sglang_flash_emits_action() { let mut app = create_test_app(); diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 282d2023e..b310001e3 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -51,6 +51,9 @@ pub const DEFAULT_NOVITA_FLASH_MODEL: &str = "deepseek/deepseek-v4-flash"; pub const DEFAULT_NOVITA_BASE_URL: &str = "https://api.novita.ai/v1"; pub const DEFAULT_FIREWORKS_MODEL: &str = "accounts/fireworks/models/deepseek-v4-pro"; pub const DEFAULT_FIREWORKS_BASE_URL: &str = "https://api.fireworks.ai/inference/v1"; +pub const DEFAULT_SILICONFLOW_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro"; +pub const DEFAULT_SILICONFLOW_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash"; +pub const DEFAULT_SILICONFLOW_BASE_URL: &str = "https://api.siliconflow.com/v1"; pub const DEFAULT_MOONSHOT_MODEL: &str = "kimi-k2.6"; pub const DEFAULT_MOONSHOT_BASE_URL: &str = "https://api.moonshot.ai/v1"; pub const DEFAULT_KIMI_CODE_MODEL: &str = "kimi-for-coding"; @@ -93,6 +96,7 @@ pub enum ApiProvider { Openrouter, Novita, Fireworks, + Siliconflow, Moonshot, Sglang, Vllm, @@ -115,6 +119,7 @@ impl ApiProvider { "openrouter" | "open_router" => Some(Self::Openrouter), "novita" => Some(Self::Novita), "fireworks" | "fireworks-ai" => Some(Self::Fireworks), + "siliconflow" | "silicon-flow" | "silicon_flow" => Some(Self::Siliconflow), "moonshot" | "moonshot-ai" | "kimi" | "kimi-k2" => Some(Self::Moonshot), "sglang" | "sg-lang" => Some(Self::Sglang), "vllm" | "v-llm" => Some(Self::Vllm), @@ -135,6 +140,7 @@ impl ApiProvider { Self::Openrouter => "openrouter", Self::Novita => "novita", Self::Fireworks => "fireworks", + Self::Siliconflow => "siliconflow", Self::Moonshot => "moonshot", Self::Sglang => "sglang", Self::Vllm => "vllm", @@ -155,6 +161,7 @@ impl ApiProvider { Self::Openrouter => "OpenRouter", Self::Novita => "Novita AI", Self::Fireworks => "Fireworks AI", + Self::Siliconflow => "SiliconFlow", Self::Moonshot => "Moonshot/Kimi", Self::Sglang => "SGLang", Self::Vllm => "vLLM", @@ -174,6 +181,7 @@ impl ApiProvider { Self::Openrouter, Self::Novita, Self::Fireworks, + Self::Siliconflow, Self::Moonshot, Self::Sglang, Self::Vllm, @@ -431,6 +439,12 @@ pub fn normalize_model_name_for_provider(provider: ApiProvider, model: &str) -> } return Some(canonical.to_string()); } + if matches!(provider, ApiProvider::Siliconflow) { + let provider_model = model_for_provider(provider, normalized.clone()); + if provider_model != normalized { + return Some(provider_model); + } + } if let Some(canonical) = canonical_official_deepseek_model_id(&normalized) { return Some(model_for_provider(provider, canonical.to_string())); } @@ -445,6 +459,9 @@ pub fn model_completion_names_for_provider(provider: ApiProvider) -> Vec<&'stati ApiProvider::Openrouter => vec![DEFAULT_OPENROUTER_MODEL, DEFAULT_OPENROUTER_FLASH_MODEL], ApiProvider::Novita => vec![DEFAULT_NOVITA_MODEL, DEFAULT_NOVITA_FLASH_MODEL], ApiProvider::Fireworks => vec![DEFAULT_FIREWORKS_MODEL], + ApiProvider::Siliconflow => { + vec![DEFAULT_SILICONFLOW_MODEL, DEFAULT_SILICONFLOW_FLASH_MODEL] + } ApiProvider::Moonshot => vec![DEFAULT_MOONSHOT_MODEL], ApiProvider::WanjieArk => vec![DEFAULT_WANJIE_ARK_MODEL], ApiProvider::Sglang => vec![DEFAULT_SGLANG_MODEL, DEFAULT_SGLANG_FLASH_MODEL], @@ -1345,6 +1362,8 @@ pub struct ProvidersConfig { #[serde(default)] pub fireworks: ProviderConfig, #[serde(default)] + pub siliconflow: ProviderConfig, + #[serde(default)] pub moonshot: ProviderConfig, #[serde(default)] pub sglang: ProviderConfig, @@ -1502,6 +1521,7 @@ impl Config { ApiProvider::Openrouter => "providers.openrouter", ApiProvider::Novita => "providers.novita", ApiProvider::Fireworks => "providers.fireworks", + ApiProvider::Siliconflow => "providers.siliconflow", ApiProvider::Moonshot => "providers.moonshot", ApiProvider::Sglang => "providers.sglang", ApiProvider::Vllm => "providers.vllm", @@ -1522,7 +1542,7 @@ impl Config { && ApiProvider::parse(provider).is_none() { anyhow::bail!( - "Invalid provider '{provider}': expected deepseek, deepseek-cn, nvidia-nim, openai, atlascloud, wanjie-ark, openrouter, novita, fireworks, sglang, vllm, or ollama." + "Invalid provider '{provider}': expected deepseek, deepseek-cn, nvidia-nim, openai, atlascloud, wanjie-ark, openrouter, novita, fireworks, siliconflow, moonshot, sglang, vllm, or ollama." ); } if let Some(ref key) = self.api_key @@ -1644,6 +1664,7 @@ impl Config { ApiProvider::Openrouter => &providers.openrouter, ApiProvider::Novita => &providers.novita, ApiProvider::Fireworks => &providers.fireworks, + ApiProvider::Siliconflow => &providers.siliconflow, ApiProvider::Moonshot => &providers.moonshot, ApiProvider::Sglang => &providers.sglang, ApiProvider::Vllm => &providers.vllm, @@ -1734,6 +1755,7 @@ impl Config { ApiProvider::Openrouter => DEFAULT_OPENROUTER_MODEL, ApiProvider::Novita => DEFAULT_NOVITA_MODEL, ApiProvider::Fireworks => DEFAULT_FIREWORKS_MODEL, + ApiProvider::Siliconflow => DEFAULT_SILICONFLOW_MODEL, ApiProvider::Moonshot => DEFAULT_MOONSHOT_MODEL, ApiProvider::Sglang => DEFAULT_SGLANG_MODEL, ApiProvider::Vllm => DEFAULT_VLLM_MODEL, @@ -1766,6 +1788,7 @@ impl Config { | ApiProvider::Openrouter | ApiProvider::Novita | ApiProvider::Fireworks + | ApiProvider::Siliconflow | ApiProvider::Moonshot | ApiProvider::Sglang | ApiProvider::Vllm @@ -1782,6 +1805,7 @@ impl Config { ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL, ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL, ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL, + ApiProvider::Siliconflow => DEFAULT_SILICONFLOW_BASE_URL, ApiProvider::Moonshot => { if self .provider_config() @@ -1825,6 +1849,7 @@ impl Config { ApiProvider::Openrouter => "openrouter", ApiProvider::Novita => "novita", ApiProvider::Fireworks => "fireworks", + ApiProvider::Siliconflow => "siliconflow", ApiProvider::Moonshot => "moonshot", ApiProvider::Sglang => "sglang", ApiProvider::Vllm => "vllm", @@ -1916,6 +1941,10 @@ impl Config { "Fireworks AI API key not found. Run 'codewhale auth set --provider fireworks', \ set FIREWORKS_API_KEY, or add [providers.fireworks] api_key in ~/.deepseek/config.toml." ), + ApiProvider::Siliconflow => anyhow::bail!( + "SiliconFlow API key not found. Run 'codewhale auth set --provider siliconflow', \ + set SILICONFLOW_API_KEY, or add [providers.siliconflow] api_key in ~/.deepseek/config.toml." + ), ApiProvider::Moonshot => anyhow::bail!( "Moonshot/Kimi API key not found. Run 'codewhale auth set --provider moonshot', \ set MOONSHOT_API_KEY/KIMI_API_KEY, or add [providers.moonshot] api_key. \ @@ -2517,6 +2546,13 @@ fn apply_env_overrides(config: &mut Config) { .fireworks .base_url = Some(value); } + ApiProvider::Siliconflow => { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .siliconflow + .base_url = Some(value); + } ApiProvider::Moonshot => { config .providers @@ -2630,6 +2666,16 @@ fn apply_env_overrides(config: &mut Config) { .fireworks .base_url = Some(value); } + if matches!(config.api_provider(), ApiProvider::Siliconflow) + && let Ok(value) = std::env::var("SILICONFLOW_BASE_URL") + && !value.trim().is_empty() + { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .siliconflow + .base_url = Some(value); + } if matches!(config.api_provider(), ApiProvider::Moonshot) && let Ok(value) = std::env::var("MOONSHOT_BASE_URL").or_else(|_| std::env::var("KIMI_BASE_URL")) @@ -2683,6 +2729,7 @@ fn apply_env_overrides(config: &mut Config) { ApiProvider::Openrouter => &mut providers.openrouter, ApiProvider::Novita => &mut providers.novita, ApiProvider::Fireworks => &mut providers.fireworks, + ApiProvider::Siliconflow => &mut providers.siliconflow, ApiProvider::Moonshot => &mut providers.moonshot, ApiProvider::Sglang => &mut providers.sglang, ApiProvider::Vllm => &mut providers.vllm, @@ -2753,6 +2800,16 @@ fn apply_env_overrides(config: &mut Config) { .moonshot .model = Some(value); } + if matches!(config.api_provider(), ApiProvider::Siliconflow) + && let Ok(value) = std::env::var("SILICONFLOW_MODEL") + && !value.trim().is_empty() + { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .siliconflow + .model = Some(value); + } if let Some(value) = codewhale_env_var("CODEWHALE_MODEL", "DEEPSEEK_MODEL") .ok() .or_else(|| { @@ -2787,6 +2844,7 @@ fn apply_env_overrides(config: &mut Config) { ApiProvider::Openrouter => &mut providers.openrouter, ApiProvider::Novita => &mut providers.novita, ApiProvider::Fireworks => &mut providers.fireworks, + ApiProvider::Siliconflow => &mut providers.siliconflow, ApiProvider::Moonshot => &mut providers.moonshot, ApiProvider::Sglang => &mut providers.sglang, ApiProvider::Vllm => &mut providers.vllm, @@ -3015,6 +3073,15 @@ fn normalize_model_config(config: &mut Config) { { providers.fireworks.model = Some(normalized); } + if let Some(model) = providers.siliconflow.model.as_deref() + && !provider_entry_uses_custom_base_url( + ApiProvider::Siliconflow, + &providers.siliconflow, + ) + && let Some(normalized) = normalize_model_for_provider(ApiProvider::Siliconflow, model) + { + providers.siliconflow.model = Some(normalized); + } if let Some(model) = providers.moonshot.model.as_deref() && !provider_entry_uses_custom_base_url(ApiProvider::Moonshot, &providers.moonshot) && let Some(normalized) = normalize_model_for_provider(ApiProvider::Moonshot, model) @@ -3072,6 +3139,7 @@ fn default_base_url_for_provider(provider: ApiProvider) -> &'static str { ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL, ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL, ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL, + ApiProvider::Siliconflow => DEFAULT_SILICONFLOW_BASE_URL, ApiProvider::Moonshot => DEFAULT_MOONSHOT_BASE_URL, ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL, ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL, @@ -3080,6 +3148,9 @@ fn default_base_url_for_provider(provider: ApiProvider) -> &'static str { } fn base_url_is_custom_for_provider(provider: ApiProvider, base_url: &str) -> bool { + if provider == ApiProvider::Siliconflow && siliconflow_base_url_is_official(base_url) { + return false; + } normalize_base_url(base_url) != normalize_base_url(default_base_url_for_provider(provider)) } @@ -3087,6 +3158,13 @@ fn provider_preserves_custom_base_url_model(provider: ApiProvider, base_url: &st base_url_is_custom_for_provider(provider, base_url) } +fn siliconflow_base_url_is_official(base_url: &str) -> bool { + matches!( + normalize_base_url(base_url).to_ascii_lowercase().as_str(), + "https://api.siliconflow.com/v1" | "https://api.siliconflow.cn/v1" + ) +} + fn moonshot_base_url_uses_kimi_code(base_url: &str) -> bool { let normalized = normalize_base_url(base_url).to_ascii_lowercase(); normalized == DEFAULT_KIMI_CODE_BASE_URL @@ -3151,6 +3229,12 @@ fn model_for_provider(provider: ApiProvider, normalized: String) -> String { // Flash not yet available on Fireworks; fall through to normalized name "accounts/fireworks/models/deepseek-v4-flash".to_string() } + (ApiProvider::Siliconflow, "deepseek-v4-pro" | "deepseek-reasoner" | "deepseek-r1") => { + DEFAULT_SILICONFLOW_MODEL.to_string() + } + (ApiProvider::Siliconflow, "deepseek-v4-flash" | "deepseek-chat" | "deepseek-v3") => { + DEFAULT_SILICONFLOW_FLASH_MODEL.to_string() + } (ApiProvider::Sglang, "deepseek-v4-pro") => DEFAULT_SGLANG_MODEL.to_string(), (ApiProvider::Sglang, "deepseek-v4-flash") => DEFAULT_SGLANG_FLASH_MODEL.to_string(), (ApiProvider::Vllm, "deepseek-v4-pro") => DEFAULT_VLLM_MODEL.to_string(), @@ -3329,6 +3413,7 @@ fn merge_providers( openrouter: merge_provider_config(base.openrouter, override_cfg.openrouter), novita: merge_provider_config(base.novita, override_cfg.novita), fireworks: merge_provider_config(base.fireworks, override_cfg.fireworks), + siliconflow: merge_provider_config(base.siliconflow, override_cfg.siliconflow), moonshot: merge_provider_config(base.moonshot, override_cfg.moonshot), sglang: merge_provider_config(base.sglang, override_cfg.sglang), vllm: merge_provider_config(base.vllm, override_cfg.vllm), @@ -3751,6 +3836,9 @@ pub fn active_provider_has_env_api_key(config: &Config) -> bool { ApiProvider::Fireworks => { std::env::var("FIREWORKS_API_KEY").is_ok_and(|k| !k.trim().is_empty()) } + ApiProvider::Siliconflow => { + std::env::var("SILICONFLOW_API_KEY").is_ok_and(|k| !k.trim().is_empty()) + } ApiProvider::Moonshot => { std::env::var("MOONSHOT_API_KEY").is_ok_and(|k| !k.trim().is_empty()) || std::env::var("KIMI_API_KEY").is_ok_and(|k| !k.trim().is_empty()) @@ -3780,6 +3868,7 @@ pub fn has_api_key_for(config: &Config, provider: ApiProvider) -> bool { ApiProvider::Openrouter => "OPENROUTER_API_KEY", ApiProvider::Novita => "NOVITA_API_KEY", ApiProvider::Fireworks => "FIREWORKS_API_KEY", + ApiProvider::Siliconflow => "SILICONFLOW_API_KEY", ApiProvider::Moonshot => "MOONSHOT_API_KEY", ApiProvider::Sglang => "SGLANG_API_KEY", ApiProvider::Vllm => "VLLM_API_KEY", @@ -3874,6 +3963,7 @@ pub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result ApiProvider::Openrouter => "providers.openrouter", ApiProvider::Novita => "providers.novita", ApiProvider::Fireworks => "providers.fireworks", + ApiProvider::Siliconflow => "providers.siliconflow", ApiProvider::Moonshot => "providers.moonshot", ApiProvider::Sglang => "providers.sglang", ApiProvider::Vllm => "providers.vllm", @@ -3911,6 +4001,7 @@ pub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result ApiProvider::Openrouter => "openrouter", ApiProvider::Novita => "novita", ApiProvider::Fireworks => "fireworks", + ApiProvider::Siliconflow => "siliconflow", ApiProvider::Moonshot => "moonshot", ApiProvider::Sglang => "sglang", ApiProvider::Vllm => "vllm", @@ -4000,6 +4091,7 @@ fn provider_config_key(provider: ApiProvider) -> Result<&'static str> { ApiProvider::Openrouter => Ok("openrouter"), ApiProvider::Novita => Ok("novita"), ApiProvider::Fireworks => Ok("fireworks"), + ApiProvider::Siliconflow => Ok("siliconflow"), ApiProvider::Moonshot => Ok("moonshot"), ApiProvider::Sglang => Ok("sglang"), ApiProvider::Vllm => Ok("vllm"), @@ -4394,6 +4486,9 @@ mod tests { novita_base_url: Option, fireworks_api_key: Option, fireworks_base_url: Option, + siliconflow_api_key: Option, + siliconflow_base_url: Option, + siliconflow_model: Option, moonshot_api_key: Option, moonshot_base_url: Option, moonshot_model: Option, @@ -4459,6 +4554,9 @@ mod tests { let novita_base_url_prev = env::var_os("NOVITA_BASE_URL"); let fireworks_api_key_prev = env::var_os("FIREWORKS_API_KEY"); let fireworks_base_url_prev = env::var_os("FIREWORKS_BASE_URL"); + let siliconflow_api_key_prev = env::var_os("SILICONFLOW_API_KEY"); + let siliconflow_base_url_prev = env::var_os("SILICONFLOW_BASE_URL"); + let siliconflow_model_prev = env::var_os("SILICONFLOW_MODEL"); let moonshot_api_key_prev = env::var_os("MOONSHOT_API_KEY"); let moonshot_base_url_prev = env::var_os("MOONSHOT_BASE_URL"); let moonshot_model_prev = env::var_os("MOONSHOT_MODEL"); @@ -4519,6 +4617,9 @@ mod tests { env::remove_var("NOVITA_BASE_URL"); env::remove_var("FIREWORKS_API_KEY"); env::remove_var("FIREWORKS_BASE_URL"); + env::remove_var("SILICONFLOW_API_KEY"); + env::remove_var("SILICONFLOW_BASE_URL"); + env::remove_var("SILICONFLOW_MODEL"); env::remove_var("MOONSHOT_API_KEY"); env::remove_var("MOONSHOT_BASE_URL"); env::remove_var("MOONSHOT_MODEL"); @@ -4579,6 +4680,9 @@ mod tests { novita_base_url: novita_base_url_prev, fireworks_api_key: fireworks_api_key_prev, fireworks_base_url: fireworks_base_url_prev, + siliconflow_api_key: siliconflow_api_key_prev, + siliconflow_base_url: siliconflow_base_url_prev, + siliconflow_model: siliconflow_model_prev, moonshot_api_key: moonshot_api_key_prev, moonshot_base_url: moonshot_base_url_prev, moonshot_model: moonshot_model_prev, @@ -4648,6 +4752,9 @@ mod tests { Self::restore_var("NOVITA_BASE_URL", self.novita_base_url.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("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()); Self::restore_var("MOONSHOT_API_KEY", self.moonshot_api_key.take()); Self::restore_var("MOONSHOT_BASE_URL", self.moonshot_base_url.take()); Self::restore_var("MOONSHOT_MODEL", self.moonshot_model.take()); @@ -4990,6 +5097,9 @@ mod tests { "fireworks" => { providers.fireworks.api_key = Some(api_key.to_string()); } + "siliconflow" => { + providers.siliconflow.api_key = Some(api_key.to_string()); + } "sglang" => { providers.sglang.api_key = Some(api_key.to_string()); } @@ -5011,7 +5121,14 @@ mod tests { #[test] fn has_api_key_uses_active_provider_scoped_config_key() { - for provider in ["openai", "wanjie-ark", "openrouter", "novita", "fireworks"] { + for provider in [ + "openai", + "wanjie-ark", + "openrouter", + "novita", + "fireworks", + "siliconflow", + ] { let config = config_with_provider_scoped_key(provider, "provider-config-key"); assert!( @@ -5030,6 +5147,7 @@ mod tests { ("openrouter", "OPENROUTER_API_KEY"), ("novita", "NOVITA_API_KEY"), ("fireworks", "FIREWORKS_API_KEY"), + ("siliconflow", "SILICONFLOW_API_KEY"), ] { unsafe { std::env::set_var(env_var, "provider-env-key"); @@ -5541,6 +5659,32 @@ api_key = "old-openrouter-key" .as_deref(), Some(DEFAULT_OPENROUTER_FLASH_MODEL) ); + assert_eq!( + normalize_model_name_for_provider(ApiProvider::Siliconflow, "deepseek-v4-pro") + .as_deref(), + Some(DEFAULT_SILICONFLOW_MODEL) + ); + assert_eq!( + normalize_model_name_for_provider(ApiProvider::Siliconflow, "deepseek-reasoner") + .as_deref(), + Some(DEFAULT_SILICONFLOW_MODEL) + ); + assert_eq!( + normalize_model_name_for_provider(ApiProvider::Siliconflow, "deepseek-r1").as_deref(), + Some(DEFAULT_SILICONFLOW_MODEL) + ); + assert_eq!( + normalize_model_name_for_provider(ApiProvider::Siliconflow, "deepseek-chat").as_deref(), + Some(DEFAULT_SILICONFLOW_FLASH_MODEL) + ); + assert_eq!( + normalize_model_name_for_provider(ApiProvider::Siliconflow, "deepseek-v3").as_deref(), + Some(DEFAULT_SILICONFLOW_FLASH_MODEL) + ); + assert_eq!( + normalize_model_name_for_provider(ApiProvider::Siliconflow, "deepseek-v3.2").as_deref(), + Some("deepseek-v3.2") + ); } #[test] @@ -6356,6 +6500,36 @@ model = "glm-5" Ok(()) } + #[test] + fn siliconflow_provider_uses_canonical_defaults() -> Result<()> { + let _lock = lock_test_env(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "codewhale-tui-siliconflow-defaults-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root)?; + let _guard = EnvGuard::new(&temp_root); + + let config = Config { + provider: Some("siliconflow".to_string()), + ..Default::default() + }; + config.validate()?; + assert_eq!(config.api_provider(), ApiProvider::Siliconflow); + assert_eq!(config.default_model(), DEFAULT_SILICONFLOW_MODEL); + assert_eq!(config.deepseek_base_url(), DEFAULT_SILICONFLOW_BASE_URL); + assert_eq!( + model_completion_names_for_provider(ApiProvider::Siliconflow), + vec![DEFAULT_SILICONFLOW_MODEL, DEFAULT_SILICONFLOW_FLASH_MODEL] + ); + Ok(()) + } + #[test] fn sglang_provider_works_without_api_key() -> Result<()> { let _lock = lock_test_env(); @@ -6593,6 +6767,68 @@ model = "qwen2.5-coder:7b" Ok(()) } + #[test] + fn siliconflow_env_overrides_key_base_url_and_model() -> Result<()> { + let _lock = lock_test_env(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "codewhale-tui-siliconflow-env-test-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root)?; + let _guard = EnvGuard::new(&temp_root); + + // Safety: test-only environment mutation guarded by a global mutex. + unsafe { + env::set_var("CODEWHALE_PROVIDER", "siliconflow"); + env::set_var("SILICONFLOW_API_KEY", "sf-env-key"); + env::set_var("SILICONFLOW_BASE_URL", "https://sf-mirror.example/v1"); + env::set_var("SILICONFLOW_MODEL", "deepseek-v4-flash"); + } + + let config = Config::load(None, None)?; + assert_eq!(config.api_provider(), ApiProvider::Siliconflow); + assert_eq!(config.deepseek_api_key()?, "sf-env-key"); + assert_eq!(config.deepseek_base_url(), "https://sf-mirror.example/v1"); + assert_eq!(config.default_model(), "deepseek-v4-flash"); + Ok(()) + } + + #[test] + fn siliconflow_cn_base_url_env_normalizes_model_aliases() -> Result<()> { + let _lock = lock_test_env(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "codewhale-tui-siliconflow-cn-env-test-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root)?; + let _guard = EnvGuard::new(&temp_root); + + // Safety: test-only environment mutation guarded by a global mutex. + unsafe { + env::set_var("CODEWHALE_PROVIDER", "siliconflow"); + env::set_var("SILICONFLOW_API_KEY", "sf-env-key"); + env::set_var("SILICONFLOW_BASE_URL", "https://api.siliconflow.cn/v1"); + env::set_var("SILICONFLOW_MODEL", "deepseek-reasoner"); + } + + let config = Config::load(None, None)?; + assert_eq!(config.api_provider(), ApiProvider::Siliconflow); + assert_eq!(config.deepseek_api_key()?, "sf-env-key"); + assert_eq!(config.deepseek_base_url(), "https://api.siliconflow.cn/v1"); + assert_eq!(config.default_model(), DEFAULT_SILICONFLOW_MODEL); + Ok(()) + } + #[test] fn openrouter_base_url_env_overrides_default() -> Result<()> { let _lock = lock_test_env(); @@ -6654,6 +6890,41 @@ base_url = "https://or-table.example/v1" Ok(()) } + #[test] + fn siliconflow_reads_provider_table_from_config_file() -> Result<()> { + let _lock = lock_test_env(); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "codewhale-tui-siliconflow-table-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root)?; + let _guard = EnvGuard::new(&temp_root); + + let config_path = temp_root.join(".deepseek").join("config.toml"); + ensure_parent_dir(&config_path)?; + fs::write( + &config_path, + r#"provider = "siliconflow" + +[providers.siliconflow] +api_key = "sf-table-key" +model = "deepseek-v4-flash" +"#, + )?; + + let config = Config::load(None, None)?; + assert_eq!(config.api_provider(), ApiProvider::Siliconflow); + assert_eq!(config.deepseek_api_key()?, "sf-table-key"); + assert_eq!(config.deepseek_base_url(), DEFAULT_SILICONFLOW_BASE_URL); + assert_eq!(config.default_model(), DEFAULT_SILICONFLOW_FLASH_MODEL); + Ok(()) + } + #[test] fn openrouter_custom_base_url_preserves_provider_model() -> Result<()> { let _lock = lock_test_env(); @@ -7033,6 +7304,7 @@ api_key = "moonshot-platform-key" assert!(!has_api_key_for(&config, ApiProvider::Openai)); assert!(!has_api_key_for(&config, ApiProvider::WanjieArk)); assert!(!has_api_key_for(&config, ApiProvider::Openrouter)); + assert!(!has_api_key_for(&config, ApiProvider::Siliconflow)); assert!( has_api_key_for(&config, ApiProvider::Sglang), "SGLang is self-hosted and does not require a key by default" @@ -7047,10 +7319,12 @@ api_key = "moonshot-platform-key" env::set_var("OPENROUTER_API_KEY", "or-env"); env::set_var("OPENAI_API_KEY", "openai-env"); env::set_var("WANJIE_API_KEY", "wanjie-env"); + env::set_var("SILICONFLOW_API_KEY", "sf-env"); } assert!(has_api_key_for(&config, ApiProvider::Openai)); assert!(has_api_key_for(&config, ApiProvider::WanjieArk)); assert!(has_api_key_for(&config, ApiProvider::Openrouter)); + assert!(has_api_key_for(&config, ApiProvider::Siliconflow)); assert!(!has_api_key_for(&config, ApiProvider::Novita)); // Safety: test-only environment mutation guarded by a global mutex. @@ -7058,15 +7332,18 @@ api_key = "moonshot-platform-key" env::remove_var("OPENROUTER_API_KEY"); env::remove_var("OPENAI_API_KEY"); env::remove_var("WANJIE_API_KEY"); + env::remove_var("SILICONFLOW_API_KEY"); } let mut providers = ProvidersConfig::default(); providers.openai.api_key = Some("file-openai".to_string()); providers.wanjie_ark.api_key = Some("file-wanjie".to_string()); providers.novita.api_key = Some("file-novita".to_string()); + providers.siliconflow.api_key = Some("file-siliconflow".to_string()); config.providers = Some(providers); assert!(has_api_key_for(&config, ApiProvider::Openai)); assert!(has_api_key_for(&config, ApiProvider::WanjieArk)); assert!(has_api_key_for(&config, ApiProvider::Novita)); + assert!(has_api_key_for(&config, ApiProvider::Siliconflow)); assert!(!has_api_key_for(&config, ApiProvider::Openrouter)); Ok(()) } @@ -7158,6 +7435,7 @@ api_key = "moonshot-platform-key" save_api_key_for(ApiProvider::Openai, "openai-saved-key")?; save_api_key_for(ApiProvider::WanjieArk, "wanjie-saved-key")?; save_api_key_for(ApiProvider::Fireworks, "fireworks-saved-key")?; + save_api_key_for(ApiProvider::Siliconflow, "sf-saved-key")?; save_api_key_for(ApiProvider::Sglang, "sglang-saved-key")?; let contents = fs::read_to_string(&path)?; let parsed: toml::Value = toml::from_str(&contents)?; @@ -7185,6 +7463,14 @@ api_key = "moonshot-platform-key" .and_then(toml::Value::as_str), Some("fireworks-saved-key") ); + assert_eq!( + parsed + .get("providers") + .and_then(|p| p.get("siliconflow")) + .and_then(|t| t.get("api_key")) + .and_then(toml::Value::as_str), + Some("sf-saved-key") + ); assert_eq!( parsed .get("providers") @@ -7444,6 +7730,22 @@ model = "deepseek-ai/deepseek-v4-pro" assert!(!cap.cache_telemetry_supported); } + #[test] + fn provider_capability_siliconflow_v4_pro_has_thinking_no_cache() { + let cap = provider_capability(ApiProvider::Siliconflow, DEFAULT_SILICONFLOW_MODEL); + assert_eq!( + cap.context_window, + crate::models::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS + ); + assert_eq!(cap.max_output, 384_000); + assert!(cap.thinking_supported); + assert!(!cap.cache_telemetry_supported); + assert_eq!( + cap.request_payload_mode, + RequestPayloadMode::ChatCompletions + ); + } + #[test] fn provider_capability_sglang_v4_pro_has_thinking_no_cache() { let cap = provider_capability(ApiProvider::Sglang, DEFAULT_SGLANG_MODEL); diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 02737eb7a..2d3119f5d 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -389,6 +389,7 @@ impl Engine { ApiProvider::Openrouter => "OPENROUTER_API_KEY", ApiProvider::Novita => "NOVITA_API_KEY", ApiProvider::Fireworks => "FIREWORKS_API_KEY", + ApiProvider::Siliconflow => "SILICONFLOW_API_KEY", ApiProvider::Moonshot => "MOONSHOT_API_KEY/KIMI_API_KEY", ApiProvider::Sglang => "SGLANG_API_KEY", ApiProvider::Vllm => "VLLM_API_KEY", diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 99579eac3..e30b2ee6c 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -1874,6 +1874,10 @@ fn run_setup_status(config: &Config, workspace: &Path) -> Result<()> { "FIREWORKS_API_KEY", "codewhale auth set --provider fireworks --api-key \"...\"", ), + crate::config::ApiProvider::Siliconflow => ( + "SILICONFLOW_API_KEY", + "codewhale auth set --provider siliconflow --api-key \"...\"", + ), crate::config::ApiProvider::Moonshot => ( "MOONSHOT_API_KEY/KIMI_API_KEY", "codewhale auth set --provider moonshot --api-key \"...\"", @@ -1904,6 +1908,7 @@ fn run_setup_status(config: &Config, workspace: &Path) -> Result<()> { crate::config::ApiProvider::Openrouter => "openrouter", crate::config::ApiProvider::Novita => "novita", crate::config::ApiProvider::Fireworks => "fireworks", + crate::config::ApiProvider::Siliconflow => "siliconflow", crate::config::ApiProvider::Moonshot => "moonshot", crate::config::ApiProvider::Sglang => "sglang", crate::config::ApiProvider::Vllm => "vllm", @@ -2173,6 +2178,11 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt "fireworks", &["FIREWORKS_API_KEY"][..], ), + ( + crate::config::ApiProvider::Siliconflow, + "siliconflow", + &["SILICONFLOW_API_KEY"][..], + ), ( crate::config::ApiProvider::Moonshot, "moonshot", diff --git a/crates/tui/src/runtime_log.rs b/crates/tui/src/runtime_log.rs index 48373d4b8..2edd53adf 100644 --- a/crates/tui/src/runtime_log.rs +++ b/crates/tui/src/runtime_log.rs @@ -174,7 +174,7 @@ fn log_directory() -> Option { { return resolve(userprofile); } - dirs::home_dir().and_then(|h| resolve(h)) + dirs::home_dir().and_then(resolve) } fn log_file_name(date: &str, pid: u32) -> String { diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 3c49df0ed..9269a9f07 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -5314,8 +5314,37 @@ mod tests { #[test] fn app_new_detects_missing_api_key_with_default_config() { - // Config::default() carries no api_key and the test runner - // should not have DEEPSEEK_API_KEY in its environment. + let _lock = lock_test_env(); + let tmp = tempfile::TempDir::new().expect("tempdir"); + let config_path = tmp.path().join("config.toml"); + let _config_path = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path); + let _provider_env = EnvVarGuard::remove("CODEWHALE_PROVIDER"); + let _legacy_provider_env = EnvVarGuard::remove("DEEPSEEK_PROVIDER"); + let _api_key_envs: Vec<_> = [ + "DEEPSEEK_API_KEY", + "NVIDIA_API_KEY", + "NVIDIA_NIM_API_KEY", + "OPENAI_API_KEY", + "ATLASCLOUD_API_KEY", + "WANJIE_ARK_API_KEY", + "WANJIE_API_KEY", + "WANJIE_MAAS_API_KEY", + "OPENROUTER_API_KEY", + "NOVITA_API_KEY", + "FIREWORKS_API_KEY", + "SILICONFLOW_API_KEY", + "MOONSHOT_API_KEY", + "KIMI_API_KEY", + "SGLANG_API_KEY", + "VLLM_API_KEY", + "OLLAMA_API_KEY", + ] + .into_iter() + .map(EnvVarGuard::remove) + .collect(); + + // Config::default() carries no api_key, and this test isolates process + // env/settings so previous tests or developer shells cannot satisfy it. let app = App::new(test_options(false), &Config::default()); assert!( app.onboarding_needs_api_key, diff --git a/crates/tui/src/tui/provider_picker.rs b/crates/tui/src/tui/provider_picker.rs index a4cdfb6cc..a65438b7b 100644 --- a/crates/tui/src/tui/provider_picker.rs +++ b/crates/tui/src/tui/provider_picker.rs @@ -104,6 +104,7 @@ impl ProviderPickerView { ApiProvider::Openrouter => "OPENROUTER_API_KEY", ApiProvider::Novita => "NOVITA_API_KEY", ApiProvider::Fireworks => "FIREWORKS_API_KEY", + ApiProvider::Siliconflow => "SILICONFLOW_API_KEY", ApiProvider::Moonshot => "MOONSHOT_API_KEY / KIMI_API_KEY", ApiProvider::Sglang => "SGLANG_API_KEY", ApiProvider::Vllm => "VLLM_API_KEY", @@ -475,6 +476,7 @@ mod tests { "OpenRouter", "Novita AI", "Fireworks AI", + "SiliconFlow", "Moonshot/Kimi", "SGLang", "vLLM", diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index fb89de619..462483447 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -5750,6 +5750,7 @@ fn render(f: &mut Frame, app: &mut App) { crate::config::ApiProvider::Openrouter => Some("OR"), crate::config::ApiProvider::Novita => Some("Novita"), crate::config::ApiProvider::Fireworks => Some("Fireworks"), + crate::config::ApiProvider::Siliconflow => Some("SiliconFlow"), crate::config::ApiProvider::Moonshot => Some("Kimi"), crate::config::ApiProvider::Sglang => Some("SGLang"), crate::config::ApiProvider::Vllm => Some("vLLM"), @@ -6650,6 +6651,7 @@ async fn apply_provider_picker_api_key( ApiProvider::Openrouter => &mut providers.openrouter, ApiProvider::Novita => &mut providers.novita, ApiProvider::Fireworks => &mut providers.fireworks, + ApiProvider::Siliconflow => &mut providers.siliconflow, ApiProvider::Moonshot => &mut providers.moonshot, ApiProvider::Sglang => &mut providers.sglang, ApiProvider::Vllm => &mut providers.vllm, @@ -6702,6 +6704,7 @@ fn set_provider_auth_mode_in_memory(config: &mut Config, provider: ApiProvider, ApiProvider::Openrouter => &mut providers.openrouter, ApiProvider::Novita => &mut providers.novita, ApiProvider::Fireworks => &mut providers.fireworks, + ApiProvider::Siliconflow => &mut providers.siliconflow, ApiProvider::Moonshot => &mut providers.moonshot, ApiProvider::Sglang => &mut providers.sglang, ApiProvider::Vllm => &mut providers.vllm, diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index f0b75de0b..fa499b665 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -63,8 +63,8 @@ provider's keyring entry. For hosted, generic OpenAI-compatible, or self-hosted providers, set `provider = "nvidia-nim"`, `"openai"`, `"atlascloud"`, `"wanjie-ark"`, -`"openrouter"`, `"novita"`, `"fireworks"`, `"moonshot"`, `"sglang"`, -`"vllm"`, or `"ollama"` or pass `codewhale --provider `. +`"openrouter"`, `"novita"`, `"fireworks"`, `"siliconflow"`, `"moonshot"`, +`"sglang"`, `"vllm"`, or `"ollama"` or pass `codewhale --provider `. For the provider-by-provider registry, including auth variables, default base URLs, model IDs, and capability metadata, see [PROVIDERS.md](PROVIDERS.md). The facade saves provider credentials to the shared user config and forwards @@ -73,7 +73,8 @@ the resolved key, base URL, provider, and model to the TUI process. Use `codewhale auth set --provider openai --api-key "YOUR_OPENAI_COMPATIBLE_API_KEY"` or `codewhale auth set --provider atlascloud --api-key "YOUR_ATLASCLOUD_API_KEY"` or `codewhale auth set --provider wanjie-ark --api-key "YOUR_WANJIE_API_KEY"` or -`codewhale auth set --provider fireworks --api-key "YOUR_FIREWORKS_API_KEY"` +`codewhale auth set --provider fireworks --api-key "YOUR_FIREWORKS_API_KEY"` or +`codewhale auth set --provider siliconflow --api-key "YOUR_SILICONFLOW_API_KEY"` to save provider keys through the facade. The generic `openai` provider defaults to `https://api.openai.com/v1`, accepts `OPENAI_BASE_URL`, and defaults to `deepseek-v4-pro` for OpenAI-compatible gateways. `atlascloud` defaults to @@ -89,6 +90,10 @@ or `qwen2.5-coder:7b` unchanged. Self-hosted providers and loopback custom URLs (`localhost`, `127.0.0.1`, `[::1]`, `0.0.0.0`) do not read the secret store unless API-key auth is explicitly requested; use an env var or config-file key when a local server does require bearer auth. +SiliconFlow defaults to `https://api.siliconflow.com/v1`, accepts +`SILICONFLOW_BASE_URL`, and uses `deepseek-ai/DeepSeek-V4-Pro` by default. +`https://api.siliconflow.cn/v1` can still be configured explicitly when a user +needs the regional endpoint. ### Custom OpenAI-Compatible Gateways @@ -160,6 +165,13 @@ default_text_model = "deepseek-ai/deepseek-v4-pro" provider = "fireworks" default_text_model = "accounts/fireworks/models/deepseek-v4-pro" +[profiles.siliconflow] +provider = "siliconflow" +default_text_model = "deepseek-ai/DeepSeek-V4-Pro" + +[profiles.siliconflow.providers.siliconflow] +base_url = "https://api.siliconflow.com/v1" + [profiles.openai-compatible] provider = "openai" @@ -236,6 +248,9 @@ Remaining variables: - `NOVITA_BASE_URL` - `FIREWORKS_API_KEY` - `FIREWORKS_BASE_URL` +- `SILICONFLOW_API_KEY` +- `SILICONFLOW_BASE_URL` +- `SILICONFLOW_MODEL` - `MOONSHOT_API_KEY` or `KIMI_API_KEY` - `MOONSHOT_BASE_URL` or `KIMI_BASE_URL` - `MOONSHOT_MODEL`, `KIMI_MODEL_NAME`, or `KIMI_MODEL` @@ -441,10 +456,10 @@ If you are upgrading from older releases: ### Core keys (used by the TUI/engine) -- `provider` (string, optional): `deepseek` (default), `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `openrouter`, `novita`, `fireworks`, `moonshot`, `sglang`, `vllm`, or `ollama`. Legacy `deepseek-cn` configs are still accepted as an alias for `deepseek`; DeepSeek uses the same official host [`https://api.deepseek.com`](https://api-docs.deepseek.com/) worldwide. `nvidia-nim` targets NVIDIA's NIM-hosted DeepSeek endpoints through `https://integrate.api.nvidia.com/v1`; `openai` targets a generic OpenAI-compatible endpoint, defaulting to `https://api.openai.com/v1`; `atlascloud` targets AtlasCloud's OpenAI-compatible endpoint at `https://api.atlascloud.ai/v1`; `wanjie-ark` targets Wanjie Ark's OpenAI-compatible endpoint at `https://maas-openapi.wanjiedata.com/api/v1`; `openrouter` targets `https://openrouter.ai/api/v1`; `novita` targets `https://api.novita.ai/v1`; `fireworks` targets `https://api.fireworks.ai/inference/v1`; `moonshot` targets Moonshot/Kimi, defaulting to `https://api.moonshot.ai/v1`; `sglang` targets a self-hosted OpenAI-compatible endpoint, defaulting to `http://localhost:30000/v1`; `vllm` targets a self-hosted vLLM OpenAI-compatible endpoint, defaulting to `http://localhost:8000/v1`; `ollama` targets Ollama's OpenAI-compatible endpoint, defaulting to `http://localhost:11434/v1`. +- `provider` (string, optional): `deepseek` (default), `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `openrouter`, `novita`, `fireworks`, `siliconflow`, `moonshot`, `sglang`, `vllm`, or `ollama`. Legacy `deepseek-cn` configs are still accepted as an alias for `deepseek`; DeepSeek uses the same official host [`https://api.deepseek.com`](https://api-docs.deepseek.com/) worldwide. `nvidia-nim` targets NVIDIA's NIM-hosted DeepSeek endpoints through `https://integrate.api.nvidia.com/v1`; `openai` targets a generic OpenAI-compatible endpoint, defaulting to `https://api.openai.com/v1`; `atlascloud` targets AtlasCloud's OpenAI-compatible endpoint at `https://api.atlascloud.ai/v1`; `wanjie-ark` targets Wanjie Ark's OpenAI-compatible endpoint at `https://maas-openapi.wanjiedata.com/api/v1`; `openrouter` targets `https://openrouter.ai/api/v1`; `novita` targets `https://api.novita.ai/v1`; `fireworks` targets `https://api.fireworks.ai/inference/v1`; `siliconflow` targets SiliconFlow, defaulting to `https://api.siliconflow.com/v1`; `moonshot` targets Moonshot/Kimi, defaulting to `https://api.moonshot.ai/v1`; `sglang` targets a self-hosted OpenAI-compatible endpoint, defaulting to `http://localhost:30000/v1`; `vllm` targets a self-hosted vLLM OpenAI-compatible endpoint, defaulting to `http://localhost:8000/v1`; `ollama` targets Ollama's OpenAI-compatible endpoint, defaulting to `http://localhost:11434/v1`. - `api_key` (string, required for hosted providers): must be non-empty for DeepSeek/hosted providers (or set the provider API key env var). Self-hosted SGLang, vLLM, and Ollama can omit it. -- `base_url` (string, optional): defaults to `https://api.deepseek.com/beta` for DeepSeek's OpenAI-compatible Chat Completions API, including legacy `provider = "deepseek-cn"` configs. Other defaults are `https://integrate.api.nvidia.com/v1` for `nvidia-nim`, `https://api.openai.com/v1` for `openai`, `https://api.atlascloud.ai/v1` for `atlascloud`, `https://maas-openapi.wanjiedata.com/api/v1` for `wanjie-ark`, `https://openrouter.ai/api/v1` for `openrouter`, `https://api.novita.ai/v1` for `novita`, `https://api.fireworks.ai/inference/v1` for `fireworks`, `https://api.moonshot.ai/v1` for `moonshot`, `http://localhost:30000/v1` for `sglang`, `http://localhost:8000/v1` for `vllm`, and `http://localhost:11434/v1` for `ollama`. Set `https://api.deepseek.com` or `https://api.deepseek.com/v1` explicitly to opt out of DeepSeek beta features. -- `default_text_model` (string, optional): defaults to `deepseek-v4-pro` for DeepSeek and generic OpenAI-compatible endpoints, `deepseek-ai/deepseek-v4-pro` for NVIDIA NIM, `deepseek-ai/deepseek-v4-flash` for AtlasCloud, `deepseek-reasoner` for Wanjie Ark, `deepseek/deepseek-v4-pro` for OpenRouter and Novita, `accounts/fireworks/models/deepseek-v4-pro` for Fireworks, `kimi-k2.6` for Moonshot, `deepseek-ai/DeepSeek-V4-Pro` for SGLang/vLLM, and `deepseek-coder:1.3b` for Ollama. Current public DeepSeek IDs are `deepseek-v4-pro` and `deepseek-v4-flash`, both with 1M context windows, 384K max output, and thinking mode enabled by default. Legacy `deepseek-chat` and `deepseek-reasoner` remain compatibility aliases for `deepseek-v4-flash` until July 24, 2026. Provider-specific mappings translate `deepseek-v4-pro` / `deepseek-v4-flash` to each provider's model ID where supported. Generic `openai`, `atlascloud`, `wanjie-ark`, and Ollama model IDs are passed through unchanged. OpenRouter provider configs with a custom `base_url` also preserve explicit model values, which lets OpenAI-compatible gateways accept bare model IDs. Use `/models` or `codewhale models` to discover live IDs from your configured endpoint. `CODEWHALE_MODEL` overrides this for a single process; `DEEPSEEK_MODEL` is the legacy alias. +- `base_url` (string, optional): defaults to `https://api.deepseek.com/beta` for DeepSeek's OpenAI-compatible Chat Completions API, including legacy `provider = "deepseek-cn"` configs. Other defaults are `https://integrate.api.nvidia.com/v1` for `nvidia-nim`, `https://api.openai.com/v1` for `openai`, `https://api.atlascloud.ai/v1` for `atlascloud`, `https://maas-openapi.wanjiedata.com/api/v1` for `wanjie-ark`, `https://openrouter.ai/api/v1` for `openrouter`, `https://api.novita.ai/v1` for `novita`, `https://api.fireworks.ai/inference/v1` for `fireworks`, `https://api.siliconflow.com/v1` for `siliconflow`, `https://api.moonshot.ai/v1` for `moonshot`, `http://localhost:30000/v1` for `sglang`, `http://localhost:8000/v1` for `vllm`, and `http://localhost:11434/v1` for `ollama`. Set `https://api.deepseek.com` or `https://api.deepseek.com/v1` explicitly to opt out of DeepSeek beta features. +- `default_text_model` (string, optional): defaults to `deepseek-v4-pro` for DeepSeek and generic OpenAI-compatible endpoints, `deepseek-ai/deepseek-v4-pro` for NVIDIA NIM, `deepseek-ai/deepseek-v4-flash` for AtlasCloud, `deepseek-reasoner` for Wanjie Ark, `deepseek/deepseek-v4-pro` for OpenRouter and Novita, `accounts/fireworks/models/deepseek-v4-pro` for Fireworks, `deepseek-ai/DeepSeek-V4-Pro` for SiliconFlow, `kimi-k2.6` for Moonshot, `deepseek-ai/DeepSeek-V4-Pro` for SGLang/vLLM, and `deepseek-coder:1.3b` for Ollama. Current public DeepSeek IDs are `deepseek-v4-pro` and `deepseek-v4-flash`, both with 1M context windows, 384K max output, and thinking mode enabled by default. Legacy `deepseek-chat` and `deepseek-reasoner` remain compatibility aliases for `deepseek-v4-flash` until July 24, 2026, except SiliconFlow maps `deepseek-reasoner` and `deepseek-r1` to its Pro model while `deepseek-chat` and `deepseek-v3` map to Flash. Provider-specific mappings translate `deepseek-v4-pro` / `deepseek-v4-flash` to each provider's model ID where supported. Generic `openai`, `atlascloud`, `wanjie-ark`, and Ollama model IDs are passed through unchanged. OpenRouter provider configs with a custom `base_url` also preserve explicit model values, which lets OpenAI-compatible gateways accept bare model IDs. Use `/models` or `codewhale models` to discover live IDs from your configured endpoint. `CODEWHALE_MODEL` overrides this for a single process; `DEEPSEEK_MODEL` is the legacy alias. - `reasoning_effort` (string, optional): `off`, `low`, `medium`, `high`, or `max`; defaults to the configured UI tier. DeepSeek Platform receives top-level `thinking` / `reasoning_effort` fields. NVIDIA NIM receives equivalent settings through `chat_template_kwargs`. - `allow_shell` (bool, optional): defaults to `true` (sandboxed). - `approval_policy` (string, optional): `on-request`, `untrusted`, or `never`. Runtime `approval_mode` editing in `/config` also accepts `on-request` and `untrusted` aliases. diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 38969415f..1e2751bff 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -6,11 +6,11 @@ limited to provider IDs, config keys, auth paths, base URLs, model resolution, and capability metadata that the code already knows about. DeepSeek remains the first-class default provider. NVIDIA NIM, OpenRouter, -Novita, Fireworks, 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. +Novita, Fireworks, SiliconFlow, 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. Sources to keep in sync: @@ -27,7 +27,8 @@ Sources to keep in sync: The canonical provider IDs are: `deepseek`, `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `openrouter`, -`novita`, `fireworks`, `moonshot`, `sglang`, `vllm`, and `ollama`. +`novita`, `fireworks`, `siliconflow`, `moonshot`, `sglang`, `vllm`, and +`ollama`. Use any of these surfaces to select a provider: @@ -73,6 +74,7 @@ self-hosted runtimes. | `openrouter` | `[providers.openrouter]` | `OPENROUTER_API_KEY` | `OPENROUTER_BASE_URL`; default `https://openrouter.ai/api/v1` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash` | Additive open-model routing layer. It does not replace DeepSeek; it lets users route supported model IDs through OpenRouter when they choose it. | | `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; users who need the regional endpoint can set `https://api.siliconflow.cn/v1` explicitly. `SILICONFLOW_MODEL` is accepted. Reasoning aliases `deepseek-reasoner` and `deepseek-r1` map to Pro; `deepseek-chat` and `deepseek-v3` map to Flash. | | `moonshot` | `[providers.moonshot]` | `MOONSHOT_API_KEY`, `KIMI_API_KEY` | `MOONSHOT_BASE_URL`, `KIMI_BASE_URL`; default `https://api.moonshot.ai/v1` | `kimi-k2.6`; Kimi Code path uses `kimi-for-coding` at `https://api.kimi.com/coding/v1` | Moonshot/Kimi route. `MOONSHOT_MODEL`, `KIMI_MODEL_NAME`, and `KIMI_MODEL` are accepted. `[providers.moonshot] auth_mode = "kimi_oauth"` reads Kimi CLI OAuth credentials when present. | | `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. | @@ -94,6 +96,7 @@ endpoint when the endpoint supports model listing. | `openrouter` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash` | yes | yes | | `novita` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash` | yes | yes | | `fireworks` | `accounts/fireworks/models/deepseek-v4-pro` | yes | yes | +| `siliconflow` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | yes | yes | | `moonshot` | `kimi-k2.6` | yes | yes | | `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 | @@ -118,7 +121,7 @@ All shipped providers use the Chat Completions request payload mode today. | DeepSeek V4 (`deepseek-v4-pro`, `deepseek-v4-flash`) | 1,000,000 | 384,000 | yes | yes | DeepSeek beta only | | DeepSeek compatibility aliases (`deepseek-chat`, `deepseek-reasoner`) | 1,000,000 | 384,000 | yes | yes | DeepSeek beta only | | NVIDIA NIM V4 registry models | 1,000,000 | 384,000 | yes | yes | not documented in code | -| OpenRouter, Novita, Fireworks, SGLang, and vLLM V4 model IDs | 1,000,000 | 384,000 | yes | no | not documented in code | +| OpenRouter, Novita, Fireworks, SiliconFlow, SGLang, and vLLM V4 model IDs | 1,000,000 | 384,000 | yes | no | not documented in code | | Wanjie Ark `reasoner` / `r1` model IDs | 128,000 | 4,096 | yes | 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 |