use dynamo_protocols::types::{ChatCompletionRequestMessage, CreateChatCompletionRequest};
use dynamo_renderer::{
ContextMixins, OAIChatLikeRequest, OAIPromptFormatter, PromptFormatter,
deepseek::{v4::DeepSeekV4Formatter, v32::DeepSeekV32Formatter, v41::DeepSeekV41Formatter},
kimi_k3::KimiK3Formatter,
};
use serde_json::json;
use std::{collections::HashMap, sync::Arc};
struct Request<'a> {
inner: &'a CreateChatCompletionRequest,
typed: bool,
args: &'a HashMap<String, serde_json::Value>,
}
#[test]
fn typed_and_generic_content_normalization_match() {
let array_template = "{% for m in messages %}{% for part in m.content %}{% if part.type == 'text' %}{{ part.text }}{% elif part.type == 'image' %}<image>{% endif %}{% endfor %}{% endfor %}";
let phi_template = "{% for m in messages %}{{ '<|' + m.role + '|>' + m.content + '<|end|>' }}{% endfor %}<|assistant|>";
let passthrough = "{% for m in messages %}{{ m.content }}{% endfor %}";
let mixed = json!([
{"type": "text", "text": "Before "},
{"type": "image_url", "image_url": {"url": "https://example.com/image.png"}},
{"type": "text", "text": " after"}
]);
for (template, content, expected) in [
(array_template, json!("Hello 中文"), "Hello 中文"),
(array_template, mixed.clone(), "Before <image> after"),
(
phi_template,
mixed.clone(),
"<|user|>Before <|image_1|> after<|end|><|assistant|>",
),
(passthrough, mixed, "Before after"),
] {
let PromptFormatter::OAI(formatter) = PromptFormatter::from_parts(
serde_json::from_value(json!({"chat_template": template})).unwrap(),
ContextMixins::new(&[]),
true,
)
.unwrap();
let inner = serde_json::from_value(json!({
"model": "test", "messages": [{"role": "user", "content": content}]
}))
.unwrap();
let args = HashMap::new();
let typed = Request {
inner: &inner,
typed: true,
args: &args,
};
let generic = Request {
typed: false,
..typed
};
let actual = formatter.render(&typed).unwrap();
assert_eq!(actual, expected);
assert_eq!(formatter.render(&generic).unwrap(), actual);
}
}
impl OAIChatLikeRequest for Request<'_> {
fn model(&self) -> String {
self.inner.model()
}
fn messages(&self) -> minijinja::Value {
assert!(!self.typed, "typed messages must skip MiniJinja conversion");
self.inner.messages()
}
fn typed_messages(&self) -> Option<&[ChatCompletionRequestMessage]> {
self.typed.then_some(self.inner.messages.as_slice())
}
fn tools(&self) -> Option<minijinja::Value> {
self.inner.tools()
}
fn tool_choice(&self) -> Option<minijinja::Value> {
self.inner.tool_choice()
}
fn response_format(&self) -> Option<minijinja::Value> {
self.inner.response_format()
}
fn should_add_generation_prompt(&self) -> bool {
true
}
fn chat_template_args(&self) -> Option<&HashMap<String, serde_json::Value>> {
Some(self.args)
}
}
#[test]
fn typed_and_generic_message_paths_match() {
let PromptFormatter::OAI(jinja) = PromptFormatter::from_parts(
serde_json::from_value(json!({
"chat_template": "{{ messages | tojson }}{{ tools | tojson }}"
}))
.unwrap(),
ContextMixins::new(&[]),
true,
)
.unwrap();
let formatters: Vec<(&str, Arc<dyn OAIPromptFormatter>, &[bool])> = vec![
("jinja", jinja, &[false]),
(
"v3.2",
Arc::new(DeepSeekV32Formatter::new_thinking()),
&[false, true],
),
(
"v4",
Arc::new(DeepSeekV4Formatter::new_thinking()),
&[false, true],
),
("v4.1", Arc::new(DeepSeekV41Formatter), &[false, true]),
("kimi", Arc::new(KimiK3Formatter::new(true)), &[false, true]),
];
let requests = [
json!({"model": "test", "messages": [
{"role": "system", "content": "Keep Unicode 中文 🦀 and <special> text."},
{"role": "user", "content": " unchanged\n"}
]}),
json!({"model": "test", "messages": [
{"role": "user", "content": [{"type": "text", "text": "東京"}, {"type": "text", "text": "weather?"}]},
{"role": "assistant", "content": null, "reasoning_content": "check",
"tool_calls": [{"id": "c1", "type": "function", "function": {"name": "weather", "arguments": "{\"city\":\"東京\"}"}}]},
{"role": "tool", "tool_call_id": "c1", "content": "sunny"},
{"role": "user", "content": "explain"}
], "tools": [{"type": "function", "function": {"name": "weather", "parameters": {}}}]}),
];
for body in requests {
let inner: CreateChatCompletionRequest = serde_json::from_value(body).unwrap();
for (name, formatter, thinking_values) in &formatters {
for &thinking in *thinking_values {
let args = HashMap::from([("thinking".into(), json!(thinking))]);
let typed = Request {
inner: &inner,
typed: true,
args: &args,
};
let generic = Request {
typed: false,
..typed
};
assert_eq!(
formatter.render_prompt(&typed).unwrap(),
formatter.render_prompt(&generic).unwrap(),
"{name}, thinking={thinking}"
);
}
}
}
}