feat(cli): track provider source and customize unsupported TUI errors (#3011)
This commit is contained in:
+149
-49
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
Reference in New Issue
Block a user