From da943be7de518c016622a9f7ca53f611fdb6dbb4 Mon Sep 17 00:00:00 2001 From: idling11 <8055620+idling11@users.noreply.github.com> Date: Fri, 12 Jun 2026 01:21:27 -0700 Subject: [PATCH] fix(config): split SiliconFlow CN provider config Harvest #2895 for the v0.8.59 release lane. SiliconFlow CN now reads its own providers.siliconflow_cn / providers.siliconflow-CN table and falls back to providers.siliconflow only when api_key, base_url, or model are unset. Maintainer amendments wire the TUI fallback paths, provider config get/set/unset/redaction surfaces, env override routing, model normalization, tests, and changelog credit. Fixes #2893. Reported-by: Artenx <18120598+Artenx@users.noreply.github.com> --- CHANGELOG.md | 5 + config.example.toml | 7 + crates/config/src/lib.rs | 148 ++++++++++++++++- crates/config/src/provider.rs | 2 +- crates/tui/CHANGELOG.md | 5 + crates/tui/src/config.rs | 258 ++++++++++++++++++++++++----- docs/PROVIDERS.md | 2 +- scripts/check-provider-registry.py | 2 +- 8 files changed, 378 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48c6396c..e360a728 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **SiliconFlow China provider config (#2893/#2895).** `siliconflow-CN` + now reads its own `[providers.siliconflow_cn]` / `[providers.siliconflow-CN]` + table and falls back to `[providers.siliconflow]` only for unset + `api_key`/`base_url`/`model` fields. Thanks @Artenx for the report and + @idling11 for the PR. - **TUI mouse-report leak (#3063/#3067).** Strip raw SGR mouse coordinate tails from the composer even when `use_mouse_capture` is false, covering orphaned terminal reporting state after crashes or focus races. diff --git a/config.example.toml b/config.example.toml index 6a0ed0af..139674b9 100644 --- a/config.example.toml +++ b/config.example.toml @@ -378,6 +378,13 @@ max_subagents = 10 # optional (1-20) # base_url = "https://api.siliconflow.com/v1" # model = "deepseek-ai/DeepSeek-V4-Pro" # or deepseek-ai/DeepSeek-V4-Flash +# SiliconFlow China-hosted DeepSeek V4 (https://siliconflow.cn) +# Falls back to [providers.siliconflow] for api_key / base_url / model when unset. +[providers.siliconflow-CN] +# api_key = "YOUR_SILICONFLOW_API_KEY" +# base_url = "https://api.siliconflow.cn/v1" +# model = "deepseek-ai/DeepSeek-V4-Pro" + # Arcee AI direct OpenAI-compatible endpoint (https://docs.arcee.ai) [providers.arcee] # api_key = "YOUR_ARCEE_API_KEY" diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index be1824bd..5fca3c5a 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -296,6 +296,8 @@ pub struct ProvidersToml { pub fireworks: ProviderConfigToml, #[serde(default)] pub siliconflow: ProviderConfigToml, + #[serde(default, alias = "siliconflow-CN", alias = "siliconflow-cn")] + pub siliconflow_cn: ProviderConfigToml, #[serde(default)] pub arcee: ProviderConfigToml, #[serde(default)] @@ -361,7 +363,8 @@ impl ProvidersToml { ProviderKind::XiaomiMimo => &self.xiaomi_mimo, ProviderKind::Novita => &self.novita, ProviderKind::Fireworks => &self.fireworks, - ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => &self.siliconflow, + ProviderKind::Siliconflow => &self.siliconflow, + ProviderKind::SiliconflowCN => &self.siliconflow_cn, ProviderKind::Arcee => &self.arcee, ProviderKind::Moonshot => &self.moonshot, ProviderKind::Sglang => &self.sglang, @@ -386,7 +389,8 @@ impl ProvidersToml { ProviderKind::XiaomiMimo => &mut self.xiaomi_mimo, ProviderKind::Novita => &mut self.novita, ProviderKind::Fireworks => &mut self.fireworks, - ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => &mut self.siliconflow, + ProviderKind::Siliconflow => &mut self.siliconflow, + ProviderKind::SiliconflowCN => &mut self.siliconflow_cn, ProviderKind::Arcee => &mut self.arcee, ProviderKind::Moonshot => &mut self.moonshot, ProviderKind::Sglang => &mut self.sglang, @@ -1121,6 +1125,10 @@ impl ConfigToml { &mut self.providers.siliconflow, &project.providers.siliconflow, ); + merge_project_provider_config( + &mut self.providers.siliconflow_cn, + &project.providers.siliconflow_cn, + ); 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); @@ -1220,6 +1228,12 @@ impl ConfigToml { "providers.siliconflow.http_headers" => { serialize_http_headers(&self.providers.siliconflow.http_headers) } + "providers.siliconflow_cn.api_key" => self.providers.siliconflow_cn.api_key.clone(), + "providers.siliconflow_cn.base_url" => self.providers.siliconflow_cn.base_url.clone(), + "providers.siliconflow_cn.model" => self.providers.siliconflow_cn.model.clone(), + "providers.siliconflow_cn.http_headers" => { + serialize_http_headers(&self.providers.siliconflow_cn.http_headers) + } "providers.arcee.api_key" => self.providers.arcee.api_key.clone(), "providers.arcee.base_url" => self.providers.arcee.base_url.clone(), "providers.arcee.model" => self.providers.arcee.model.clone(), @@ -1439,6 +1453,18 @@ impl ConfigToml { "providers.siliconflow.http_headers" => { self.providers.siliconflow.http_headers = parse_http_headers(value)?; } + "providers.siliconflow_cn.api_key" => { + self.providers.siliconflow_cn.api_key = Some(value.to_string()); + } + "providers.siliconflow_cn.base_url" => { + self.providers.siliconflow_cn.base_url = Some(value.to_string()); + } + "providers.siliconflow_cn.model" => { + self.providers.siliconflow_cn.model = Some(value.to_string()); + } + "providers.siliconflow_cn.http_headers" => { + self.providers.siliconflow_cn.http_headers = parse_http_headers(value)?; + } "providers.arcee.api_key" => { self.providers.arcee.api_key = Some(value.to_string()); } @@ -1618,6 +1644,12 @@ impl ConfigToml { "providers.siliconflow.http_headers" => { self.providers.siliconflow.http_headers.clear(); } + "providers.siliconflow_cn.api_key" => self.providers.siliconflow_cn.api_key = None, + "providers.siliconflow_cn.base_url" => self.providers.siliconflow_cn.base_url = None, + "providers.siliconflow_cn.model" => self.providers.siliconflow_cn.model = None, + "providers.siliconflow_cn.http_headers" => { + self.providers.siliconflow_cn.http_headers.clear(); + } "providers.arcee.api_key" => self.providers.arcee.api_key = None, "providers.arcee.base_url" => self.providers.arcee.base_url = None, "providers.arcee.model" => self.providers.arcee.model = None, @@ -1845,6 +1877,21 @@ impl ConfigToml { 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.siliconflow_cn.api_key.as_ref() { + out.insert( + "providers.siliconflow_cn.api_key".to_string(), + redact_secret(v), + ); + } + if let Some(v) = self.providers.siliconflow_cn.base_url.as_ref() { + out.insert("providers.siliconflow_cn.base_url".to_string(), v.clone()); + } + if let Some(v) = self.providers.siliconflow_cn.model.as_ref() { + out.insert("providers.siliconflow_cn.model".to_string(), v.clone()); + } + if let Some(v) = serialize_http_headers(&self.providers.siliconflow_cn.http_headers) { + out.insert("providers.siliconflow_cn.http_headers".to_string(), v); + } if let Some(v) = self.providers.arcee.api_key.as_ref() { out.insert("providers.arcee.api_key".to_string(), redact_secret(v)); } @@ -1956,7 +2003,19 @@ impl ConfigToml { let env = EnvRuntimeOverrides::load(); let provider = cli.provider.or(env.provider).unwrap_or(self.provider); - let provider_cfg = self.providers.for_provider(provider); + let mut provider_cfg = self.providers.for_provider(provider).clone(); + if provider == ProviderKind::SiliconflowCN { + let fb = &self.providers.siliconflow; + if provider_cfg.api_key.is_none() { + provider_cfg.api_key = fb.api_key.clone(); + } + if provider_cfg.base_url.is_none() { + provider_cfg.base_url = fb.base_url.clone(); + } + if provider_cfg.model.is_none() { + provider_cfg.model = fb.model.clone(); + } + } let root_deepseek_api_key = (provider == ProviderKind::Deepseek) .then(|| self.api_key.clone()) .flatten(); @@ -2284,11 +2343,11 @@ fn normalize_model_for_provider(provider: ProviderKind, model: &str) -> String { DEFAULT_FIREWORKS_MODEL.to_string() } ( - ProviderKind::Siliconflow, + ProviderKind::Siliconflow | ProviderKind::SiliconflowCN, "deepseek-v4-pro" | "deepseek-v4pro" | "deepseek-reasoner" | "deepseek-r1", ) => DEFAULT_SILICONFLOW_MODEL.to_string(), ( - ProviderKind::Siliconflow, + ProviderKind::Siliconflow | ProviderKind::SiliconflowCN, "deepseek-v4-flash" | "deepseek-v4flash" | "deepseek-chat" | "deepseek-v3", ) => DEFAULT_SILICONFLOW_FLASH_MODEL.to_string(), ( @@ -4632,6 +4691,59 @@ unix_socket_path = "/tmp/cw-hooks.sock" Ok(()) } + #[test] + fn siliconflow_cn_provider_config_values_round_trip() -> Result<()> { + let mut config = ConfigToml::default(); + + config.set_value("providers.siliconflow_cn.api_key", "sf-cn-secret-value")?; + config.set_value( + "providers.siliconflow_cn.base_url", + DEFAULT_SILICONFLOW_CN_BASE_URL, + )?; + config.set_value("providers.siliconflow_cn.model", DEFAULT_SILICONFLOW_MODEL)?; + config.set_value("providers.siliconflow_cn.http_headers", "X-Test=ok")?; + + assert_eq!( + config + .get_display_value("providers.siliconflow_cn.api_key") + .as_deref(), + Some("sf-c***alue") + ); + assert_eq!( + config + .get_value("providers.siliconflow_cn.base_url") + .as_deref(), + Some(DEFAULT_SILICONFLOW_CN_BASE_URL) + ); + assert_eq!( + config + .get_value("providers.siliconflow_cn.model") + .as_deref(), + Some(DEFAULT_SILICONFLOW_MODEL) + ); + assert_eq!( + config + .list_values() + .get("providers.siliconflow_cn.api_key") + .map(String::as_str), + Some("sf-c***alue") + ); + + config.unset_value("providers.siliconflow_cn.api_key")?; + config.unset_value("providers.siliconflow_cn.base_url")?; + config.unset_value("providers.siliconflow_cn.model")?; + config.unset_value("providers.siliconflow_cn.http_headers")?; + + assert_eq!(config.get_value("providers.siliconflow_cn.api_key"), None); + assert_eq!(config.get_value("providers.siliconflow_cn.base_url"), None); + assert_eq!(config.get_value("providers.siliconflow_cn.model"), None); + assert_eq!( + config.get_value("providers.siliconflow_cn.http_headers"), + None + ); + Ok(()) + } + #[test] fn volcengine_provider_config_values_round_trip() -> Result<()> { let mut config = ConfigToml::default(); @@ -5135,11 +5247,11 @@ unix_socket_path = "/tmp/cw-hooks.sock" provider::resolve_provider("siliconflow-cn").expect("siliconflow-cn alias resolves"); assert_eq!(siliconflow_cn.kind(), ProviderKind::SiliconflowCN); assert_eq!(siliconflow_cn.id(), "siliconflow-CN"); - assert_eq!(siliconflow_cn.provider_config_key(), "siliconflow"); + assert_eq!(siliconflow_cn.provider_config_key(), "siliconflow_cn"); let config = ProvidersToml::default(); let shared_table = config.for_provider(ProviderKind::SiliconflowCN); - assert!(std::ptr::eq( + assert!(!std::ptr::eq( shared_table, config.for_provider(ProviderKind::Siliconflow) )); @@ -5363,6 +5475,28 @@ mode = "token-plan-usa" assert_eq!(resolved.model, DEFAULT_SILICONFLOW_MODEL); } + #[test] + fn siliconflow_cn_config_falls_back_to_shared_table_when_unset() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let mut config = ConfigToml { + provider: ProviderKind::SiliconflowCN, + ..ConfigToml::default() + }; + config.providers.siliconflow.api_key = Some("sf-shared-key".to_string()); + config.providers.siliconflow.base_url = Some(DEFAULT_SILICONFLOW_BASE_URL.to_string()); + config.providers.siliconflow.model = Some("deepseek-chat".to_string()); + config.providers.siliconflow_cn.base_url = + Some(DEFAULT_SILICONFLOW_CN_BASE_URL.to_string()); + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::SiliconflowCN); + assert_eq!(resolved.api_key.as_deref(), Some("sf-shared-key")); + assert_eq!(resolved.base_url, DEFAULT_SILICONFLOW_CN_BASE_URL); + assert_eq!(resolved.model, DEFAULT_SILICONFLOW_FLASH_MODEL); + } + #[test] fn moonshot_provider_defaults_to_kimi_k2() { let _lock = env_lock(); diff --git a/crates/config/src/provider.rs b/crates/config/src/provider.rs index c9e59414..e8312a27 100644 --- a/crates/config/src/provider.rs +++ b/crates/config/src/provider.rs @@ -223,7 +223,7 @@ provider!( DEFAULT_SILICONFLOW_CN_BASE_URL, DEFAULT_SILICONFLOW_MODEL, ["SILICONFLOW_API_KEY"], - "siliconflow" + "siliconflow_cn" ); provider!( Arcee, diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index b456a676..b074eafa 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -31,6 +31,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **SiliconFlow China provider config (#2893/#2895).** `siliconflow-CN` + now reads its own `[providers.siliconflow_cn]` / `[providers.siliconflow-CN]` + table and falls back to `[providers.siliconflow]` only for unset + `api_key`/`base_url`/`model` fields. Thanks @Artenx for the report and + @idling11 for the PR. - **TUI mouse-report leak (#3063/#3067).** Strip raw SGR mouse coordinate tails from the composer even when `use_mouse_capture` is false, covering orphaned terminal reporting state after crashes or focus races. diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 1bd830ed..c55ab80e 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -2012,6 +2012,8 @@ pub struct ProvidersConfig { pub fireworks: ProviderConfig, #[serde(default)] pub siliconflow: ProviderConfig, + #[serde(default, alias = "siliconflow-CN", alias = "siliconflow-cn")] + pub siliconflow_cn: ProviderConfig, #[serde(default)] pub arcee: ProviderConfig, #[serde(default)] @@ -2184,7 +2186,8 @@ impl Config { ApiProvider::XiaomiMimo => "providers.xiaomi_mimo", ApiProvider::Novita => "providers.novita", ApiProvider::Fireworks => "providers.fireworks", - ApiProvider::Siliconflow | ApiProvider::SiliconflowCn => "providers.siliconflow", + ApiProvider::Siliconflow => "providers.siliconflow", + ApiProvider::SiliconflowCn => "providers.siliconflow_cn", ApiProvider::Arcee => "providers.arcee", ApiProvider::Moonshot => "providers.moonshot", ApiProvider::Sglang => "providers.sglang", @@ -2335,7 +2338,8 @@ impl Config { ApiProvider::XiaomiMimo => &providers.xiaomi_mimo, ApiProvider::Novita => &providers.novita, ApiProvider::Fireworks => &providers.fireworks, - ApiProvider::Siliconflow | ApiProvider::SiliconflowCn => &providers.siliconflow, + ApiProvider::Siliconflow => &providers.siliconflow, + ApiProvider::SiliconflowCn => &providers.siliconflow_cn, ApiProvider::Arcee => &providers.arcee, ApiProvider::Moonshot => &providers.moonshot, ApiProvider::Sglang => &providers.sglang, @@ -2362,7 +2366,8 @@ impl Config { ApiProvider::XiaomiMimo => &mut providers.xiaomi_mimo, ApiProvider::Novita => &mut providers.novita, ApiProvider::Fireworks => &mut providers.fireworks, - ApiProvider::Siliconflow | ApiProvider::SiliconflowCn => &mut providers.siliconflow, + ApiProvider::Siliconflow => &mut providers.siliconflow, + ApiProvider::SiliconflowCn => &mut providers.siliconflow_cn, ApiProvider::Arcee => &mut providers.arcee, ApiProvider::Moonshot => &mut providers.moonshot, ApiProvider::Sglang => &mut providers.sglang, @@ -2380,6 +2385,28 @@ impl Config { self.provider_config_for(self.api_provider()) } + fn provider_config_string_with_runtime_fallback( + &self, + provider: ApiProvider, + get: F, + ) -> Option + where + F: Fn(&ProviderConfig) -> Option, + { + if let Some(value) = self + .provider_config_for(provider) + .and_then(|entry| get(entry)) + { + return Some(value); + } + if provider == ApiProvider::SiliconflowCn { + return self + .provider_config_for(ApiProvider::Siliconflow) + .and_then(|entry| get(entry)); + } + None + } + #[must_use] pub fn insecure_skip_tls_verify(&self) -> bool { self.provider_config() @@ -2403,14 +2430,14 @@ impl Config { #[must_use] pub fn default_model(&self) -> String { let provider = self.api_provider(); - if let Some(model) = self - .provider_config() - .and_then(|provider| provider.model.as_deref()) + if let Some(model) = + self.provider_config_string_with_runtime_fallback(provider, |entry| entry.model.clone()) { + let model = model.trim(); if provider_passes_model_through(provider) || self.active_provider_preserves_custom_base_url_model() { - return model.trim().to_string(); + return model.to_string(); } if let Some(normalized) = normalize_model_for_provider(provider, model) { return normalized; @@ -2421,9 +2448,8 @@ impl Config { // It must pass through verbatim rather than fall back to a // DeepSeek/provider default (issue #1714). if !matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) { - let trimmed = model.trim(); - if !trimmed.is_empty() { - return trimmed.to_string(); + if !model.is_empty() { + return model.to_string(); } } } @@ -2506,8 +2532,7 @@ impl Config { pub fn deepseek_base_url(&self) -> String { let provider = self.api_provider(); let provider_base = self - .provider_config_for(provider) - .and_then(|provider| provider.base_url.clone()); + .provider_config_string_with_runtime_fallback(provider, |entry| entry.base_url.clone()); // Root `base_url` is the legacy DeepSeek field; only NvidiaNim has a // back-compat sniff (integrate.api.nvidia.com). OpenRouter / Novita // were added in v0.6.7 and require explicit `[providers.]` @@ -2682,8 +2707,7 @@ impl Config { // 1. Config file (provider-scoped slot). This intentionally wins // over ambient env so `codewhale auth set` fixes stale shell exports. if let Some(configured) = self - .provider_config_for(provider) - .and_then(|provider| provider.api_key.clone()) + .provider_config_string_with_runtime_fallback(provider, |entry| entry.api_key.clone()) && !configured.trim().is_empty() { return Ok(configured); @@ -2779,10 +2803,15 @@ 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 ~/.codewhale/config.toml." ), - ApiProvider::Siliconflow | ApiProvider::SiliconflowCn => anyhow::bail!( + 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 ~/.codewhale/config.toml." ), + ApiProvider::SiliconflowCn => anyhow::bail!( + "SiliconFlow China API key not found. Run 'codewhale auth set --provider siliconflow-CN', \ + set SILICONFLOW_API_KEY, or add [providers.siliconflow_cn] api_key in ~/.codewhale/config.toml. \ + [providers.siliconflow] remains a fallback when the CN table omits api_key." + ), ApiProvider::Arcee => anyhow::bail!( "Arcee AI API key not found. Run 'codewhale auth set --provider arcee', \ set ARCEE_API_KEY, or add [providers.arcee] api_key in ~/.codewhale/config.toml." @@ -3573,13 +3602,20 @@ fn apply_env_overrides(config: &mut Config) { .fireworks .base_url = Some(value); } - ApiProvider::Siliconflow | ApiProvider::SiliconflowCn => { + ApiProvider::Siliconflow => { config .providers .get_or_insert_with(ProvidersConfig::default) .siliconflow .base_url = Some(value); } + ApiProvider::SiliconflowCn => { + config + .providers + .get_or_insert_with(ProvidersConfig::default) + .siliconflow_cn + .base_url = Some(value); + } ApiProvider::Arcee => { config .providers @@ -3761,17 +3797,14 @@ fn apply_env_overrides(config: &mut Config) { .fireworks .base_url = Some(value); } + let active_provider = config.api_provider(); if matches!( - config.api_provider(), + active_provider, ApiProvider::Siliconflow | ApiProvider::SiliconflowCn ) && 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); + config.provider_config_for_mut(active_provider).base_url = Some(value); } if matches!(config.api_provider(), ApiProvider::Arcee) && let Ok(value) = std::env::var("ARCEE_BASE_URL") @@ -3848,7 +3881,8 @@ fn apply_env_overrides(config: &mut Config) { ApiProvider::XiaomiMimo => &mut providers.xiaomi_mimo, ApiProvider::Novita => &mut providers.novita, ApiProvider::Fireworks => &mut providers.fireworks, - ApiProvider::Siliconflow | ApiProvider::SiliconflowCn => &mut providers.siliconflow, + ApiProvider::Siliconflow => &mut providers.siliconflow, + ApiProvider::SiliconflowCn => &mut providers.siliconflow_cn, ApiProvider::Arcee => &mut providers.arcee, ApiProvider::Moonshot => &mut providers.moonshot, ApiProvider::Sglang => &mut providers.sglang, @@ -3978,17 +4012,14 @@ fn apply_env_overrides(config: &mut Config) { .moonshot .model = Some(value); } + let active_provider = config.api_provider(); if matches!( - config.api_provider(), + active_provider, ApiProvider::Siliconflow | ApiProvider::SiliconflowCn ) && let Ok(value) = std::env::var("SILICONFLOW_MODEL") && !value.trim().is_empty() { - config - .providers - .get_or_insert_with(ProvidersConfig::default) - .siliconflow - .model = Some(value); + config.provider_config_for_mut(active_provider).model = Some(value); } if matches!(config.api_provider(), ApiProvider::Arcee) && let Ok(value) = std::env::var("ARCEE_MODEL") @@ -4045,7 +4076,8 @@ fn apply_env_overrides(config: &mut Config) { ApiProvider::XiaomiMimo => &mut providers.xiaomi_mimo, ApiProvider::Novita => &mut providers.novita, ApiProvider::Fireworks => &mut providers.fireworks, - ApiProvider::Siliconflow | ApiProvider::SiliconflowCn => &mut providers.siliconflow, + ApiProvider::Siliconflow => &mut providers.siliconflow, + ApiProvider::SiliconflowCn => &mut providers.siliconflow_cn, ApiProvider::Arcee => &mut providers.arcee, ApiProvider::Moonshot => &mut providers.moonshot, ApiProvider::Sglang => &mut providers.sglang, @@ -4303,6 +4335,16 @@ fn normalize_model_config(config: &mut Config) { { providers.siliconflow.model = Some(normalized); } + if let Some(model) = providers.siliconflow_cn.model.as_deref() + && !provider_entry_uses_custom_base_url( + ApiProvider::SiliconflowCn, + &providers.siliconflow_cn, + ) + && let Some(normalized) = + normalize_model_for_provider(ApiProvider::SiliconflowCn, model) + { + providers.siliconflow_cn.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) @@ -4796,6 +4838,7 @@ fn merge_providers( 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), + siliconflow_cn: merge_provider_config(base.siliconflow_cn, override_cfg.siliconflow_cn), arcee: merge_provider_config(base.arcee, override_cfg.arcee), moonshot: merge_provider_config(base.moonshot, override_cfg.moonshot), sglang: merge_provider_config(base.sglang, override_cfg.sglang), @@ -5232,8 +5275,7 @@ pub fn active_provider_has_config_api_key(config: &Config) -> bool { } if config - .provider_config_for(provider) - .and_then(|entry| entry.api_key.as_ref()) + .provider_config_string_with_runtime_fallback(provider, |entry| entry.api_key.clone()) .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL) { return true; @@ -5408,8 +5450,7 @@ pub fn has_api_key_for(config: &Config, provider: ApiProvider) -> bool { } if config - .provider_config_for(provider) - .and_then(|entry| entry.api_key.as_ref()) + .provider_config_string_with_runtime_fallback(provider, |entry| entry.api_key.clone()) .is_some_and(|k| !k.trim().is_empty() && k != API_KEYRING_SENTINEL) { return true; @@ -5458,7 +5499,8 @@ pub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result ApiProvider::XiaomiMimo => "providers.xiaomi_mimo", ApiProvider::Novita => "providers.novita", ApiProvider::Fireworks => "providers.fireworks", - ApiProvider::Siliconflow | ApiProvider::SiliconflowCn => "providers.siliconflow", + ApiProvider::Siliconflow => "providers.siliconflow", + ApiProvider::SiliconflowCn => "providers.siliconflow_cn", ApiProvider::Arcee => "providers.arcee", ApiProvider::Huggingface => "providers.huggingface", ApiProvider::Moonshot => "providers.moonshot", @@ -5504,7 +5546,7 @@ pub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result ApiProvider::Novita => "novita", ApiProvider::Fireworks => "fireworks", ApiProvider::Siliconflow => "siliconflow", - ApiProvider::SiliconflowCn => "siliconflow", + ApiProvider::SiliconflowCn => "siliconflow_cn", ApiProvider::Arcee => "arcee", ApiProvider::Huggingface => "huggingface", ApiProvider::Moonshot => "moonshot", @@ -5603,7 +5645,7 @@ fn provider_config_key(provider: ApiProvider) -> Result<&'static str> { ApiProvider::Novita => Ok("novita"), ApiProvider::Fireworks => Ok("fireworks"), ApiProvider::Siliconflow => Ok("siliconflow"), - ApiProvider::SiliconflowCn => Ok("siliconflow"), + ApiProvider::SiliconflowCn => Ok("siliconflow_cn"), ApiProvider::Arcee => Ok("arcee"), ApiProvider::Huggingface => Ok("huggingface"), ApiProvider::Moonshot => Ok("moonshot"), @@ -9970,6 +10012,138 @@ model = "deepseek-v4-flash" Ok(()) } + #[test] + fn siliconflow_cn_reads_hyphenated_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-cn-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-CN" + +[providers.siliconflow-CN] +api_key = "sf-cn-table-key" +base_url = "https://api.siliconflow.cn/v1" +model = "deepseek-reasoner" +"#, + )?; + + let config = Config::load(None, None)?; + assert_eq!(config.api_provider(), ApiProvider::SiliconflowCn); + assert_eq!(config.deepseek_api_key()?, "sf-cn-table-key"); + assert_eq!(config.deepseek_base_url(), DEFAULT_SILICONFLOW_CN_BASE_URL); + assert_eq!(config.default_model(), DEFAULT_SILICONFLOW_MODEL); + assert!(has_api_key_for(&config, ApiProvider::SiliconflowCn)); + Ok(()) + } + + #[test] + fn siliconflow_cn_falls_back_to_shared_siliconflow_table_when_unset() -> 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-fallback-{}-{}", + 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-CN" + +[providers.siliconflow] +api_key = "sf-shared-key" +base_url = "https://api.siliconflow.com/v1" +model = "deepseek-chat" + +[providers.siliconflow_cn] +base_url = "https://api.siliconflow.cn/v1" +"#, + )?; + + let config = Config::load(None, None)?; + assert_eq!(config.api_provider(), ApiProvider::SiliconflowCn); + assert_eq!(config.deepseek_api_key()?, "sf-shared-key"); + assert_eq!(config.deepseek_base_url(), DEFAULT_SILICONFLOW_CN_BASE_URL); + assert_eq!(config.default_model(), DEFAULT_SILICONFLOW_FLASH_MODEL); + assert!(active_provider_has_config_api_key(&config)); + Ok(()) + } + + #[test] + fn siliconflow_cn_env_overrides_write_cn_table_only() -> 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-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-CN" + +[providers.siliconflow] +api_key = "sf-shared-key" +base_url = "https://api.siliconflow.com/v1" +model = "deepseek-reasoner" +"#, + )?; + unsafe { + env::set_var("SILICONFLOW_BASE_URL", "https://api.siliconflow.cn/v1"); + env::set_var("SILICONFLOW_MODEL", "deepseek-chat"); + } + + let config = Config::load(None, None)?; + let providers = config.providers.as_ref().expect("providers"); + assert_eq!( + providers.siliconflow.base_url.as_deref(), + Some(DEFAULT_SILICONFLOW_BASE_URL) + ); + assert_eq!( + providers.siliconflow.model.as_deref(), + Some(DEFAULT_SILICONFLOW_MODEL) + ); + assert_eq!( + providers.siliconflow_cn.base_url.as_deref(), + Some(DEFAULT_SILICONFLOW_CN_BASE_URL) + ); + assert_eq!( + providers.siliconflow_cn.model.as_deref(), + Some(DEFAULT_SILICONFLOW_FLASH_MODEL) + ); + assert_eq!(config.deepseek_api_key()?, "sf-shared-key"); + 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(); @@ -10549,16 +10723,18 @@ api_key = "moonshot-platform-key" assert_eq!( parsed .get("providers") - .and_then(|p| p.get("siliconflow")) + .and_then(|p| p.get("siliconflow_cn")) .and_then(|t| t.get("api_key")) .and_then(toml::Value::as_str), Some("sf-cn-saved-key") ); - assert!( + assert_eq!( parsed .get("providers") - .and_then(|p| p.get("siliconflow-CN")) - .is_none() + .and_then(|p| p.get("siliconflow")) + .and_then(|t| t.get("api_key")) + .and_then(toml::Value::as_str), + Some("sf-saved-key") ); Ok(()) } diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 1743f4c1..d6b4737b 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -128,7 +128,7 @@ endpoint. | `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. | -| `siliconflow-CN` | `[providers.siliconflow]` | `SILICONFLOW_API_KEY` | `SILICONFLOW_BASE_URL`; default `https://api.siliconflow.cn/v1` | Uses the SiliconFlow model set | China regional SiliconFlow route. This intentionally shares `[providers.siliconflow]` and `SILICONFLOW_API_KEY`; do not create `[providers.siliconflow_CN]`. Select it with `provider = "siliconflow-CN"` or `CODEWHALE_PROVIDER=siliconflow-CN`. | +| `siliconflow-CN` | `[providers.siliconflow_cn]` | `SILICONFLOW_API_KEY` | `SILICONFLOW_BASE_URL`; default `https://api.siliconflow.cn/v1` | Uses the SiliconFlow model set | China regional SiliconFlow route. Falls back to `[providers.siliconflow]` for api_key / base_url / model when unset. Select it with `provider = "siliconflow-CN"` or `CODEWHALE_PROVIDER=siliconflow-CN`. | | `arcee` | `[providers.arcee]` | `ARCEE_API_KEY` | `ARCEE_BASE_URL`; default `https://api.arcee.ai/api/v1` | `trinity-large-thinking`, `trinity-large-preview` | Arcee AI direct OpenAI-compatible route, tracked as 256K-context BF16 serving. `ARCEE_MODEL` is accepted. OpenRouter's `arcee-ai/trinity-large-thinking` remains the OpenRouter namespaced model ID; direct Arcee uses the bare `trinity-large-thinking` ID. | | `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. | diff --git a/scripts/check-provider-registry.py b/scripts/check-provider-registry.py index 85d7eea6..0caa5a71 100644 --- a/scripts/check-provider-registry.py +++ b/scripts/check-provider-registry.py @@ -28,7 +28,7 @@ PROVIDERS_MD = ROOT / "docs" / "PROVIDERS.md" API_PROVIDER_ONLY_IDS = {"deepseek-cn"} SHARED_PROVIDER_TABLES = { - "siliconflow-CN": "siliconflow", + "siliconflow-CN": "siliconflow_cn", }