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>
This commit is contained in:
idling11
2026-06-12 01:21:27 -07:00
committed by Hunter B
parent 0cd8bcde1b
commit da943be7de
8 changed files with 378 additions and 51 deletions
+5
View File
@@ -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.
+7
View File
@@ -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"
+141 -7
View File
@@ -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();
+1 -1
View File
@@ -223,7 +223,7 @@ provider!(
DEFAULT_SILICONFLOW_CN_BASE_URL,
DEFAULT_SILICONFLOW_MODEL,
["SILICONFLOW_API_KEY"],
"siliconflow"
"siliconflow_cn"
);
provider!(
Arcee,
+5
View File
@@ -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.
+217 -41
View File
@@ -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<F>(
&self,
provider: ApiProvider,
get: F,
) -> Option<String>
where
F: Fn(&ProviderConfig) -> Option<String>,
{
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.<name>]`
@@ -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<PathBuf>
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<PathBuf>
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(())
}
+1 -1
View File
@@ -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. |
+1 -1
View File
@@ -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",
}