From eef16f45fdc8b4c062497f0a1646700f447a41aa Mon Sep 17 00:00:00 2001 From: Reid <61492567+reidliu41@users.noreply.github.com> Date: Fri, 15 May 2026 02:39:31 +0800 Subject: [PATCH] fix(model): canonicalize DeepSeek model completions Deduplicate official DeepSeek model completions and normalize known prefixed aliases to the bare model IDs expected by official DeepSeek providers, while preserving provider-specific IDs for compatible backends.\n\nFixes #1594.\n\nCo-authored-by: reidliu41 --- crates/tui/src/commands/config.rs | 4 +- crates/tui/src/commands/core.rs | 4 +- crates/tui/src/config.rs | 90 +++++++++++++++++++++++++++++++ crates/tui/src/tui/slash_menu.rs | 2 + crates/tui/src/tui/widgets/mod.rs | 78 +++++++++++++++++++++++---- 5 files changed, 165 insertions(+), 13 deletions(-) diff --git a/crates/tui/src/commands/config.rs b/crates/tui/src/commands/config.rs index 563ef6d4..435695c8 100644 --- a/crates/tui/src/commands/config.rs +++ b/crates/tui/src/commands/config.rs @@ -5,7 +5,7 @@ use std::time::Duration; use super::CommandResult; use crate::client::DeepSeekClient; -use crate::config::{COMMON_DEEPSEEK_MODELS, clear_api_key, normalize_model_name}; +use crate::config::{COMMON_DEEPSEEK_MODELS, clear_api_key, normalize_model_name_for_provider}; use crate::config_ui::{ConfigUiMode, parse_mode}; use crate::llm_client::LlmClient; use crate::localization::resolve_locale; @@ -386,7 +386,7 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> // Clear auto mode when a specific model is set app.auto_model = false; app.last_effective_model = None; - let Some(model) = normalize_model_name(value) else { + let Some(model) = normalize_model_name_for_provider(app.api_provider, value) else { return CommandResult::error(format!( "Invalid model '{value}'. Expected a DeepSeek model ID. Common models: {}", COMMON_DEEPSEEK_MODELS.join(", ") diff --git a/crates/tui/src/commands/core.rs b/crates/tui/src/commands/core.rs index c75660a2..fe64fec0 100644 --- a/crates/tui/src/commands/core.rs +++ b/crates/tui/src/commands/core.rs @@ -3,7 +3,7 @@ use std::fmt::Write; use std::path::PathBuf; -use crate::config::{COMMON_DEEPSEEK_MODELS, normalize_model_name}; +use crate::config::{COMMON_DEEPSEEK_MODELS, normalize_model_name_for_provider}; use crate::localization::{MessageId, tr}; use crate::tui::app::{App, AppAction, AppMode, ReasoningEffort}; use crate::tui::views::{HelpView, ModalKind, SubAgentsView, subagent_view_agents}; @@ -121,7 +121,7 @@ pub fn model(app: &mut App, model_name: Option<&str>) -> CommandResult { AppAction::UpdateCompaction(app.compaction_config()), ); } - let Some(model_id) = normalize_model_name(name) else { + let Some(model_id) = normalize_model_name_for_provider(app.api_provider, name) else { return CommandResult::error(format!( "Invalid model '{name}'. Expected auto or a DeepSeek model ID. Common models: {}", COMMON_DEEPSEEK_MODELS.join(", ") diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 3d7bab52..672a4f62 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -60,6 +60,7 @@ pub const COMMON_DEEPSEEK_MODELS: &[&str] = &[ "deepseek/deepseek-v4-pro", "deepseek/deepseek-v4-flash", ]; +pub const OFFICIAL_DEEPSEEK_MODELS: &[&str] = &["deepseek-v4-pro", "deepseek-v4-flash"]; #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "snake_case")] @@ -351,6 +352,60 @@ pub fn normalize_model_name(model: &str) -> Option { None } +fn canonical_official_deepseek_model_id(model: &str) -> Option<&'static str> { + match model.trim().to_ascii_lowercase().as_str() { + "deepseek-v4-pro" + | "deepseek-v4pro" + | "deepseek-ai/deepseek-v4-pro" + | "deepseek-ai/deepseek-v4pro" + | "deepseek/deepseek-v4-pro" + | "deepseek/deepseek-v4pro" => Some("deepseek-v4-pro"), + "deepseek-v4-flash" + | "deepseek-v4flash" + | "deepseek-ai/deepseek-v4-flash" + | "deepseek-ai/deepseek-v4flash" + | "deepseek/deepseek-v4-flash" + | "deepseek/deepseek-v4flash" => Some("deepseek-v4-flash"), + _ => None, + } +} + +/// Normalize a model selected through the TUI for the active provider. +/// +/// Official DeepSeek endpoints require bare model IDs. Provider-prefixed +/// aliases are valid for some compatible backends, but sending them to +/// DeepSeek's own API causes a 400. Keep the generic normalizer permissive for +/// config/back-compat, and canonicalize only when the active provider is known. +#[must_use] +pub fn normalize_model_name_for_provider(provider: ApiProvider, model: &str) -> Option { + let normalized = normalize_model_name(model)?; + if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) + && let Some(canonical) = canonical_official_deepseek_model_id(&normalized) + { + return Some(canonical.to_string()); + } + if let Some(canonical) = canonical_official_deepseek_model_id(&normalized) { + return Some(model_for_provider(provider, canonical.to_string())); + } + Some(normalized) +} + +#[must_use] +pub fn model_completion_names_for_provider(provider: ApiProvider) -> Vec<&'static str> { + match provider { + ApiProvider::Deepseek | ApiProvider::DeepseekCN => OFFICIAL_DEEPSEEK_MODELS.to_vec(), + ApiProvider::NvidiaNim => vec![DEFAULT_NVIDIA_NIM_MODEL, DEFAULT_NVIDIA_NIM_FLASH_MODEL], + ApiProvider::Openrouter => vec![DEFAULT_OPENROUTER_MODEL, DEFAULT_OPENROUTER_FLASH_MODEL], + ApiProvider::Novita => vec![DEFAULT_NOVITA_MODEL, DEFAULT_NOVITA_FLASH_MODEL], + ApiProvider::Fireworks => vec![DEFAULT_FIREWORKS_MODEL], + ApiProvider::Sglang => vec![DEFAULT_SGLANG_MODEL, DEFAULT_SGLANG_FLASH_MODEL], + ApiProvider::Vllm => vec![DEFAULT_VLLM_MODEL, DEFAULT_VLLM_FLASH_MODEL], + ApiProvider::Openai | ApiProvider::Atlascloud | ApiProvider::Ollama => { + OFFICIAL_DEEPSEEK_MODELS.to_vec() + } + } +} + // === Types === /// Raw retry configuration loaded from config files. @@ -4499,6 +4554,41 @@ api_key = "old-openrouter-key" ); } + #[test] + fn normalize_model_name_for_provider_canonicalizes_deepseek_api_variants() { + assert_eq!( + normalize_model_name_for_provider(ApiProvider::Deepseek, "deepseek-ai/DeepSeek-V4-Pro") + .as_deref(), + Some("deepseek-v4-pro") + ); + assert_eq!( + normalize_model_name_for_provider(ApiProvider::Deepseek, "deepseek/deepseek-v4-flash") + .as_deref(), + Some("deepseek-v4-flash") + ); + } + + #[test] + fn normalize_model_name_for_provider_keeps_provider_specific_ids() { + assert_eq!( + normalize_model_name_for_provider(ApiProvider::NvidiaNim, "deepseek-v4-pro").as_deref(), + Some(DEFAULT_NVIDIA_NIM_MODEL) + ); + assert_eq!( + normalize_model_name_for_provider(ApiProvider::Openrouter, "deepseek-v4-flash") + .as_deref(), + Some(DEFAULT_OPENROUTER_FLASH_MODEL) + ); + } + + #[test] + fn model_completion_names_for_deepseek_api_are_deduplicated_bare_ids() { + assert_eq!( + model_completion_names_for_provider(ApiProvider::Deepseek), + vec!["deepseek-v4-pro", "deepseek-v4-flash"] + ); + } + #[test] fn normalize_model_name_rejects_invalid_or_non_deepseek_ids() { assert!(normalize_model_name("gpt-4o").is_none()); diff --git a/crates/tui/src/tui/slash_menu.rs b/crates/tui/src/tui/slash_menu.rs index e2cc97fb..d31f84ba 100644 --- a/crates/tui/src/tui/slash_menu.rs +++ b/crates/tui/src/tui/slash_menu.rs @@ -26,6 +26,7 @@ pub fn visible_slash_menu_entries(app: &App, limit: usize) -> Vec bool { &app.cached_skills, app.ui_locale, Some(&app.workspace), + app.api_provider, ) .into_iter() .map(|entry| entry.name) diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index abe4bf6d..03b9a83b 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -30,7 +30,10 @@ use crate::tui::approval::{ }; use crate::tui::history::HistoryCell; use crate::tui::scrolling::TranscriptLineMeta; -use crate::{commands, config::COMMON_DEEPSEEK_MODELS}; +use crate::{ + commands, + config::{ApiProvider, model_completion_names_for_provider}, +}; use ratatui::{ buffer::Buffer, layout::Rect, @@ -2033,6 +2036,7 @@ pub(crate) fn slash_completion_hints( cached_skills: &[(String, String)], locale: crate::localization::Locale, workspace: Option<&std::path::Path>, + api_provider: ApiProvider, ) -> Vec { if !super::app::looks_like_slash_command_input(input) { return Vec::new(); @@ -2111,7 +2115,7 @@ pub(crate) fn slash_completion_hints( // Special: /model completions when only /model matches if entries.iter().any(|e| e.name == "/model") && prefix_lower.eq_ignore_ascii_case("model") { - for model_name in COMMON_DEEPSEEK_MODELS { + for model_name in model_completion_names_for_provider(api_provider) { entries.push(SlashMenuEntry { name: format!("/model {model_name}"), description: String::from("Switch to this model"), @@ -2273,7 +2277,7 @@ mod tests { cursor_row_col, layout_input, pad_lines_to_bottom, placeholder_visual_lines, should_render_empty_state, slash_completion_hints, wrap_input_lines, wrap_text, }; - use crate::config::Config; + use crate::config::{ApiProvider, Config}; use crate::localization::Locale; use crate::palette; use crate::tui::app::{App, ComposerDensity, TuiOptions}; @@ -2475,14 +2479,14 @@ mod tests { #[test] fn slash_completion_hints_include_links_and_config() { - let hints = slash_completion_hints("/", 128, &[], Locale::En, None); + let hints = slash_completion_hints("/", 128, &[], Locale::En, None, ApiProvider::Deepseek); assert!(hints.iter().any(|hint| hint.name == "/config")); assert!(hints.iter().any(|hint| hint.name == "/links")); } #[test] fn slash_completion_hints_exclude_set_and_deepseek_commands() { - let hints = slash_completion_hints("/", 128, &[], Locale::En, None); + let hints = slash_completion_hints("/", 128, &[], Locale::En, None, ApiProvider::Deepseek); assert!(!hints.iter().any(|hint| hint.name == "/set")); assert!(!hints.iter().any(|hint| hint.name == "/deepseek")); } @@ -2493,7 +2497,14 @@ mod tests { ("search-files".to_string(), "Search files".to_string()), ("my-review".to_string(), "Review code".to_string()), ]; - let hints = slash_completion_hints("/", 128, &cached_skills, Locale::En, None); + let hints = slash_completion_hints( + "/", + 128, + &cached_skills, + Locale::En, + None, + ApiProvider::Deepseek, + ); assert!(hints.iter().any(|hint| hint.name == "/skill")); assert!(hints.iter().any(|hint| hint.name == "/skills")); assert!(!hints.iter().any(|hint| hint.is_skill)); @@ -2505,7 +2516,14 @@ mod tests { ("search-files".to_string(), "Search files".to_string()), ("my-review".to_string(), "Review code".to_string()), ]; - let hints = slash_completion_hints("/se", 128, &cached_skills, Locale::En, None); + let hints = slash_completion_hints( + "/se", + 128, + &cached_skills, + Locale::En, + None, + ApiProvider::Deepseek, + ); assert!(!hints.iter().any(|hint| hint.name == "/skill search-files")); assert!(!hints.iter().any(|hint| hint.name == "/skill my-review")); } @@ -2516,7 +2534,14 @@ mod tests { ("search-files".to_string(), "Search files".to_string()), ("my-review".to_string(), "Review code".to_string()), ]; - let hints = slash_completion_hints("/skill ", 128, &cached_skills, Locale::En, None); + let hints = slash_completion_hints( + "/skill ", + 128, + &cached_skills, + Locale::En, + None, + ApiProvider::Deepseek, + ); assert_eq!(hints.len(), 2); assert!(hints.iter().any(|hint| hint.name == "/skill search-files")); assert!(hints.iter().any(|hint| hint.name == "/skill my-review")); @@ -2529,12 +2554,47 @@ mod tests { ("search-files".to_string(), "Search files".to_string()), ("my-review".to_string(), "Review code".to_string()), ]; - let hints = slash_completion_hints("/skill my", 128, &cached_skills, Locale::En, None); + let hints = slash_completion_hints( + "/skill my", + 128, + &cached_skills, + Locale::En, + None, + ApiProvider::Deepseek, + ); assert_eq!(hints.len(), 1); assert_eq!(hints[0].name, "/skill my-review"); assert!(hints[0].is_skill); } + #[test] + fn slash_completion_hints_model_deepseek_provider_uses_bare_ids() { + let hints = + slash_completion_hints("/model", 128, &[], Locale::En, None, ApiProvider::Deepseek); + let names = hints + .iter() + .map(|hint| hint.name.as_str()) + .collect::>(); + + assert!(names.contains(&"/model deepseek-v4-pro")); + assert!(names.contains(&"/model deepseek-v4-flash")); + assert!(!names.contains(&"/model deepseek-ai/deepseek-v4-pro")); + assert!(!names.contains(&"/model deepseek/deepseek-v4-pro")); + } + + #[test] + fn slash_completion_hints_model_provider_uses_provider_specific_ids() { + let hints = + slash_completion_hints("/model", 128, &[], Locale::En, None, ApiProvider::NvidiaNim); + let names = hints + .iter() + .map(|hint| hint.name.as_str()) + .collect::>(); + + assert!(names.contains(&"/model deepseek-ai/deepseek-v4-pro")); + assert!(!names.contains(&"/model deepseek/deepseek-v4-pro")); + } + #[test] fn selection_style_uses_explicit_selection_text_role() { let line = Line::from(Span::styled(