fix(config): preserve OpenRouter custom endpoint models

When OpenRouter is pointed at a custom base_url, keep explicit model values verbatim instead of remapping DeepSeek aliases to OpenRouter catalog IDs.

Add config coverage for both the dispatcher config crate and the TUI config loader, while preserving existing provider alias behavior such as NVIDIA NIM.

Closes #857
This commit is contained in:
THINKER_ONLY
2026-05-07 21:01:25 +08:00
committed by Hunter Bown
parent 9d86ddb480
commit 4aee8a15c6
3 changed files with 157 additions and 15 deletions
+69 -12
View File
@@ -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<ProviderKind>,
@@ -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;
+87 -2
View File
@@ -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();
+1 -1
View File
@@ -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.