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:
@@ -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(", ")
|
||||
|
||||
@@ -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(", ")
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user