use std::{collections::HashSet, sync::Arc};
use anyhow::{Context, Ok, Result};
use minijinja::Environment;
use crate::model_card::{ModelDeploymentCard, PromptContextMixin, PromptFormatterArtifact};
mod context;
mod formatters;
mod oai;
mod tokcfg;
use super::{OAIChatLikeRequest, OAIPromptFormatter, PromptFormatter};
pub use tokcfg::ChatTemplate;
use tokcfg::ChatTemplateValue;
impl PromptFormatter {
pub fn from_mdc(mdc: &ModelDeploymentCard) -> Result<PromptFormatter> {
let model_type_lower = mdc
.model_info
.as_ref()
.and_then(|info| info.get_model_info().ok())
.map(|info| info.model_type().to_lowercase())
.filter(|s| !s.is_empty());
let display_name_lower = mdc.display_name.to_lowercase();
if is_deepseek_v4(&model_type_lower, &display_name_lower) {
tracing::info!(
model_type = ?model_type_lower,
display_name = %mdc.display_name,
"Detected DeepSeek V4 model, using native Rust formatter",
);
return Ok(Self::OAI(Arc::new(
super::deepseek_v4::DeepSeekV4Formatter::new_thinking(),
)));
}
if is_deepseek_v3_2_non_exp(&model_type_lower, &display_name_lower) {
tracing::info!("Detected DeepSeek V3.2 model (non-Exp), using native Rust formatter");
return Ok(Self::OAI(Arc::new(
super::deepseek_v32::DeepSeekV32Formatter::new_thinking(),
)));
}
match mdc
.prompt_formatter
.as_ref()
.ok_or(anyhow::anyhow!("MDC does not contain a prompt formatter"))?
{
PromptFormatterArtifact::HfTokenizerConfigJson(checked_file) => {
let Some(file) = checked_file.path() else {
anyhow::bail!(
"HfTokenizerConfigJson for {} is a URL, cannot load",
mdc.display_name
);
};
let contents = std::fs::read_to_string(file).with_context(|| {
format!(
"PromptFormatter.from_mdc fs:read_to_string '{}'",
file.display()
)
})?;
let mut config: ChatTemplate =
serde_json::from_str(&contents).inspect_err(|err| {
crate::log_json_err(&file.display().to_string(), &contents, err)
})?;
match mdc.chat_template_file.as_ref() {
Some(PromptFormatterArtifact::HfChatTemplateJinja {
file: checked_file,
..
}) => {
let Some(path) = checked_file.path() else {
anyhow::bail!(
"HfChatTemplateJinja for {} is a URL, cannot load",
mdc.display_name
);
};
let chat_template = std::fs::read_to_string(path)
.with_context(|| format!("fs:read_to_string '{}'", path.display()))?;
config.chat_template = Some(ChatTemplateValue(either::Left(chat_template)));
}
Some(PromptFormatterArtifact::HfChatTemplateJson {
file: checked_file,
..
}) => {
let Some(path) = checked_file.path() else {
anyhow::bail!(
"HfChatTemplateJson for {} is a URL, cannot load",
mdc.display_name
);
};
let raw = std::fs::read_to_string(path)
.with_context(|| format!("fs:read_to_string '{}'", path.display()))?;
let wrapper: serde_json::Value =
serde_json::from_str(&raw).with_context(|| {
format!("Failed to parse '{}' as JSON", path.display())
})?;
let field = wrapper.get("chat_template").ok_or_else(|| {
anyhow::anyhow!(
"'{}' does not contain a 'chat_template' field",
path.display()
)
})?;
let value = serde_json::from_value::<ChatTemplateValue>(field.clone())
.with_context(|| {
format!(
"Failed to deserialize 'chat_template' in '{}'",
path.display()
)
})?;
config.chat_template = Some(value);
}
_ => {}
}
Self::from_parts(
config,
mdc.prompt_context
.clone()
.map_or(ContextMixins::default(), |x| ContextMixins::new(&x)),
mdc.runtime_config.exclude_tools_when_tool_choice_none,
)
}
PromptFormatterArtifact::HfChatTemplateJinja { .. }
| PromptFormatterArtifact::HfChatTemplateJson { .. } => Err(anyhow::anyhow!(
"prompt_formatter should not have type HfChatTemplate*"
)),
}
}
pub fn from_parts(
config: ChatTemplate,
context: ContextMixins,
exclude_tools_when_tool_choice_none: bool,
) -> Result<PromptFormatter> {
let formatter = HfTokenizerConfigJsonFormatter::with_options(
config,
context,
exclude_tools_when_tool_choice_none,
)?;
Ok(Self::OAI(Arc::new(formatter)))
}
}
struct JinjaEnvironment {
env: Environment<'static>,
}
#[derive(Debug)]
struct HfTokenizerConfigJsonFormatter {
env: Environment<'static>,
config: ChatTemplate,
mixins: Arc<ContextMixins>,
supports_add_generation_prompt: bool,
requires_content_arrays: bool,
exclude_tools_when_tool_choice_none: bool,
template_handles_reasoning: bool,
image_placeholder_template: Option<&'static str>,
}
#[derive(Debug, Clone, Default)]
pub struct ContextMixins {
context_mixins: HashSet<PromptContextMixin>,
}
fn is_deepseek_v4(model_type_lower: &Option<String>, display_name_lower: &str) -> bool {
match model_type_lower.as_deref() {
Some("deepseek_v4") => true,
Some(_) => false, None => is_deepseek_v4_name(display_name_lower),
}
}
fn is_deepseek_v3_2_non_exp(model_type_lower: &Option<String>, display_name_lower: &str) -> bool {
let name_match = display_name_lower.contains("deepseek")
&& display_name_lower.contains("v3.2")
&& !display_name_lower.contains("exp");
match model_type_lower.as_deref() {
Some("deepseek_v3_2") => !display_name_lower.contains("exp"),
Some(_) => false,
None => name_match,
}
}
fn is_deepseek_v4_name(name_lower: &str) -> bool {
let Some(rest) = name_lower.strip_prefix("deepseek") else {
return false;
};
let rest = rest
.strip_prefix(|c: char| matches!(c, '-' | '_' | '.'))
.unwrap_or(rest);
let Some(after_v4) = rest.strip_prefix("v4") else {
return false;
};
after_v4.is_empty() || after_v4.starts_with(['-', '_', '.'])
}
#[cfg(test)]
mod detection_tests {
use super::{is_deepseek_v3_2_non_exp, is_deepseek_v4, is_deepseek_v4_name};
#[test]
fn v4_name_matches_canonical_variants() {
for name in [
"deepseek-v4",
"deepseek_v4",
"deepseek.v4",
"deepseekv4",
"deepseek-v4-pro",
"deepseek-v4-flash",
"deepseek-v4-flash-2507",
"deepseek-v4.1",
"deepseek_v4_thinking",
] {
assert!(is_deepseek_v4_name(name), "expected {name} to match V4");
}
}
#[test]
fn v4_name_rejects_non_v4() {
for name in [
"deepseek-v3.2-v4-foo",
"my-deepseek-v4",
"deepseek-v40",
"deepseek-v4pro",
"deepseekv40",
"deepseek-v3",
"deepseek-v3.2",
"deepseek-r1",
"qwen3-v4", "dsflash",
"",
] {
assert!(
!is_deepseek_v4_name(name),
"expected {name} to NOT match V4",
);
}
}
#[test]
fn v4_detection_prefers_config_model_type() {
let v4 = Some("deepseek_v4".to_string());
for display in ["dsflash", "my-pet-model", "llama-3-8b", ""] {
assert!(
is_deepseek_v4(&v4, display),
"config says deepseek_v4, display {display:?} — expected V4",
);
}
let llama = Some("llama".to_string());
for display in ["deepseek-v4", "deepseek-v4-flash", "anything"] {
assert!(
!is_deepseek_v4(&llama, display),
"config says llama, display {display:?} — expected NOT V4",
);
}
assert!(is_deepseek_v4(&None, "deepseek-v4-flash"));
assert!(!is_deepseek_v4(&None, "dsflash"));
let empty: Option<String> = None;
assert!(is_deepseek_v4(&empty, "deepseek-v4-flash"));
assert!(!is_deepseek_v4(&empty, "dsflash"));
}
#[test]
fn v3_2_detection_prefers_config_model_type() {
let v3_2 = Some("deepseek_v3_2".to_string());
assert!(is_deepseek_v3_2_non_exp(&v3_2, "whatever"));
assert!(is_deepseek_v3_2_non_exp(&v3_2, "deepseek-v3.2"));
assert!(!is_deepseek_v3_2_non_exp(&v3_2, "deepseek-v3.2-exp"));
let other = Some("deepseek_v4".to_string());
assert!(!is_deepseek_v3_2_non_exp(&other, "deepseek-v3.2"));
assert!(is_deepseek_v3_2_non_exp(&None, "deepseek-v3.2-pro"));
assert!(!is_deepseek_v3_2_non_exp(&None, "deepseek-v3.2-exp"));
assert!(!is_deepseek_v3_2_non_exp(&None, "deepseek-v4"));
}
}