diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d0f05ae9..fa6211cbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **`CODEWHALE_*` env aliases.** `CODEWHALE_PROVIDER`, `CODEWHALE_MODEL`, + and `CODEWHALE_BASE_URL` are public aliases that take precedence over + the legacy `DEEPSEEK_*` forms. The `DEEPSEEK_*` names remain accepted + for back-compat. Recommended setup paths are `codewhale --provider `, + `provider = ""` in `~/.codewhale/config.toml`, or + `CODEWHALE_PROVIDER=` — never `DEEPSEEK_PROVIDER=` in + user-facing docs. + ### Fixed - **Kimi Code API-key setup.** `codewhale config set providers.moonshot.*` now writes the Moonshot/Kimi provider table, and Kimi Code API-key endpoints default to `kimi-for-coding` without using the Kimi CLI OAuth path. +- **Kimi Code model precedence.** When `[providers.moonshot]` points at the + Kimi Code endpoint, the resolver now returns `kimi-for-coding` even when + a root DeepSeek `default_text_model` (e.g. `deepseek-v4-pro`) is still in + the config from a previous setup. ## [0.8.45] - 2026-05-25 diff --git a/README.md b/README.md index 450ca64d0..f7051c00a 100644 --- a/README.md +++ b/README.md @@ -506,12 +506,15 @@ Key environment variables: | Variable | Purpose | |---|---| +| `CODEWHALE_PROVIDER` | Active provider. Same value set as `DEEPSEEK_PROVIDER`; this is the public alias and wins when both are set. | +| `CODEWHALE_MODEL` | Default model for the active provider. Public alias for `DEEPSEEK_MODEL`. | +| `CODEWHALE_BASE_URL` | Base URL for the active provider. Public alias for `DEEPSEEK_BASE_URL`. | | `DEEPSEEK_API_KEY` | API key | -| `DEEPSEEK_BASE_URL` | API base URL | +| `DEEPSEEK_BASE_URL` | API base URL (legacy alias of `CODEWHALE_BASE_URL`) | | `DEEPSEEK_HTTP_HEADERS` | Optional custom model request headers, e.g. `X-Model-Provider-Id=your-model-provider` | -| `DEEPSEEK_MODEL` | Default model | +| `DEEPSEEK_MODEL` | Default model (legacy alias of `CODEWHALE_MODEL`) | | `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` | Stream idle timeout in seconds, default `300`, clamped to `1..=3600` | -| `DEEPSEEK_PROVIDER` | `deepseek` (default), `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `openrouter`, `novita`, `fireworks`, `moonshot`, `sglang`, `vllm`, `ollama` | +| `DEEPSEEK_PROVIDER` | Legacy alias of `CODEWHALE_PROVIDER`. Accepts `deepseek` (default), `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `openrouter`, `novita`, `fireworks`, `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 | diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index d9d728648..039c28c73 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -1787,10 +1787,15 @@ struct EnvRuntimeOverrides { impl EnvRuntimeOverrides { fn load() -> Self { Self { - provider: std::env::var("DEEPSEEK_PROVIDER") + provider: std::env::var("CODEWHALE_PROVIDER") + .or_else(|_| std::env::var("DEEPSEEK_PROVIDER")) .ok() .and_then(|v| ProviderKind::parse(&v)), - model: std::env::var("DEEPSEEK_MODEL").ok(), + model: std::env::var("CODEWHALE_MODEL") + .or_else(|_| std::env::var("DEEPSEEK_MODEL")) + .or_else(|_| std::env::var("DEEPSEEK_DEFAULT_TEXT_MODEL")) + .ok() + .filter(|v| !v.trim().is_empty()), wanjie_ark_model: std::env::var("WANJIE_ARK_MODEL") .or_else(|_| std::env::var("WANJIE_MODEL")) .or_else(|_| std::env::var("WANJIE_MAAS_MODEL")) @@ -1816,7 +1821,8 @@ impl EnvRuntimeOverrides { .ok() .and_then(|value| parse_http_headers(&value).ok()) .filter(|headers| !headers.is_empty()), - deepseek_base_url: std::env::var("DEEPSEEK_BASE_URL") + deepseek_base_url: std::env::var("CODEWHALE_BASE_URL") + .or_else(|_| std::env::var("DEEPSEEK_BASE_URL")) .ok() .filter(|v| !v.trim().is_empty()), nvidia_base_url: std::env::var("NVIDIA_NIM_BASE_URL") @@ -1921,6 +1927,7 @@ mod tests { deepseek_base_url: Option, deepseek_http_headers: Option, deepseek_model: Option, + deepseek_default_text_model: Option, deepseek_provider: Option, deepseek_auth_mode: Option, nvidia_api_key: Option, @@ -1954,6 +1961,9 @@ mod tests { vllm_base_url: Option, ollama_api_key: Option, ollama_base_url: Option, + codewhale_provider: Option, + codewhale_model: Option, + codewhale_base_url: Option, } impl EnvGuard { @@ -1963,8 +1973,12 @@ mod tests { deepseek_base_url: env::var_os("DEEPSEEK_BASE_URL"), deepseek_http_headers: env::var_os("DEEPSEEK_HTTP_HEADERS"), deepseek_model: env::var_os("DEEPSEEK_MODEL"), + deepseek_default_text_model: env::var_os("DEEPSEEK_DEFAULT_TEXT_MODEL"), deepseek_provider: env::var_os("DEEPSEEK_PROVIDER"), deepseek_auth_mode: env::var_os("DEEPSEEK_AUTH_MODE"), + codewhale_provider: env::var_os("CODEWHALE_PROVIDER"), + codewhale_model: env::var_os("CODEWHALE_MODEL"), + codewhale_base_url: env::var_os("CODEWHALE_BASE_URL"), nvidia_api_key: env::var_os("NVIDIA_API_KEY"), nvidia_nim_api_key: env::var_os("NVIDIA_NIM_API_KEY"), nim_base_url: env::var_os("NIM_BASE_URL"), @@ -2003,8 +2017,12 @@ mod tests { env::remove_var("DEEPSEEK_BASE_URL"); env::remove_var("DEEPSEEK_HTTP_HEADERS"); env::remove_var("DEEPSEEK_MODEL"); + env::remove_var("DEEPSEEK_DEFAULT_TEXT_MODEL"); env::remove_var("DEEPSEEK_PROVIDER"); env::remove_var("DEEPSEEK_AUTH_MODE"); + env::remove_var("CODEWHALE_PROVIDER"); + env::remove_var("CODEWHALE_MODEL"); + env::remove_var("CODEWHALE_BASE_URL"); env::remove_var("NVIDIA_API_KEY"); env::remove_var("NVIDIA_NIM_API_KEY"); env::remove_var("NIM_BASE_URL"); @@ -2057,8 +2075,15 @@ mod tests { Self::restore_var("DEEPSEEK_BASE_URL", self.deepseek_base_url.take()); Self::restore_var("DEEPSEEK_HTTP_HEADERS", self.deepseek_http_headers.take()); Self::restore_var("DEEPSEEK_MODEL", self.deepseek_model.take()); + Self::restore_var( + "DEEPSEEK_DEFAULT_TEXT_MODEL", + self.deepseek_default_text_model.take(), + ); Self::restore_var("DEEPSEEK_PROVIDER", self.deepseek_provider.take()); Self::restore_var("DEEPSEEK_AUTH_MODE", self.deepseek_auth_mode.take()); + Self::restore_var("CODEWHALE_PROVIDER", self.codewhale_provider.take()); + Self::restore_var("CODEWHALE_MODEL", self.codewhale_model.take()); + Self::restore_var("CODEWHALE_BASE_URL", self.codewhale_base_url.take()); Self::restore_var("NVIDIA_API_KEY", self.nvidia_api_key.take()); Self::restore_var("NVIDIA_NIM_API_KEY", self.nvidia_nim_api_key.take()); Self::restore_var("NIM_BASE_URL", self.nim_base_url.take()); @@ -2408,6 +2433,55 @@ mod tests { ); } + /// End-to-end smoke for the preferred Kimi Code setup path: + /// 1. Start from a fresh root config that uses DeepSeek defaults. + /// 2. Mutate it through the same key-value setters the + /// `codewhale config set providers.moonshot.*` CLI invokes. + /// 3. Switch the active provider through `CODEWHALE_PROVIDER` — + /// the public env alias — without ever touching the legacy + /// `DEEPSEEK_PROVIDER` name. + /// 4. Resolve the runtime and confirm the doctor/runtime values. + /// + /// No real API key is required; the `api_key` here is just a + /// non-empty placeholder. + #[test] + fn moonshot_kimi_code_smoke_config_set_then_resolve() -> Result<()> { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + + let mut config = ConfigToml { + provider: ProviderKind::Deepseek, + default_text_model: Some("deepseek-v4-pro".to_string()), + ..ConfigToml::default() + }; + + // Same key paths a user would run via `codewhale config set`. + config.set_value("providers.moonshot.api_key", "kimi-code-key-placeholder")?; + config.set_value("providers.moonshot.auth_mode", "api_key")?; + config.set_value("providers.moonshot.base_url", DEFAULT_KIMI_CODE_BASE_URL)?; + config.set_value("providers.moonshot.model", DEFAULT_KIMI_CODE_MODEL)?; + + // Public env alias for the active-provider switch. + // Safety: test-only env mutation guarded by env_lock(). + unsafe { env::set_var("CODEWHALE_PROVIDER", "moonshot") }; + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Moonshot); + assert_eq!(resolved.base_url, DEFAULT_KIMI_CODE_BASE_URL); + assert_eq!(resolved.model, DEFAULT_KIMI_CODE_MODEL); + assert_eq!(resolved.auth_mode.as_deref(), Some("api_key")); + assert_eq!( + resolved.api_key.as_deref(), + Some("kimi-code-key-placeholder") + ); + assert_eq!( + resolved.api_key_source, + Some(RuntimeApiKeySource::ConfigFile) + ); + Ok(()) + } + #[test] fn moonshot_provider_config_values_round_trip() -> Result<()> { let mut config = ConfigToml::default(); @@ -2757,6 +2831,109 @@ mod tests { ); } + /// `CODEWHALE_PROVIDER` is the user-facing env alias for switching the + /// active provider. It must be honored by the runtime resolver and win + /// over a root `provider = "deepseek"` config entry. + #[test] + fn codewhale_provider_env_switches_active_provider() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + // Safety: test-only env mutation guarded by env_lock(). + unsafe { + env::set_var("CODEWHALE_PROVIDER", "moonshot"); + } + let mut config = ConfigToml { + provider: ProviderKind::Deepseek, + ..ConfigToml::default() + }; + config.providers.moonshot.api_key = Some("kimi-code-key".to_string()); + config.providers.moonshot.base_url = Some(DEFAULT_KIMI_CODE_BASE_URL.to_string()); + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Moonshot); + assert_eq!(resolved.base_url, DEFAULT_KIMI_CODE_BASE_URL); + assert_eq!(resolved.model, DEFAULT_KIMI_CODE_MODEL); + assert_eq!(resolved.api_key.as_deref(), Some("kimi-code-key")); + } + + /// When both `CODEWHALE_PROVIDER` and the legacy `DEEPSEEK_PROVIDER` + /// are set, the public alias wins — a user adopting `CODEWHALE_*` in a + /// fresh shell config is not tripped up by a stale legacy export still + /// living in their dotfiles. + #[test] + fn codewhale_provider_env_wins_over_deepseek_provider_env() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + // Safety: test-only env mutation guarded by env_lock(). + unsafe { + env::set_var("CODEWHALE_PROVIDER", "moonshot"); + env::set_var("DEEPSEEK_PROVIDER", "openrouter"); + } + let config = ConfigToml { + provider: ProviderKind::Deepseek, + ..ConfigToml::default() + }; + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Moonshot); + } + + /// `CODEWHALE_MODEL` is the user-facing env alias for picking a model + /// against the active provider. It must be honored by the runtime + /// resolver in place of `DEEPSEEK_MODEL`. + #[test] + fn codewhale_model_env_alias_overrides_default_for_active_provider() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + // Safety: test-only env mutation guarded by env_lock(). + unsafe { + env::set_var("CODEWHALE_PROVIDER", "moonshot"); + env::set_var("CODEWHALE_MODEL", "custom-kimi-test-model"); + } + let config = ConfigToml::default(); + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Moonshot); + assert_eq!(resolved.model, "custom-kimi-test-model"); + } + + #[test] + fn blank_codewhale_model_env_alias_does_not_override_default_for_active_provider() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + // Safety: test-only env mutation guarded by env_lock(). + unsafe { + env::set_var("CODEWHALE_PROVIDER", "moonshot"); + env::set_var("CODEWHALE_MODEL", " "); + } + let config = ConfigToml::default(); + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Moonshot); + assert_eq!(resolved.model, DEFAULT_MOONSHOT_MODEL); + } + + #[test] + fn deepseek_default_text_model_legacy_alias_still_overrides_active_provider_model() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + // Safety: test-only env mutation guarded by env_lock(). + unsafe { + env::set_var("CODEWHALE_PROVIDER", "moonshot"); + env::set_var("DEEPSEEK_DEFAULT_TEXT_MODEL", "legacy-env-model"); + } + let config = ConfigToml::default(); + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Moonshot); + assert_eq!(resolved.model, "legacy-env-model"); + } + #[test] fn wanjie_ark_provider_defaults_to_openai_compatible_endpoint_and_model() { let _lock = env_lock(); diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 2d0f05ae9..fa6211cbc 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -7,11 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **`CODEWHALE_*` env aliases.** `CODEWHALE_PROVIDER`, `CODEWHALE_MODEL`, + and `CODEWHALE_BASE_URL` are public aliases that take precedence over + the legacy `DEEPSEEK_*` forms. The `DEEPSEEK_*` names remain accepted + for back-compat. Recommended setup paths are `codewhale --provider `, + `provider = ""` in `~/.codewhale/config.toml`, or + `CODEWHALE_PROVIDER=` — never `DEEPSEEK_PROVIDER=` in + user-facing docs. + ### Fixed - **Kimi Code API-key setup.** `codewhale config set providers.moonshot.*` now writes the Moonshot/Kimi provider table, and Kimi Code API-key endpoints default to `kimi-for-coding` without using the Kimi CLI OAuth path. +- **Kimi Code model precedence.** When `[providers.moonshot]` points at the + Kimi Code endpoint, the resolver now returns `kimi-for-coding` even when + a root DeepSeek `default_text_model` (e.g. `deepseek-v4-pro`) is still in + the config from a previous setup. ## [0.8.45] - 2026-05-25 diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 78975ee33..2b0a7091f 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -1554,6 +1554,19 @@ impl Config { } } } + let moonshot_config = (provider == ApiProvider::Moonshot) + .then(|| self.provider_config()) + .flatten(); + let moonshot_uses_kimi_code = moonshot_config.is_some_and(|config| { + provider_config_uses_kimi_oauth(config) + || config + .base_url + .as_deref() + .is_some_and(moonshot_base_url_uses_kimi_code) + }); + if moonshot_uses_kimi_code { + return DEFAULT_KIMI_CODE_MODEL.to_string(); + } if let Some(model) = self.default_text_model.as_deref() && (provider_passes_model_through(provider) || self.active_provider_preserves_custom_base_url_model()) @@ -1570,19 +1583,6 @@ impl Config { { return model_for_provider(provider, normalized); } - let moonshot_config = (provider == ApiProvider::Moonshot) - .then(|| self.provider_config()) - .flatten(); - let moonshot_uses_kimi_code = moonshot_config.is_some_and(|config| { - provider_config_uses_kimi_oauth(config) - || config - .base_url - .as_deref() - .is_some_and(moonshot_base_url_uses_kimi_code) - }); - if moonshot_uses_kimi_code { - return DEFAULT_KIMI_CODE_MODEL.to_string(); - } match provider { ApiProvider::Deepseek | ApiProvider::DeepseekCN => DEFAULT_TEXT_MODEL, @@ -2271,11 +2271,29 @@ fn default_memory_path() -> Option { // === Environment Overrides === +/// Read a CodeWhale env var, preferring the `CODEWHALE_*` form over the +/// legacy `DEEPSEEK_*` form. Empty values are ignored so a blank shell export +/// does not erase configured provider settings. +fn codewhale_env_var( + codewhale_name: &str, + legacy_name: &str, +) -> Result { + std::env::var(codewhale_name) + .ok() + .filter(|value| !value.trim().is_empty()) + .or_else(|| { + std::env::var(legacy_name) + .ok() + .filter(|value| !value.trim().is_empty()) + }) + .ok_or(std::env::VarError::NotPresent) +} + fn apply_env_overrides(config: &mut Config) { - if let Ok(value) = std::env::var("DEEPSEEK_PROVIDER") { + if let Ok(value) = codewhale_env_var("CODEWHALE_PROVIDER", "DEEPSEEK_PROVIDER") { config.provider = Some(value); } - if let Ok(value) = std::env::var("DEEPSEEK_BASE_URL") { + if let Ok(value) = codewhale_env_var("CODEWHALE_BASE_URL", "DEEPSEEK_BASE_URL") { match config.api_provider() { ApiProvider::Deepseek | ApiProvider::DeepseekCN => { config.base_url = Some(value); @@ -2558,8 +2576,13 @@ fn apply_env_overrides(config: &mut Config) { .moonshot .model = Some(value); } - if let Ok(value) = - std::env::var("DEEPSEEK_MODEL").or_else(|_| std::env::var("DEEPSEEK_DEFAULT_TEXT_MODEL")) + if let Some(value) = codewhale_env_var("CODEWHALE_MODEL", "DEEPSEEK_MODEL") + .ok() + .or_else(|| { + std::env::var("DEEPSEEK_DEFAULT_TEXT_MODEL") + .ok() + .filter(|value| !value.trim().is_empty()) + }) { // The CLI `--model` handoff always sets DEEPSEEK_MODEL, never the // provider-specific *_MODEL var. The legacy root `default_text_model` @@ -4075,6 +4098,9 @@ mod tests { deepseek_http_headers: Option, deepseek_model: Option, deepseek_default_text_model: Option, + codewhale_provider: Option, + codewhale_model: Option, + codewhale_base_url: Option, nvidia_api_key: Option, nvidia_nim_api_key: Option, nim_base_url: Option, @@ -4137,6 +4163,9 @@ mod tests { let http_headers_prev = env::var_os("DEEPSEEK_HTTP_HEADERS"); let model_prev = env::var_os("DEEPSEEK_MODEL"); let default_text_model_prev = env::var_os("DEEPSEEK_DEFAULT_TEXT_MODEL"); + let codewhale_provider_prev = env::var_os("CODEWHALE_PROVIDER"); + let codewhale_model_prev = env::var_os("CODEWHALE_MODEL"); + let codewhale_base_url_prev = env::var_os("CODEWHALE_BASE_URL"); let nvidia_api_key_prev = env::var_os("NVIDIA_API_KEY"); let nvidia_nim_api_key_prev = env::var_os("NVIDIA_NIM_API_KEY"); let nim_base_url_prev = env::var_os("NIM_BASE_URL"); @@ -4194,6 +4223,9 @@ mod tests { env::remove_var("DEEPSEEK_HTTP_HEADERS"); env::remove_var("DEEPSEEK_MODEL"); env::remove_var("DEEPSEEK_DEFAULT_TEXT_MODEL"); + env::remove_var("CODEWHALE_PROVIDER"); + env::remove_var("CODEWHALE_MODEL"); + env::remove_var("CODEWHALE_BASE_URL"); env::remove_var("NVIDIA_API_KEY"); env::remove_var("NVIDIA_NIM_API_KEY"); env::remove_var("NIM_BASE_URL"); @@ -4251,6 +4283,9 @@ mod tests { deepseek_http_headers: http_headers_prev, deepseek_model: model_prev, deepseek_default_text_model: default_text_model_prev, + codewhale_provider: codewhale_provider_prev, + codewhale_model: codewhale_model_prev, + codewhale_base_url: codewhale_base_url_prev, nvidia_api_key: nvidia_api_key_prev, nvidia_nim_api_key: nvidia_nim_api_key_prev, nim_base_url: nim_base_url_prev, @@ -4317,6 +4352,9 @@ mod tests { "DEEPSEEK_DEFAULT_TEXT_MODEL", self.deepseek_default_text_model.take(), ); + Self::restore_var("CODEWHALE_PROVIDER", self.codewhale_provider.take()); + Self::restore_var("CODEWHALE_MODEL", self.codewhale_model.take()); + Self::restore_var("CODEWHALE_BASE_URL", self.codewhale_base_url.take()); Self::restore_var("NVIDIA_API_KEY", self.nvidia_api_key.take()); Self::restore_var("NVIDIA_NIM_API_KEY", self.nvidia_nim_api_key.take()); Self::restore_var("NIM_BASE_URL", self.nim_base_url.take()); @@ -6484,6 +6522,162 @@ base_url = "https://api.kimi.com/coding/v1" Ok(()) } + /// Regression for issue #2160: a stale root `default_text_model` carried + /// over from a DeepSeek setup must not steer the Kimi Code endpoint to + /// `deepseek-v4-pro`. The user-facing trigger here is the legacy + /// `DEEPSEEK_PROVIDER` env var (still produced by the `codewhale + /// --provider moonshot` dispatcher for compat); the test also has a + /// `CODEWHALE_PROVIDER` twin below for the public env path. + #[test] + fn moonshot_kimi_code_model_overrides_root_deepseek_default() -> 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-kimi-code-root-model-{}-{}", + 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 = "deepseek" +default_text_model = "deepseek-v4-pro" + +[providers.moonshot] +api_key = "kimi-code-key" +base_url = "https://api.kimi.com/coding/v1" +"#, + )?; + // Safety: test-only env mutation guarded by lock_test_env(). + unsafe { env::set_var("DEEPSEEK_PROVIDER", "moonshot") }; + + let config = Config::load(None, None)?; + assert_eq!(config.api_provider(), ApiProvider::Moonshot); + assert_eq!(config.deepseek_base_url(), DEFAULT_KIMI_CODE_BASE_URL); + assert_eq!(config.default_model(), DEFAULT_KIMI_CODE_MODEL); + Ok(()) + } + + /// Same regression as above, but driven by the public `CODEWHALE_PROVIDER` + /// env var. Documents the recommended user-facing setup path: never + /// `DEEPSEEK_PROVIDER=moonshot`, always `CODEWHALE_PROVIDER=moonshot` + /// (or `codewhale --provider moonshot`, which also resolves through + /// this code path internally). + #[test] + fn moonshot_kimi_code_model_resolves_via_codewhale_provider_env() -> 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-kimi-code-cw-env-{}-{}", + 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 = "deepseek" +default_text_model = "deepseek-v4-pro" + +[providers.moonshot] +api_key = "kimi-code-key" +base_url = "https://api.kimi.com/coding/v1" +"#, + )?; + // Safety: test-only env mutation guarded by lock_test_env(). + unsafe { env::set_var("CODEWHALE_PROVIDER", "moonshot") }; + + let config = Config::load(None, None)?; + assert_eq!(config.api_provider(), ApiProvider::Moonshot); + assert_eq!(config.deepseek_base_url(), DEFAULT_KIMI_CODE_BASE_URL); + assert_eq!(config.default_model(), DEFAULT_KIMI_CODE_MODEL); + Ok(()) + } + + /// `CODEWHALE_PROVIDER` wins when both it and the legacy + /// `DEEPSEEK_PROVIDER` are set, so a user adding the new alias to their + /// shell isn't surprised by a stale legacy export. + #[test] + fn codewhale_provider_env_takes_precedence_over_deepseek_provider() -> 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-cw-vs-ds-provider-{}-{}", + 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, "provider = \"deepseek\"\n")?; + // Safety: test-only env mutation guarded by lock_test_env(). + unsafe { + env::set_var("CODEWHALE_PROVIDER", "moonshot"); + env::set_var("DEEPSEEK_PROVIDER", "openrouter"); + } + + let config = Config::load(None, None)?; + assert_eq!(config.api_provider(), ApiProvider::Moonshot); + Ok(()) + } + + /// Moonshot Platform path: when [providers.moonshot] is empty (or + /// missing) and no Kimi Code endpoint is configured, the resolver + /// defaults to the Moonshot Platform base URL and the `kimi-k2.6` + /// model. This is the "I have a Moonshot Platform API key, not a + /// Kimi Code plan key" path. + #[test] + fn moonshot_platform_defaults_to_kimi_k26() -> 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-moonshot-platform-{}-{}", + 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 = "moonshot" + +[providers.moonshot] +api_key = "moonshot-platform-key" +"#, + )?; + + let config = Config::load(None, None)?; + assert_eq!(config.api_provider(), ApiProvider::Moonshot); + assert_eq!(config.deepseek_base_url(), DEFAULT_MOONSHOT_BASE_URL); + assert_eq!(config.default_model(), DEFAULT_MOONSHOT_MODEL); + assert_eq!(config.deepseek_api_key()?, "moonshot-platform-key"); + Ok(()) + } + #[test] fn has_api_key_for_detects_env_and_config_per_provider() -> Result<()> { let _lock = lock_test_env(); diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index c63e5b9bd..8c0adefcd 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -200,13 +200,22 @@ If a profile is selected but missing, codewhale exits with an error listing avai ## Environment Variables Most runtime environment variables override config values. API-key variables are -fallbacks after saved config and keyring credentials: +fallbacks after saved config and keyring credentials. + +The three user-facing slots — provider, model, base URL — expose `CODEWHALE_*` +aliases. When both forms are set the `CODEWHALE_*` value wins; the +`DEEPSEEK_*` form is kept for older shells: + +- `CODEWHALE_PROVIDER` (preferred) / `DEEPSEEK_PROVIDER` (legacy alias) — + `deepseek|nvidia-nim|openai|atlascloud|wanjie-ark|openrouter|novita|fireworks|moonshot|sglang|vllm|ollama` +- `CODEWHALE_MODEL` (preferred) / `DEEPSEEK_MODEL` (legacy alias) — default model for the active provider +- `CODEWHALE_BASE_URL` (preferred) / `DEEPSEEK_BASE_URL` (legacy alias) — base URL for the active provider + +Remaining variables: - `DEEPSEEK_API_KEY` -- `DEEPSEEK_BASE_URL` - `DEEPSEEK_HTTP_HEADERS` (custom model request headers, comma-separated `name=value` pairs) -- `DEEPSEEK_PROVIDER` (`codewhale|nvidia-nim|openai|atlascloud|wanjie-ark|openrouter|novita|fireworks|moonshot|sglang|vllm|ollama`) -- `DEEPSEEK_MODEL` or `DEEPSEEK_DEFAULT_TEXT_MODEL` +- `DEEPSEEK_DEFAULT_TEXT_MODEL` (extra legacy alias of `DEEPSEEK_MODEL`) - `DEEPSEEK_STREAM_IDLE_TIMEOUT_SECS` (stream idle timeout in seconds; default `300`, clamped to `1..=3600`) - `DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS` (connection setup + response-header wait in seconds; default `45`, clamped to `5..=300`; distinct from the per-chunk idle timeout) - `NVIDIA_API_KEY` or `NVIDIA_NIM_API_KEY` (preferred when provider is `nvidia-nim`; falls back to `DEEPSEEK_API_KEY`)