feat(cli): track provider source and customize unsupported TUI errors (#3011)

This commit is contained in:
cyq
2026-06-13 01:53:32 +08:00
committed by GitHub
parent b63287e653
commit 38519552fd
2 changed files with 243 additions and 50 deletions
+149 -49
View File
@@ -14,7 +14,8 @@ use codewhale_app_server::{
AppServerOptions, run as run_app_server, run_stdio as run_app_server_stdio,
};
use codewhale_config::{
CliRuntimeOverrides, ConfigStore, ProviderKind, ResolvedRuntimeOptions, RuntimeApiKeySource,
CliRuntimeOverrides, ConfigStore, ProviderKind, ProviderSource, ResolvedRuntimeOptions,
RuntimeApiKeySource,
};
use codewhale_execpolicy::{AskForApproval, ExecPolicyContext, ExecPolicyEngine};
use codewhale_mcp::{McpServerDefinition, run_stdio_server};
@@ -806,32 +807,6 @@ const PROVIDER_LIST: [ProviderKind; 20] = [
ProviderKind::OpenaiCodex,
];
fn provider_is_supported_by_tui(provider: ProviderKind) -> bool {
matches!(
provider,
ProviderKind::Deepseek
| ProviderKind::NvidiaNim
| ProviderKind::Openai
| ProviderKind::Atlascloud
| ProviderKind::WanjieArk
| ProviderKind::Volcengine
| ProviderKind::Openrouter
| ProviderKind::XiaomiMimo
| ProviderKind::Novita
| ProviderKind::Fireworks
| ProviderKind::Siliconflow
| ProviderKind::SiliconflowCN
| ProviderKind::Arcee
| ProviderKind::Moonshot
| ProviderKind::Sglang
| ProviderKind::Vllm
| ProviderKind::Ollama
| ProviderKind::Huggingface
| ProviderKind::Together
| ProviderKind::OpenaiCodex
)
}
#[cfg(test)]
fn no_keyring_secrets() -> Secrets {
Secrets::new(std::sync::Arc::new(
@@ -1714,33 +1689,41 @@ fn build_tui_command(
}
cmd.args(passthrough);
if !provider_is_supported_by_tui(resolved_runtime.provider) {
let source_hint = if cli.provider.is_some() {
"set via --provider flag"
} else {
"resolved from config file or environment"
};
bail!(
"The interactive TUI does not support provider '{}' ({}).\n\
\n\
Supported TUI providers: deepseek, openai, ollama, openrouter, nvidia-nim, \n\
volcengine, siliconflow, moonshot, arcee, fireworks, novita, xiaomi-mimo,\n\
huggingface, sglang, vllm, atlascloud, wanjie-ark, together, openai-codex.\n\
\n\
To fix:\n\
- Set a supported provider in your config file (~/.codewhale/config.toml)\n\
under [providers.<id>] with an api_key, or\n\
- Pass --provider <supported-id> on the command line, or\n\
- Run `codewhale exec --provider <supported-id> \"your prompt\"` for a\n\
one-shot non-interactive session with this provider.",
resolved_runtime.provider.as_str(),
source_hint,
);
let mut resolved_runtime = resolved_runtime.clone();
let mut fallback_provider = None;
if !resolved_runtime.provider.is_tui_capable() {
match resolved_runtime.provider_source {
ProviderSource::Cli => {
bail!(
"The interactive TUI supports {} providers. Remove --provider {} or use `codewhale model ...` for provider registry inspection.",
ProviderKind::tui_supported_providers_msg(),
resolved_runtime.provider.as_str()
);
}
ProviderSource::Env(var_name) => {
bail!(
"The interactive TUI supports {} providers. Unset `{}` (currently `{}`) or use `codewhale model ...` for provider registry inspection.",
ProviderKind::tui_supported_providers_msg(),
var_name,
resolved_runtime.provider.as_str()
);
}
ProviderSource::Config => {
eprintln!(
"warning: Unsupported provider '{}' configured in config.toml. Falling back to default 'deepseek'.",
resolved_runtime.provider.as_str()
);
resolved_runtime.provider = ProviderKind::Deepseek;
fallback_provider = Some(resolved_runtime.provider);
}
}
}
if let Some(provider) = cli.provider {
let provider: ProviderKind = provider.into();
cmd.env("DEEPSEEK_PROVIDER", provider.as_str());
} else if let Some(provider) = fallback_provider {
cmd.env("DEEPSEEK_PROVIDER", provider.as_str());
}
if matches!(
resolved_runtime.api_key_source,
@@ -3130,6 +3113,7 @@ mod tests {
]);
let resolved = ResolvedRuntimeOptions {
provider: ProviderKind::Openai,
provider_source: ProviderSource::Cli,
model: "glm-5".to_string(),
api_key: Some("resolved-openai-key".to_string()),
api_key_source: Some(RuntimeApiKeySource::Keyring),
@@ -3189,6 +3173,7 @@ mod tests {
let cli = parse_ok(&["codewhale", "doctor"]);
let resolved = ResolvedRuntimeOptions {
provider: ProviderKind::OpenaiCodex,
provider_source: ProviderSource::Config,
model: "gpt-5.5".to_string(),
api_key: None,
api_key_source: None,
@@ -3229,6 +3214,7 @@ mod tests {
let cli = parse_ok(&["codewhale", "--provider", "openai-codex", "doctor"]);
let resolved = ResolvedRuntimeOptions {
provider: ProviderKind::OpenaiCodex,
provider_source: ProviderSource::Cli,
model: "gpt-5.5".to_string(),
api_key: None,
api_key_source: None,
@@ -3269,6 +3255,7 @@ mod tests {
resolved_headers.insert("X-From-Base".to_string(), "base".to_string());
let resolved = ResolvedRuntimeOptions {
provider: ProviderKind::Deepseek,
provider_source: ProviderSource::Config,
model: "deepseek-v4-pro".to_string(),
api_key: Some("config-file-key".to_string()),
api_key_source: Some(RuntimeApiKeySource::ConfigFile),
@@ -3326,6 +3313,7 @@ mod tests {
]);
let resolved = ResolvedRuntimeOptions {
provider: ProviderKind::Moonshot,
provider_source: ProviderSource::Cli,
model: "kimi-k2.6".to_string(),
api_key: Some("resolved-kimi-key".to_string()),
api_key_source: Some(RuntimeApiKeySource::Keyring),
@@ -3392,6 +3380,7 @@ mod tests {
]);
let resolved = ResolvedRuntimeOptions {
provider: ProviderKind::Volcengine,
provider_source: ProviderSource::Cli,
model: "DeepSeek-V4-Pro".to_string(),
api_key: Some("resolved-ark-key".to_string()),
api_key_source: Some(RuntimeApiKeySource::Keyring),
@@ -3459,6 +3448,7 @@ mod tests {
]);
let resolved = ResolvedRuntimeOptions {
provider: ProviderKind::Openai,
provider_source: ProviderSource::Cli,
model: "glm-5".to_string(),
api_key: None,
api_key_source: None,
@@ -3556,6 +3546,7 @@ mod tests {
]);
let resolved = ResolvedRuntimeOptions {
provider,
provider_source: ProviderSource::Cli,
model: "test-model".to_string(),
api_key: Some("test-key".to_string()),
api_key_source: Some(RuntimeApiKeySource::Keyring),
@@ -3846,4 +3837,113 @@ mod tests {
let resolved = locate_sibling_tui_binary().expect("override must resolve");
assert_eq!(resolved, custom);
}
#[test]
fn test_build_tui_command_unsupported_provider_cli() {
let _lock = env_lock();
let dir = tempfile::TempDir::new().expect("tempdir");
let custom = dir
.path()
.join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
std::fs::write(&custom, b"").unwrap();
let custom_str = custom.to_string_lossy().into_owned();
let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
let cli = parse_ok(&["deepseek"]);
let options = ResolvedRuntimeOptions {
provider: ProviderKind::Anthropic,
provider_source: ProviderSource::Cli,
model: "model".to_string(),
api_key: None,
api_key_source: None,
base_url: "url".to_string(),
auth_mode: None,
insecure_skip_tls_verify: false,
output_mode: None,
log_level: None,
telemetry: false,
approval_policy: None,
sandbox_mode: None,
yolo: None,
http_headers: std::collections::BTreeMap::new(),
};
let err = build_tui_command(&cli, &options, vec![]).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("Remove --provider anthropic"), "msg: {}", msg);
assert!(msg.contains("The interactive TUI supports"), "msg: {}", msg);
}
#[test]
fn test_build_tui_command_unsupported_provider_env() {
let _lock = env_lock();
let dir = tempfile::TempDir::new().expect("tempdir");
let custom = dir
.path()
.join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
std::fs::write(&custom, b"").unwrap();
let custom_str = custom.to_string_lossy().into_owned();
let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
let cli = parse_ok(&["deepseek"]);
let options = ResolvedRuntimeOptions {
provider: ProviderKind::Anthropic,
provider_source: ProviderSource::Env("CODEWHALE_PROVIDER"),
model: "model".to_string(),
api_key: None,
api_key_source: None,
base_url: "url".to_string(),
auth_mode: None,
insecure_skip_tls_verify: false,
output_mode: None,
log_level: None,
telemetry: false,
approval_policy: None,
sandbox_mode: None,
yolo: None,
http_headers: std::collections::BTreeMap::new(),
};
let err = build_tui_command(&cli, &options, vec![]).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("Unset `CODEWHALE_PROVIDER` (currently `anthropic`)"),
"msg: {}",
msg
);
}
#[test]
fn test_build_tui_command_unsupported_provider_config_fallback() {
let _lock = env_lock();
let dir = tempfile::TempDir::new().expect("tempdir");
let custom = dir
.path()
.join(format!("custom-tui{}", std::env::consts::EXE_SUFFIX));
std::fs::write(&custom, b"").unwrap();
let custom_str = custom.to_string_lossy().into_owned();
let _bin = ScopedEnvVar::set("DEEPSEEK_TUI_BIN", &custom_str);
let cli = parse_ok(&["deepseek"]);
let options = ResolvedRuntimeOptions {
provider: ProviderKind::Anthropic,
provider_source: ProviderSource::Config,
model: "model".to_string(),
api_key: None,
api_key_source: None,
base_url: "url".to_string(),
auth_mode: None,
insecure_skip_tls_verify: false,
output_mode: None,
log_level: None,
telemetry: false,
approval_policy: None,
sandbox_mode: None,
yolo: None,
http_headers: std::collections::BTreeMap::new(),
};
let cmd = build_tui_command(&cli, &options, vec![]).expect("should graceful fallback");
assert_eq!(
command_env(&cmd, "DEEPSEEK_PROVIDER").as_deref(),
Some("deepseek")
);
}
}
+94 -1
View File
@@ -159,6 +159,79 @@ pub enum ProviderKind {
}
impl ProviderKind {
#[must_use]
pub fn is_tui_capable(self) -> bool {
matches!(
self,
Self::Deepseek
| Self::NvidiaNim
| Self::Openai
| Self::Atlascloud
| Self::WanjieArk
| Self::Volcengine
| Self::Openrouter
| Self::XiaomiMimo
| Self::Novita
| Self::Fireworks
| Self::Siliconflow
| Self::SiliconflowCN
| Self::Arcee
| Self::Moonshot
| Self::Sglang
| Self::Vllm
| Self::Ollama
| Self::Huggingface
| Self::Together
| Self::OpenaiCodex
)
}
#[must_use]
pub fn display_name(self) -> &'static str {
match self {
Self::Deepseek => "DeepSeek",
Self::NvidiaNim => "NVIDIA NIM",
Self::Openai => "OpenAI-compatible",
Self::Atlascloud => "AtlasCloud",
Self::WanjieArk => "Wanjie Ark",
Self::Volcengine => "Volcengine Ark",
Self::Openrouter => "OpenRouter",
Self::XiaomiMimo => "Xiaomi MiMo",
Self::Novita => "Novita",
Self::Fireworks => "Fireworks",
Self::Siliconflow => "SiliconFlow",
Self::SiliconflowCN => "SiliconFlow (CN)",
Self::Arcee => "Arcee AI",
Self::Moonshot => "Moonshot/Kimi",
Self::Sglang => "SGLang",
Self::Vllm => "vLLM",
Self::Ollama => "Ollama",
Self::Huggingface => "Hugging Face",
Self::Together => "Together AI",
Self::OpenaiCodex => "OpenAI Codex",
Self::Anthropic => "Anthropic",
}
}
#[must_use]
pub fn tui_supported_providers_msg() -> String {
let mut names = Vec::new();
for p in Self::ALL {
if p.is_tui_capable() {
names.push(p.display_name());
}
}
match names.as_slice() {
[] => String::new(),
[only] => (*only).to_string(),
[first, second] => format!("{first} and {second}"),
_ => {
let last = names.pop().expect("non-empty");
format!("{}, and {}", names.join(", "), last)
}
}
}
pub const ALL: [Self; 21] = [
Self::Deepseek,
Self::NvidiaNim,
@@ -1968,7 +2041,18 @@ impl ConfigToml {
secrets: &Secrets,
) -> ResolvedRuntimeOptions {
let env = EnvRuntimeOverrides::load();
let provider = cli.provider.or(env.provider).unwrap_or(self.provider);
let (provider, provider_source) = if let Some(p) = cli.provider {
(p, ProviderSource::Cli)
} else if let Some(p) = env.provider {
let var_name = if std::env::var("CODEWHALE_PROVIDER").is_ok() {
"CODEWHALE_PROVIDER"
} else {
"DEEPSEEK_PROVIDER"
};
(p, ProviderSource::Env(var_name))
} else {
(self.provider, ProviderSource::Config)
};
let mut provider_cfg = self.providers.for_provider(provider).clone();
if provider == ProviderKind::SiliconflowCN {
@@ -2168,6 +2252,7 @@ impl ConfigToml {
ResolvedRuntimeOptions {
provider,
provider_source,
model,
api_key,
api_key_source,
@@ -2809,9 +2894,17 @@ impl RuntimeApiKeySource {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProviderSource {
Cli,
Env(&'static str),
Config,
}
#[derive(Debug, Clone)]
pub struct ResolvedRuntimeOptions {
pub provider: ProviderKind,
pub provider_source: ProviderSource,
pub model: String,
pub api_key: Option<String>,
pub api_key_source: Option<RuntimeApiKeySource>,