diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index f4d6c425..d0400945 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -888,6 +888,11 @@ impl ConfigToml { ProviderKind::Ollama => DEFAULT_OLLAMA_BASE_URL.to_string(), }); + let explicit_model = cli.model.is_some() + || env.model.is_some() + || provider_cfg.model.is_some() + || root_deepseek_model.is_some() + || self.model.is_some(); let model = cli .model .clone() @@ -895,18 +900,13 @@ impl ConfigToml { .or_else(|| provider_cfg.model.clone()) .or(root_deepseek_model) .or_else(|| self.model.clone()) - .unwrap_or_else(|| match provider { - ProviderKind::Deepseek => DEFAULT_DEEPSEEK_MODEL.to_string(), - ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_MODEL.to_string(), - ProviderKind::Openai => DEFAULT_OPENAI_MODEL.to_string(), - ProviderKind::Openrouter => DEFAULT_OPENROUTER_MODEL.to_string(), - ProviderKind::Novita => DEFAULT_NOVITA_MODEL.to_string(), - ProviderKind::Fireworks => DEFAULT_FIREWORKS_MODEL.to_string(), - ProviderKind::Sglang => DEFAULT_SGLANG_MODEL.to_string(), - ProviderKind::Vllm => DEFAULT_VLLM_MODEL.to_string(), - ProviderKind::Ollama => DEFAULT_OLLAMA_MODEL.to_string(), - }); - let model = normalize_model_for_provider(provider, &model); + .unwrap_or_else(|| default_model_for_provider(provider).to_string()); + let model = + if explicit_model && provider_preserves_custom_base_url_model(provider, &base_url) { + model.trim().to_string() + } else { + normalize_model_for_provider(provider, &model) + }; let mut http_headers = self.http_headers.clone(); http_headers.extend(provider_cfg.http_headers.clone()); @@ -1043,6 +1043,45 @@ fn normalize_model_for_provider(provider: ProviderKind, model: &str) -> String { } } +fn default_model_for_provider(provider: ProviderKind) -> &'static str { + match provider { + ProviderKind::Deepseek => DEFAULT_DEEPSEEK_MODEL, + ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_MODEL, + ProviderKind::Openai => DEFAULT_OPENAI_MODEL, + ProviderKind::Openrouter => DEFAULT_OPENROUTER_MODEL, + ProviderKind::Novita => DEFAULT_NOVITA_MODEL, + ProviderKind::Fireworks => DEFAULT_FIREWORKS_MODEL, + ProviderKind::Sglang => DEFAULT_SGLANG_MODEL, + ProviderKind::Vllm => DEFAULT_VLLM_MODEL, + ProviderKind::Ollama => DEFAULT_OLLAMA_MODEL, + } +} + +fn default_base_url_for_provider(provider: ProviderKind) -> &'static str { + match provider { + ProviderKind::Deepseek => DEFAULT_DEEPSEEK_BASE_URL, + ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL, + ProviderKind::Openai => DEFAULT_OPENAI_BASE_URL, + ProviderKind::Openrouter => DEFAULT_OPENROUTER_BASE_URL, + ProviderKind::Novita => DEFAULT_NOVITA_BASE_URL, + ProviderKind::Fireworks => DEFAULT_FIREWORKS_BASE_URL, + ProviderKind::Sglang => DEFAULT_SGLANG_BASE_URL, + ProviderKind::Vllm => DEFAULT_VLLM_BASE_URL, + ProviderKind::Ollama => DEFAULT_OLLAMA_BASE_URL, + } +} + +fn base_url_is_custom_for_provider(provider: ProviderKind, base_url: &str) -> bool { + let actual = base_url.trim_end_matches('/'); + let default = default_base_url_for_provider(provider).trim_end_matches('/'); + actual != default +} + +fn provider_preserves_custom_base_url_model(provider: ProviderKind, base_url: &str) -> bool { + matches!(provider, ProviderKind::Openrouter) + && base_url_is_custom_for_provider(provider, base_url) +} + #[derive(Debug, Clone, Default)] pub struct CliRuntimeOverrides { pub provider: Option, @@ -2080,6 +2119,24 @@ mod tests { assert_eq!(resolved.base_url, "https://or-mirror.example/v1"); } + #[test] + fn openrouter_custom_base_url_preserves_provider_model() { + let _lock = env_lock(); + let _env = EnvGuard::without_deepseek_runtime_overrides(); + let mut config = ConfigToml { + provider: ProviderKind::Openrouter, + ..ConfigToml::default() + }; + config.providers.openrouter.base_url = Some("https://gateway.example.com/v1".to_string()); + config.providers.openrouter.model = Some("DeepSeek-V4-Pro".to_string()); + + let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default()); + + assert_eq!(resolved.provider, ProviderKind::Openrouter); + assert_eq!(resolved.base_url, "https://gateway.example.com/v1"); + assert_eq!(resolved.model, "DeepSeek-V4-Pro"); + } + #[test] fn config_file_resolves_above_env_and_keyring() { use deepseek_secrets::KeyringStore; diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 7291edcf..e7de631e 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -1108,6 +1108,7 @@ impl Config { if let Some(model) = self.default_text_model.as_deref() && !model.trim().eq_ignore_ascii_case("auto") && !provider_passes_model_through(self.api_provider()) + && !self.active_provider_preserves_custom_base_url_model() && normalize_model_name(model).is_none() { anyhow::bail!( @@ -1239,7 +1240,9 @@ impl Config { .provider_config() .and_then(|provider| provider.model.as_deref()) { - if provider_passes_model_through(provider) { + if provider_passes_model_through(provider) + || self.active_provider_preserves_custom_base_url_model() + { return model.trim().to_string(); } if let Some(normalized) = normalize_model_for_provider(provider, model) { @@ -1247,7 +1250,8 @@ impl Config { } } if let Some(model) = self.default_text_model.as_deref() - && provider_passes_model_through(provider) + && (provider_passes_model_through(provider) + || self.active_provider_preserves_custom_base_url_model()) { return model.trim().to_string(); } @@ -1320,6 +1324,11 @@ impl Config { normalize_base_url(&base) } + fn active_provider_preserves_custom_base_url_model(&self) -> bool { + let provider = self.api_provider(); + provider_preserves_custom_base_url_model(provider, &self.deepseek_base_url()) + } + /// Read the API key. /// /// Precedence: **explicit in-memory override → provider/root config @@ -2194,6 +2203,7 @@ fn apply_env_overrides(config: &mut Config) { fn normalize_model_config(config: &mut Config) { if let Some(model) = config.default_text_model.as_deref() && !provider_passes_model_through(config.api_provider()) + && !config.active_provider_preserves_custom_base_url_model() && let Some(normalized) = normalize_model_for_provider(config.api_provider(), model) { config.default_text_model = Some(normalized); @@ -2201,41 +2211,49 @@ fn normalize_model_config(config: &mut Config) { if let Some(providers) = config.providers.as_mut() { if let Some(model) = providers.deepseek.model.as_deref() + && !provider_entry_uses_custom_base_url(ApiProvider::Deepseek, &providers.deepseek) && let Some(normalized) = normalize_model_for_provider(ApiProvider::Deepseek, model) { providers.deepseek.model = Some(normalized); } if let Some(model) = providers.deepseek_cn.model.as_deref() + && !provider_entry_uses_custom_base_url(ApiProvider::DeepseekCN, &providers.deepseek_cn) && let Some(normalized) = normalize_model_for_provider(ApiProvider::DeepseekCN, model) { providers.deepseek_cn.model = Some(normalized); } if let Some(model) = providers.nvidia_nim.model.as_deref() + && !provider_entry_uses_custom_base_url(ApiProvider::NvidiaNim, &providers.nvidia_nim) && let Some(normalized) = normalize_model_for_provider(ApiProvider::NvidiaNim, model) { providers.nvidia_nim.model = Some(normalized); } if let Some(model) = providers.openrouter.model.as_deref() + && !provider_entry_uses_custom_base_url(ApiProvider::Openrouter, &providers.openrouter) && let Some(normalized) = normalize_model_for_provider(ApiProvider::Openrouter, model) { providers.openrouter.model = Some(normalized); } if let Some(model) = providers.novita.model.as_deref() + && !provider_entry_uses_custom_base_url(ApiProvider::Novita, &providers.novita) && let Some(normalized) = normalize_model_for_provider(ApiProvider::Novita, model) { providers.novita.model = Some(normalized); } if let Some(model) = providers.fireworks.model.as_deref() + && !provider_entry_uses_custom_base_url(ApiProvider::Fireworks, &providers.fireworks) && let Some(normalized) = normalize_model_for_provider(ApiProvider::Fireworks, model) { providers.fireworks.model = Some(normalized); } if let Some(model) = providers.sglang.model.as_deref() + && !provider_entry_uses_custom_base_url(ApiProvider::Sglang, &providers.sglang) && let Some(normalized) = normalize_model_for_provider(ApiProvider::Sglang, model) { providers.sglang.model = Some(normalized); } if let Some(model) = providers.vllm.model.as_deref() + && !provider_entry_uses_custom_base_url(ApiProvider::Vllm, &providers.vllm) && let Some(normalized) = normalize_model_for_provider(ApiProvider::Vllm, model) { providers.vllm.model = Some(normalized); @@ -2254,6 +2272,37 @@ fn provider_passes_model_through(provider: ApiProvider) -> bool { matches!(provider, ApiProvider::Openai | ApiProvider::Ollama) } +fn provider_entry_uses_custom_base_url(provider: ApiProvider, entry: &ProviderConfig) -> bool { + entry + .base_url + .as_deref() + .is_some_and(|base_url| provider_preserves_custom_base_url_model(provider, base_url)) +} + +fn default_base_url_for_provider(provider: ApiProvider) -> &'static str { + match provider { + ApiProvider::Deepseek => DEFAULT_DEEPSEEK_BASE_URL, + ApiProvider::DeepseekCN => DEFAULT_DEEPSEEKCN_BASE_URL, + ApiProvider::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL, + ApiProvider::Openai => DEFAULT_OPENAI_BASE_URL, + ApiProvider::Openrouter => DEFAULT_OPENROUTER_BASE_URL, + ApiProvider::Novita => DEFAULT_NOVITA_BASE_URL, + ApiProvider::Fireworks => DEFAULT_FIREWORKS_BASE_URL, + ApiProvider::Sglang => DEFAULT_SGLANG_BASE_URL, + ApiProvider::Vllm => DEFAULT_VLLM_BASE_URL, + ApiProvider::Ollama => DEFAULT_OLLAMA_BASE_URL, + } +} + +fn base_url_is_custom_for_provider(provider: ApiProvider, base_url: &str) -> bool { + normalize_base_url(base_url) != normalize_base_url(default_base_url_for_provider(provider)) +} + +fn provider_preserves_custom_base_url_model(provider: ApiProvider, base_url: &str) -> bool { + matches!(provider, ApiProvider::Openrouter) + && base_url_is_custom_for_provider(provider, base_url) +} + fn model_for_provider(provider: ApiProvider, normalized: String) -> String { let lowered = normalized.to_ascii_lowercase(); match (provider, lowered.as_str()) { @@ -4777,6 +4826,42 @@ base_url = "https://or-table.example/v1" Ok(()) } + #[test] + fn openrouter_custom_base_url_preserves_provider_model() -> 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!( + "deepseek-tui-or-custom-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 = "openrouter" + +[providers.openrouter] +api_key = "or-table-key" +base_url = "https://gateway.example.com/v1" +model = "DeepSeek-V4-Pro" +"#, + )?; + + let config = Config::load(None, None)?; + assert_eq!(config.api_provider(), ApiProvider::Openrouter); + assert_eq!(config.deepseek_api_key()?, "or-table-key"); + assert_eq!(config.deepseek_base_url(), "https://gateway.example.com/v1"); + assert_eq!(config.default_model(), "DeepSeek-V4-Pro"); + Ok(()) + } + #[test] fn novita_reads_provider_table_from_config_file() -> Result<()> { let _lock = lock_test_env(); diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index cc8d9c04..90bafbc2 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -359,7 +359,7 @@ If you are upgrading from older releases: - `provider` (string, optional): `deepseek` (default), `deepseek-cn`, `nvidia-nim`, `openai`, `openrouter`, `novita`, `fireworks`, `sglang`, `vllm`, or `ollama`. `deepseek-cn` presets DeepSeek Platform for mainland China with the documented host [`https://api.deepseek.com`](https://api-docs.deepseek.com/) (distinct from typo `api.deepseeki.com`, which older configs may still carry and the client accepts as a DeepSeek-compatible host); `nvidia-nim` targets NVIDIA's NIM-hosted DeepSeek endpoints through `https://integrate.api.nvidia.com/v1`; `openai` targets a generic OpenAI-compatible endpoint, defaulting to `https://api.openai.com/v1`; `fireworks` targets `https://api.fireworks.ai/inference/v1`; `sglang` targets a self-hosted OpenAI-compatible endpoint, defaulting to `http://localhost:30000/v1`; `vllm` targets a self-hosted vLLM OpenAI-compatible endpoint, defaulting to `http://localhost:8000/v1`; `ollama` targets Ollama's OpenAI-compatible endpoint, defaulting to `http://localhost:11434/v1`. - `api_key` (string, required for hosted providers): must be non-empty for DeepSeek/hosted providers (or set the provider API key env var). Self-hosted SGLang, vLLM, and Ollama can omit it. - `base_url` (string, optional): defaults to `https://api.deepseek.com/beta` for DeepSeek's OpenAI-compatible Chat Completions API in v0.8.16, `https://api.deepseek.com` for `provider = "deepseek-cn"`, `https://api.openai.com/v1` for `provider = "openai"`, or the provider-specific endpoint for hosted/self-hosted providers. Set `https://api.deepseek.com` or `https://api.deepseek.com/v1` explicitly to opt out of DeepSeek beta features. -- `default_text_model` (string, optional): defaults to `deepseek-v4-pro` for DeepSeek, `deepseek-ai/deepseek-v4-pro` for NVIDIA NIM, `gpt-4.1` for generic OpenAI-compatible endpoints, `accounts/fireworks/models/deepseek-v4-pro` for Fireworks, `deepseek-ai/DeepSeek-V4-Pro` for SGLang/vLLM, and `deepseek-coder:1.3b` for Ollama. Current public DeepSeek IDs are `deepseek-v4-pro` and `deepseek-v4-flash`, both with 1M context windows, 384K max output, and thinking mode enabled by default. Legacy `deepseek-chat` and `deepseek-reasoner` remain compatibility aliases for `deepseek-v4-flash` until July 24, 2026. Provider-specific mappings translate `deepseek-v4-pro` / `deepseek-v4-flash` to each provider's model ID where supported. Generic `openai` and Ollama model IDs are passed through unchanged. Use `/models` or `deepseek models` to discover live IDs from your configured endpoint. `DEEPSEEK_MODEL` overrides this for a single process. +- `default_text_model` (string, optional): defaults to `deepseek-v4-pro` for DeepSeek, `deepseek-ai/deepseek-v4-pro` for NVIDIA NIM, `gpt-4.1` for generic OpenAI-compatible endpoints, `accounts/fireworks/models/deepseek-v4-pro` for Fireworks, `deepseek-ai/DeepSeek-V4-Pro` for SGLang/vLLM, and `deepseek-coder:1.3b` for Ollama. Current public DeepSeek IDs are `deepseek-v4-pro` and `deepseek-v4-flash`, both with 1M context windows, 384K max output, and thinking mode enabled by default. Legacy `deepseek-chat` and `deepseek-reasoner` remain compatibility aliases for `deepseek-v4-flash` until July 24, 2026. Provider-specific mappings translate `deepseek-v4-pro` / `deepseek-v4-flash` to each provider's model ID where supported. Generic `openai` and Ollama model IDs are passed through unchanged. OpenRouter provider configs with a custom `base_url` also preserve explicit model values, which lets OpenAI-compatible gateways accept bare model IDs. Use `/models` or `deepseek models` to discover live IDs from your configured endpoint. `DEEPSEEK_MODEL` overrides this for a single process. - `reasoning_effort` (string, optional): `off`, `low`, `medium`, `high`, or `max`; defaults to the configured UI tier. DeepSeek Platform receives top-level `thinking` / `reasoning_effort` fields. NVIDIA NIM receives equivalent settings through `chat_template_kwargs`. - `allow_shell` (bool, optional): defaults to `true` (sandboxed). - `approval_policy` (string, optional): `on-request`, `untrusted`, or `never`. Runtime `approval_mode` editing in `/config` also accepts `on-request` and `untrusted` aliases.