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 <reid201711@gmail.com>
This commit is contained in:
Reid
2026-05-15 02:39:31 +08:00
committed by GitHub
parent a87c50b044
commit eef16f45fd
5 changed files with 165 additions and 13 deletions
+2 -2
View File
@@ -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(", ")
+2 -2
View File
@@ -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(", ")
+90
View File
@@ -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<String> {
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<String> {
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());
+2
View File
@@ -26,6 +26,7 @@ pub fn visible_slash_menu_entries(app: &App, limit: usize) -> Vec<SlashMenuEntry
&app.cached_skills,
app.ui_locale,
Some(&app.workspace),
app.api_provider,
)
}
@@ -76,6 +77,7 @@ pub fn try_autocomplete_slash_command(app: &mut App) -> bool {
&app.cached_skills,
app.ui_locale,
Some(&app.workspace),
app.api_provider,
)
.into_iter()
.map(|entry| entry.name)
+69 -9
View File
@@ -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<SlashMenuEntry> {
if !super::app::looks_like_slash_command_input(input) {
return Vec::new();
@@ -2111,7 +2115,7 @@ pub(crate) fn slash_completion_hints(
// Special: /model <name> 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::<Vec<_>>();
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::<Vec<_>>();
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(