[sgl-router] Render chat prompts with dynamo-render (#38983)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kan Wu
2026-09-17 15:57:20 +08:00
committed by GitHub
co-authored by Claude Fable 5.1
parent 15b256bdb0
commit 3ce7e2a29f
33 changed files with 1048 additions and 864 deletions
-3
View File
@@ -2072,7 +2072,6 @@ dependencies = [
"memo-map",
"percent-encoding",
"serde",
"serde_json",
]
[[package]]
@@ -3285,7 +3284,6 @@ dependencies = [
"anyhow",
"axum",
"bytes",
"chrono",
"clap",
"criterion",
"dashmap",
@@ -3301,7 +3299,6 @@ dependencies = [
"k8s-openapi",
"kube",
"minijinja",
"minijinja-contrib",
"parking_lot",
"rand 0.8.8",
"reqwest",
+5 -10
View File
@@ -24,17 +24,12 @@ unused_qualifications = "warn"
[dependencies]
# Pin Dynamo versions and commit Cargo.lock so builds are reproducible.
dynamo-tokenizers = "=1.8.1"
# Prepare the Dynamo renderer dependency for the following migration.
# Renders the model's chat template (HF Jinja, or Dynamo's built-in encoder for
# template-less models like DeepSeek-V4) so cache-aware routing hashes the same
# tokens the engine caches.
dynamo-renderer = "=5.1.2"
# Chat-template rendering for cache-aware routing, retained until the
# renderer migration replaces it: the engine caches tokens AFTER applying the
# model's chat template, so the router must render the same template before
# hashing or its token_ids diverge from the engine's stored blocks. `pycompat`
# supplies the Python str/dict methods HF templates call; `chrono` backs
# `strftime_now`.
minijinja = { version = "2.24", features = ["loop_controls", "json"] }
minijinja-contrib = { version = "2", features = ["pycompat"] }
chrono = { version = "0.4", default-features = false, features = ["clock"] }
# `OAIChatLikeRequest` speaks minijinja values.
minijinja = "2.24"
# Async runtime + http
tokio = { version = "1.42", features = ["full"] }
+42
View File
@@ -169,6 +169,48 @@ queue slot and no engine round-trip — the `sampling_contract_violation` code
and per-parameter counter, bands, and one contract applied at a shared ingress
across engines whose own flags the router operator may not control.
## Chat rendering
The router renders chat requests with dynamo-render (`dynamo-renderer`): the model's
HF Jinja template from `tokenizer_config.json` or a sibling
`chat_template.jinja`, or dynamo-render's built-in DeepSeek encoder (V4 family, V3.2)
for template-less models. Cache-aware routing hashes the rendered tokens so its
prefix queries match the blocks the engine caches. Models the engine encodes in
code but dynamo-render cannot tokenize here (Inkling, Kimi K3) route via raw prompt
text, as does any model whose template fails to load or render.
Plain text chat requests (string `content`, no tools, no template kwargs or
reasoning controls or historical `reasoning_content`, no assistant continuation,
no consecutive users or non-leading system turns) additionally forward the
rendered tokens to the engine as `input_ids`, retaining the original messages,
so the engine skips re-tokenizing. Every other request shape is rendered for
routing only: the router renders with dynamo-render and does not replicate
SGLang's request normalization, so forwarding is enabled shape by shape as
parity is verified. Use matching model files on the router and workers; worker
template overrides and default kwargs are not observable from the request.
Set `--disable-input-ids-forwarding` for this router's model when worker-side
rendering has not been verified to match. This disables router-generated IDs
for every routing policy; cache-aware routing still renders and tokenizes
locally, and the original messages reach the workers for engine processing.
Caller-supplied `input_ids` remain caller-owned and pass through unchanged.
Forwarding logs its assumptions at startup. In particular, disable it for
`SGLANG_DEFAULT_THINKING=true`, a non-default `SGLANG_DSV4_REASONING_EFFORT`,
worker parser overrides such as `--tool-call-parser deepseekv32` that select a
native encoder over a shipped template, or conversation templates with stop
strings (the engine's `input_ids` path skips those template stops). These worker
settings are not inferred from the router's environment. Disabling forwarding preserves
engine behavior but does not establish parity for local routing hashes.
Also set `--disable-input-ids-forwarding` for array-only templates: Dynamo may wrap
string content into arrays differently from the worker. Dynamo 5.1.2 does not expose
its conversion flag, so the router cannot automatically block these templates.
Detailed content-format parity coverage follows in #39133.
The Dynamo crates are pinned exactly and `Cargo.lock` is committed; CI builds
with `--locked`, so rendered bytes cannot change without a reviewed diff.
## HTTP/2
There is nothing to configure. The router negotiates per connection inbound and
+19
View File
@@ -71,6 +71,11 @@ pub struct Cli {
/// as the repo id (download honors `HF_TOKEN` / `HF_HOME`).
#[arg(long)]
pub tokenizer_path: Option<String>,
/// Disable router-generated input_ids for this model. Workers tokenize messages
/// themselves; cache-aware routing still renders locally. Use for worker-only
/// thinking/effort defaults, parser/template overrides, or template stop strings.
#[arg(long)]
pub disable_input_ids_forwarding: bool,
/// Routing policy.
#[arg(long, value_enum, default_value = "round_robin")]
pub policy: PolicyKind,
@@ -702,6 +707,7 @@ impl Cli {
// HuggingFace repo id) when --tokenizer-path is omitted.
tokenizer_path: self.tokenizer_path.unwrap_or_else(|| self.model_id.clone()),
id: self.model_id,
disable_input_ids_forwarding: self.disable_input_ids_forwarding,
policy: self.policy,
decode_policy: self.decode_policy,
bucket_config,
@@ -938,6 +944,19 @@ mod tests {
assert_eq!(c.model.tokenizer_path, "/models/qwen3/tokenizer.json");
}
#[test]
fn input_ids_forwarding_can_be_disabled_for_the_model() {
let defaults = into_config_owned(with_model(&["--worker-urls", "http://x:30000"])).unwrap();
assert!(!defaults.model.disable_input_ids_forwarding);
let disabled = into_config_owned(with_model(&[
"--worker-urls",
"http://x:30000",
"--disable-input-ids-forwarding",
]))
.unwrap();
assert!(disabled.model.disable_input_ids_forwarding);
}
#[test]
fn static_urls_backend() {
let c = into_config_owned(with_model(&[
@@ -279,6 +279,7 @@ mod tests {
model: ModelConfig {
id: model_id.into(),
tokenizer_path: "/tmp/tok.json".into(),
disable_input_ids_forwarding: false,
policy: PolicyKind::RoundRobin,
decode_policy: DecodePolicyKind::PowerOfTwo,
bucket_config: None,
@@ -350,6 +350,9 @@ pub struct ModelConfig {
/// id (downloaded on demand). Defaults to `id` when `--tokenizer-path`
/// is omitted. Resolved by [`crate::tokenizer::adapter::load`].
pub tokenizer_path: String,
/// Disable router-generated input IDs for this model; keep routing tokenization.
/// Use when workers have rendering defaults or template stops the router cannot see.
pub disable_input_ids_forwarding: bool,
pub policy: PolicyKind,
/// Selection policy for the decode pool.
pub decode_policy: DecodePolicyKind,
@@ -337,6 +337,7 @@ mod tests {
model: ModelConfig {
id: id.into(),
tokenizer_path: "/tmp/x".into(),
disable_input_ids_forwarding: false,
policy,
decode_policy: Default::default(),
bucket_config: None,
+12 -16
View File
@@ -52,14 +52,14 @@ pub fn request_tokens_for(
model_id: &ModelId,
value: &serde_json::Value,
) -> Option<RequestTokens> {
if tokenizers.has_chat_formatter(&model_id.0) {
if let Some(messages) = value.get("messages").filter(|m| m.is_array()) {
if let Some(ids) = tokenizers.encode_chat(&model_id.0, messages) {
return Some(RequestTokens {
ids,
rendered_from_chat: true,
});
}
if tokenizers.has_chat_formatter(&model_id.0)
&& value.get("messages").is_some_and(|m| m.is_array())
{
if let Some(ids) = tokenizers.encode_chat(&model_id.0, value) {
return Some(RequestTokens {
ids,
rendered_from_chat: true,
});
}
}
let text = extract_prompt_text_from_value(value)?;
@@ -510,14 +510,10 @@ pub trait Policy: Send + Sync + std::fmt::Debug {
}
/// Whether this policy's routing decision needs request tokens (i.e.
/// it routes by prompt prefix). Ingress tokenization itself is no longer
/// gated on this — that is a model property (`has_chat_formatter`) decided at
/// ingress via [`request_tokens_for`]. This flag is the EXTRA gate that
/// keeps the cache-aware policy's RAW-prompt routing path alive: a
/// cache-aware model with no chat formatter still wants its `/v1/completions`
/// /`text` prompt tokenized for tree matching, which `has_chat_formatter`
/// alone would not trigger. Default `false` for load-only and sticky
/// routes; only the cache-aware policy overrides it.
/// it routes by prompt prefix). This keeps routing tokenization active when
/// generated input-ID forwarding is disabled or no chat formatter exists.
/// Models with forwarding enabled also tokenize independently of this flag.
/// Default `false` for load-only and sticky routes.
fn needs_request_tokens(&self) -> bool {
false
}
@@ -169,6 +169,7 @@ impl AppContext {
model: crate::config::ModelConfig {
id: "stub-model".into(),
tokenizer_path: "stub".into(),
disable_input_ids_forwarding: false,
policy: crate::config::PolicyKind::RoundRobin,
decode_policy: Default::default(),
bucket_config: None,
+138 -67
View File
@@ -507,7 +507,7 @@ pub async fn chat_completions(
// MODEL (does it have a chat formatter so the router can produce
// engine-equivalent tokens?), not of how we pick the worker. Two gates:
//
// * `has_chat_formatter` a chat request on this model yields
// * Forwarding is enabled and `has_chat_formatter` -> a chat request yields
// engine-equivalent ids we can forward as `input_ids` so the engine
// skips re-tokenizing. This enables the offload for EVERY policy —
// sticky and round-robin included — not just cache-aware.
@@ -523,8 +523,10 @@ pub async fn chat_completions(
// body. When parsed, this single value is reused for the routing
// tokenization and the outgoing-body injection below (and PD bootstrap
// injection). `parse_probe` already validated the object shape.
let can_forward_input_ids = !ctx.config.model.disable_input_ids_forwarding
&& ctx.tokenizers.has_chat_formatter(&model_str);
let want_tokens = should_tokenize_request(
ctx.tokenizers.has_chat_formatter(&model_str),
can_forward_input_ids,
policy.needs_request_tokens(),
ctx.bucket_selector.is_enabled(),
);
@@ -798,8 +800,9 @@ pub async fn chat_completions(
// Forward the router-computed tokens to the engine as `input_ids` so it
// skips re-tokenizing the same prompt — but only when they are
// engine-equivalent (chat-formatter path) AND the request contains nothing
// the router's formatter didn't replicate (see `input_ids_safe_to_forward`).
// enabled for this model, engine-equivalent (chat-formatter path), and
// the request has no unreplicated rendering controls (see
// `input_ids_safe_to_forward`).
// Otherwise omit them and the engine tokenizes from `messages` as usual —
// a transparent, always-correct fallback (`messages` are always retained
// in the forwarded body). `forward_input_ids` is `Some` only when
@@ -807,7 +810,9 @@ pub async fn chat_completions(
// predicate always has a parsed body to inspect.
let forward_input_ids: Option<&[u32]> = match (request_tokens.as_ref(), request_value.as_ref())
{
(Some(t), Some(v)) if t.rendered_from_chat && input_ids_safe_to_forward(v) => {
(Some(t), Some(v))
if can_forward_input_ids && t.rendered_from_chat && input_ids_safe_to_forward(v) =>
{
Some(t.ids.as_slice())
}
_ => None,
@@ -819,7 +824,7 @@ pub async fn chat_completions(
// `ingress_tokenize_offload_failed`); successful forwards and expected
// omissions are not problems.
if ingress_tokenize_offload_failed(
ctx.tokenizers.has_chat_formatter(&model_str),
can_forward_input_ids,
request_value.as_ref(),
request_tokens.as_ref(),
) {
@@ -1198,11 +1203,11 @@ fn parse_optional_positive_f64_header(
}
fn should_tokenize_request(
has_chat_formatter: bool,
can_forward_input_ids: bool,
policy_needs_request_tokens: bool,
bucket_enabled: bool,
) -> bool {
has_chat_formatter || policy_needs_request_tokens || bucket_enabled
can_forward_input_ids || policy_needs_request_tokens || bucket_enabled
}
/// Estimate prefill-token count from the raw request body for use as
@@ -1401,51 +1406,30 @@ fn build_outgoing_body(
Ok(Bytes::from(bytes))
}
/// Whether the router's `input_ids` may be forwarded for this request.
/// Forward generated IDs only for request shapes verified against the engine.
/// The engine uses `input_ids` verbatim, bypassing its chat-template processing.
///
/// We forward only when the engine, fed `input_ids`, would have produced the
/// SAME prompt the router tokenized. When `input_ids` is present the engine
/// uses it verbatim and ignores everything that would otherwise steer its
/// `messages`-side tokenization (only stop tokens / tool-call constraint are
/// still taken from `messages`). So any request field that changes that
/// tokenization but which the router's chat formatter does not replicate makes
/// the forwarded ids wrong. This predicate is conservative by construction
/// any such signal returns `false` and the engine tokenizes from `messages`
/// (always correct).
/// Exclude requests that may render differently with dynamo-render:
/// - Non-leading system turns or consecutive users, which strict templates rewrite.
/// - Historical `reasoning_content`, which may be injected into message content.
/// - Tools and tool-call history, which the engine merges and normalizes
/// before rendering.
/// - Non-string or missing content, which the engine flattens or blanks.
/// - Template overrides, kwargs, reasoning controls, or task selection.
/// - Assistant continuations, whose final turn the engine handles separately.
///
/// Replicated-and-safe: plain text `messages` with a string `content`.
/// Not replicated → omit:
/// * `tools` / `functions` — the formatter doesn't render tool schemas.
/// * non-string or missing `content` (arrays, `null`): the engine normalizes
/// these before rendering; the router's formatter renders them verbatim.
/// * `chat_template` — an OpenAI-compatible per-request template override
/// (e.g. vLLM); the router renders with the model's default template, so a
/// custom one would diverge. (SGLang ignores it today, but block it so the
/// offload stays correct across engines / future versions.)
/// * `chat_template_kwargs` (carries `enable_thinking`/`thinking`),
/// `reasoning` / `reasoning_effort`, `task` — thinking/mode toggles the
/// formatter renders in the engine's default mode only.
/// * `continue_final_message: true`, or a trailing `assistant` message — the
/// engine rewrites/strips the final assistant turn; the formatter renders it
/// verbatim.
///
/// NOTE: the router's chat formatter renders in the engine's default
/// (non-thinking) mode. Current sglang derives thinking from the request
/// (`chat_template_kwargs`), which this guard already omits, so a plain request
/// the router rendered matches the engine. The only way to diverge is an engine
/// build that applies a non-default thinking mode the router can't observe from
/// the request — the same router↔engine tokenization-parity assumption that
/// cache-aware routing already depends on. The same assumption covers
/// `add_special_tokens`: the router renders specials via the chat template, which
/// matches the engine on tokenizers that auto-add them (the common case); a
/// tokenizer that does not would diverge by a leading special, again undetectable
/// from the request.
/// Matching model files and engine defaults are still required. Worker template
/// overrides and default kwargs cannot be inferred from the request.
/// `--disable-input-ids-forwarding` gates forwarding separately for such fleets.
fn input_ids_safe_to_forward(value: &serde_json::Value) -> bool {
if request_has_tools(value) || request_has_non_text_content(value) {
if request_has_tools(value)
|| request_has_non_text_content(value)
|| request_has_reasoning_content(value)
|| request_has_role_rewrites(value)
{
return false;
}
// Fields that steer the engine's template tokenization but which the
// router's formatter does not thread through.
// Request controls whose rendering has not been verified against the engine.
for key in [
"chat_template",
"chat_template_kwargs",
@@ -1469,15 +1453,15 @@ fn input_ids_safe_to_forward(value: &serde_json::Value) -> bool {
/// Whether to increment `sgl_router_ingress_tokenize_errors_total`.
///
/// Count chats with a configured formatter that pass the forwarding guard
/// Count chats with forwarding enabled that pass the forwarding guard
/// but lack chat-rendered tokens. Excluded requests are expected fallbacks,
/// even when rendering fails.
fn ingress_tokenize_offload_failed(
has_chat_formatter: bool,
can_forward_input_ids: bool,
request_value: Option<&serde_json::Value>,
request_tokens: Option<&RequestTokens>,
) -> bool {
if !has_chat_formatter {
if !can_forward_input_ids {
return false;
}
let chat_request = request_value.is_some_and(|v| {
@@ -1501,23 +1485,61 @@ fn last_message_is_assistant(value: &serde_json::Value) -> bool {
== Some("assistant")
}
/// Whether the request carries tool / function definitions. The router's chat
/// formatter renders only `messages`, so its `input_ids` would omit the tool
/// schemas the engine's template injects into the prompt — the caller must let
/// the engine tokenize these itself.
/// Tool schemas and tool-call history require engine normalization before
/// rendering: the engine merges message-level `tools` into the template's tools
/// and parses `tool_calls` arguments; dynamo-render does neither the same way.
fn request_has_tools(value: &serde_json::Value) -> bool {
let nonempty = |key: &str| {
value.get(key).is_some_and(|v| match v {
serde_json::Value::Array(a) => !a.is_empty(),
serde_json::Value::Null => false,
_ => true,
})
let nonempty = |v: &serde_json::Value| match v {
serde_json::Value::Array(a) => !a.is_empty(),
serde_json::Value::Null => false,
_ => true,
};
nonempty("tools") || nonempty("functions")
if ["tools", "functions"]
.iter()
.any(|key| value.get(key).is_some_and(nonempty))
{
return true;
}
value
.get("messages")
.and_then(|m| m.as_array())
.is_some_and(|messages| {
messages.iter().any(|message| {
message["role"] == "tool"
|| ["tools", "tool_calls", "function_call"]
.iter()
.any(|key| message.get(key).is_some_and(nonempty))
})
})
}
/// dynamo-render may inject historical reasoning into content the engine leaves unchanged.
fn request_has_reasoning_content(value: &serde_json::Value) -> bool {
value
.get("messages")
.and_then(|messages| messages.as_array())
.is_some_and(|messages| {
messages.iter().any(|message| {
message
.get("reasoning_content")
.is_some_and(|v| !v.is_null())
})
})
}
/// Message orders dynamo-render may rewrite for strict templates.
fn request_has_role_rewrites(value: &serde_json::Value) -> bool {
let Some(messages) = value.get("messages").and_then(|v| v.as_array()) else {
return false;
};
messages.iter().skip(1).any(|m| m["role"] == "system")
|| messages
.windows(2)
.any(|pair| pair[0]["role"] == "user" && pair[1]["role"] == "user")
}
/// Detect non-string or missing content, which requires engine tokenization:
/// the engine normalizes arrays and nulls differently from the router's formatter.
/// the engine normalizes arrays and nulls differently from dynamo-render.
fn request_has_non_text_content(value: &serde_json::Value) -> bool {
value
.get("messages")
@@ -1815,8 +1837,8 @@ mod tests {
);
}
/// Tool / function requests are detected so the caller omits `input_ids`
/// (the router's formatter doesn't render tools).
/// Tool schemas and tool-call history are detected so the caller omits
/// `input_ids`; empty lists and nulls are not tools.
#[test]
fn request_has_tools_detects_tools_and_functions() {
assert!(request_has_tools(
@@ -1827,6 +1849,14 @@ mod tests {
));
assert!(!request_has_tools(&serde_json::json!({"tools":[]})));
assert!(!request_has_tools(&serde_json::json!({"messages":[]})));
for message in [
serde_json::json!({"role":"system","content":"s","tools":[{"type":"function"}]}),
serde_json::json!({"role":"assistant","content":"","tool_calls":[{"function":{"name":"f","arguments":"{}"}}]}),
] {
assert!(request_has_tools(
&serde_json::json!({"messages":[message]})
));
}
}
/// Arrays, nulls, and missing content block `input_ids` forwarding.
@@ -1860,6 +1890,47 @@ mod tests {
})));
}
#[test]
fn reasoning_history_is_an_expected_forwarding_omission() {
let mut value = serde_json::json!({"messages": [
{"role":"user", "content":"hi"},
{"role":"assistant", "content":"answer", "reasoning_content":"prior reasoning"},
{"role":"user", "content":"next"}
]});
assert!(!input_ids_safe_to_forward(&value));
assert!(!ingress_tokenize_offload_failed(true, Some(&value), None));
value["messages"][1]["reasoning_content"] = serde_json::Value::Null;
assert!(input_ids_safe_to_forward(&value));
value["messages"][1]
.as_object_mut()
.unwrap()
.remove("reasoning_content");
assert!(input_ids_safe_to_forward(&value));
}
#[test]
fn role_rewrites_are_expected_forwarding_omissions() {
for roles in [
vec!["user", "user"],
vec!["system", "system", "user"],
vec!["user", "assistant", "system", "user"],
] {
let messages: Vec<_> = roles
.iter()
.map(|role| serde_json::json!({"role": role, "content": "text"}))
.collect();
let value = serde_json::json!({"messages": messages});
assert!(!input_ids_safe_to_forward(&value), "{roles:?}");
assert!(!ingress_tokenize_offload_failed(true, Some(&value), None));
}
assert!(input_ids_safe_to_forward(&serde_json::json!({"messages": [
{"role": "system", "content": "instructions"},
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "hello"},
{"role": "user", "content": "next"}
]})));
}
/// Every field the engine honors on the `messages` path but which the
/// router's formatter does not replicate must block forwarding — otherwise
/// the engine uses the router's ids verbatim and silently runs a different
@@ -1967,7 +2038,7 @@ mod tests {
));
}
/// Non-chat-formatter models never expected the offload not a failure even
/// Non-chat-formatter models never expected the offload -> not a failure even
/// with no tokens.
#[test]
fn offload_failed_false_without_chat_formatter() {
@@ -1975,7 +2046,7 @@ mod tests {
assert!(!ingress_tokenize_offload_failed(false, Some(&value), None));
}
/// A non-chat (no `messages`) request on a chat-formatter model e.g.
/// A non-chat (no `messages`) request on a chat-formatter model, e.g.
/// `/v1/completions` `prompt` — never expected the chat-encode offload, so
/// the absence of engine-equivalent ids is not a failure.
#[test]
@@ -50,6 +50,7 @@ mod tests {
ctx.config.model = crate::config::ModelConfig {
id: "qwen3".into(),
tokenizer_path: "x".into(),
disable_input_ids_forwarding: false,
policy: PolicyKind::RoundRobin,
decode_policy: Default::default(),
bucket_config: None,
@@ -118,6 +118,7 @@ mod tests {
model: crate::config::ModelConfig {
id: "tiny".into(),
tokenizer_path: "tests/fixtures/tiny_tokenizer.json".into(),
disable_input_ids_forwarding: false,
policy: PolicyKind::RoundRobin,
decode_policy: Default::default(),
bucket_config: None,
@@ -161,11 +161,6 @@ impl ModelFiles {
}
}
/// Load the sibling tokenizer config for the current chat formatter.
pub fn load_tokenizer_config(source: &str) -> Result<Option<serde_json::Value>> {
ModelFiles::open(source).json("tokenizer_config.json")
}
pub fn encode(t: &Tokenizer, text: &str) -> Result<Vec<u32>> {
let enc = t.encode(text).context("encode")?;
Ok(enc.token_ids().to_vec())
@@ -0,0 +1,488 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
//! Chat rendering via dynamo-render for cache-aware routing and input ID forwarding.
//!
//! Engine-specific request normalization is not replicated here. The forwarding
//! guard omits IDs for request shapes whose engine rendering has not been verified.
use std::collections::HashMap;
use std::sync::Arc;
use anyhow::{Context, Result};
use dynamo_renderer::{
deepseek_formatter_for, may_be_fix_tool_schema, ChatTemplate, ContextMixins,
OAIChatLikeRequest, OAIPromptFormatter, PromptFormatter,
};
use minijinja::Value;
use serde_json::Value as JsonValue;
pub type ChatTemplateKwargs = HashMap<String, JsonValue>;
// HF special_tokens_map names. dynamo-render supplies bos/eos/unk; the rest
// are passed as template context defaults.
const SPECIAL_TOKEN_KEYS: [&str; 7] = [
"bos_token",
"eos_token",
"unk_token",
"sep_token",
"pad_token",
"cls_token",
"mask_token",
];
/// Renders and tokenizes chat requests through dynamo-render.
pub struct ChatFormatter {
formatter: Arc<dyn OAIPromptFormatter>,
/// Template context defaults; request `chat_template_kwargs` override them.
defaults: ChatTemplateKwargs,
}
impl ChatFormatter {
/// Load model files and select a template or native formatter from dynamo-render.
pub fn load(model_id: &str, tokenizer_path: &str) -> Result<Option<Self>> {
let files = super::adapter::ModelFiles::open(tokenizer_path);
let model_type = files
.json("config.json")?
.and_then(|cfg| cfg["model_type"].as_str().map(str::to_owned));
match model_type.as_deref() {
// These require tokenization paths not yet supported by this adapter.
Some("inkling_mm_model" | "kimi_k3") => return Ok(None),
Some(t) if t.starts_with("deepseek_v4") => {
return Ok(Self::deepseek_native(model_type.as_deref(), model_id));
}
_ => {}
}
let cfg = files
.json("tokenizer_config.json")?
.unwrap_or_else(|| serde_json::json!({}));
let jinja = files.text("chat_template.jinja")?;
Ok(Self::from_tokenizer_config(cfg, jinja.as_deref())?
.or_else(|| Self::deepseek_native(model_type.as_deref(), model_id)))
}
/// HF Jinja template from `tokenizer_config.json`, overridden by a sibling
/// `chat_template.jinja` when present (transformers' precedence); `Ok(None)`
/// when the model ships neither.
pub fn from_tokenizer_config(
mut cfg: JsonValue,
chat_template_jinja: Option<&str>,
) -> Result<Option<Self>> {
let config = cfg
.as_object_mut()
.context("tokenizer_config.json must be an object")?;
// Tokenizer-only settings have types the renderer's config does not support.
config.retain(|key, _| {
key == "chat_template"
|| SPECIAL_TOKEN_KEYS.contains(&key.as_str())
|| key == "additional_special_tokens"
});
if let Some(template) = chat_template_jinja {
cfg["chat_template"] = template.into();
}
// HuggingFace supplies None when no retrieval documents are present.
let mut defaults = HashMap::from([("documents".into(), JsonValue::Null)]);
for key in SPECIAL_TOKEN_KEYS {
// Convert HF AddedToken objects to strings for dynamo-render.
if let Some(content) = added_token_content(&cfg[key]) {
cfg[key] = content.into();
}
if matches!(key, "bos_token" | "eos_token" | "unk_token") {
// Missing tokens render as `None` in dynamo-render; HF uses "".
// These config values take precedence over kwargs.
if cfg[key].is_null() {
cfg[key] = "".into();
}
} else {
// Never reach the template except through kwargs.
defaults.insert(key.to_owned(), cfg[key].as_str().unwrap_or_default().into());
}
}
if let Some(extra) = cfg["additional_special_tokens"].as_array() {
let extra: Vec<JsonValue> = extra
.iter()
.map(|t| added_token_content(t).map_or_else(|| t.clone(), Into::into))
.collect();
cfg["additional_special_tokens"] = extra.clone().into();
defaults.insert("additional_special_tokens".into(), extra.into());
}
// HF's `[{name, template}]` list form -> dynamo-render's `[{name: template}]`.
for entry in cfg["chat_template"].as_array_mut().into_iter().flatten() {
if let (Some(name), Some(template)) =
(entry["name"].as_str(), entry["template"].as_str())
{
*entry = serde_json::json!({ name: template });
}
}
let template: ChatTemplate =
serde_json::from_value(cfg).context("parse tokenizer_config.json")?;
if template.chat_template.is_none() {
return Ok(None);
}
let PromptFormatter::OAI(formatter) =
PromptFormatter::from_parts(template, ContextMixins::default(), true)
.context("compile chat template")?;
Ok(Some(Self {
formatter,
defaults,
}))
}
/// dynamo-render's code-based DeepSeek encoders, for V4 (including variants
/// such as V4.1) and V3.2 non-Exp: the only built-in formatters verified
/// against the engine. `model_type` (from `config.json`) is authoritative;
/// the model id's last path segment is the fallback.
pub fn deepseek_native(model_type: Option<&str>, model_id: &str) -> Option<Self> {
let name = model_id
.rsplit('/')
.next()
.unwrap_or(model_id)
.to_lowercase();
// The engine treats every `deepseek_v4*` variant (e.g. V4.1) as V4.
let model_type = model_type.map(str::to_lowercase).map(|t| {
if t.starts_with("deepseek_v4") {
"deepseek_v4".into()
} else {
t
}
});
let PromptFormatter::OAI(formatter) = deepseek_formatter_for(&model_type, &name)?;
// Engine defaults: chat mode (`SGLANG_DEFAULT_THINKING=false`) and no
// reasoning-effort preamble; dynamo-render defaults to thinking at high effort.
let defaults = HashMap::from([
("thinking".into(), false.into()),
("reasoning_effort".into(), "low".into()),
]);
Some(Self {
formatter,
defaults,
})
}
fn template_kwargs(&self, request: &JsonValue) -> Result<ChatTemplateKwargs> {
let mut kwargs: ChatTemplateKwargs = match request.get("chat_template_kwargs") {
None | Some(JsonValue::Null) => ChatTemplateKwargs::new(),
Some(v) => serde_json::from_value(v.clone()).context("chat_template_kwargs")?,
};
for (key, value) in &self.defaults {
kwargs.entry(key.clone()).or_insert_with(|| value.clone());
}
Ok(kwargs)
}
/// Render the prompt text dynamo-render produces for `request`.
pub fn render(&self, request: &JsonValue) -> Result<String> {
anyhow::ensure!(request["messages"].is_array(), "messages must be an array");
let kwargs = self.template_kwargs(request)?;
self.formatter
.render(&ChatRequest { request, kwargs })
.context("render chat template")
}
pub fn encode(
&self,
tokenizer: &dynamo_tokenizers::Tokenizer,
request: &JsonValue,
) -> Result<Vec<u32>> {
super::adapter::encode(tokenizer, &self.render(request)?)
}
}
/// `content` of an HF `AddedToken` object (`{"content": "<s>", "lstrip": ...}`).
fn added_token_content(token: &JsonValue) -> Option<String> {
token
.as_object()?
.get("content")?
.as_str()
.map(str::to_owned)
}
struct ChatRequest<'a> {
request: &'a JsonValue,
kwargs: ChatTemplateKwargs,
}
/// Mirrors dynamo-render's own impl for its wire type: request fields pass through.
impl OAIChatLikeRequest for ChatRequest<'_> {
fn model(&self) -> String {
self.request["model"]
.as_str()
.unwrap_or_default()
.to_owned()
}
fn messages(&self) -> Value {
Value::from_serialize(&self.request["messages"])
}
fn tools(&self) -> Option<Value> {
let tools = self.request.get("tools")?;
// HF and the engine treat an empty list as "no tools"; dynamo-render's schema
// fixer would hand the template `[]`, which tools-branching templates
// render as a tool preamble.
if tools.as_array().is_none_or(|t| t.is_empty()) {
return None;
}
may_be_fix_tool_schema(tools.clone())
}
fn tool_choice(&self) -> Option<Value> {
self.request.get("tool_choice").map(Value::from_serialize)
}
fn reasoning_effort(&self) -> Option<Value> {
self.request
.get("reasoning_effort")
.map(Value::from_serialize)
}
/// Withheld: the engine enforces `response_format` by constrained decoding
/// and never renders it, while dynamo-render's DeepSeek formatters would
/// append a "## Response Format" schema preamble to the system turn.
fn response_format(&self) -> Option<Value> {
None
}
fn should_add_generation_prompt(&self) -> bool {
true
}
fn chat_template_args(&self) -> Option<&ChatTemplateKwargs> {
Some(&self.kwargs)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
const SIMPLE_TEMPLATE: &str = "{{ bos_token }}{% for m in messages %}<|{{ m['role'] }}|>\n{{ m['content'] }}<|end|>\n{% endfor %}{% if add_generation_prompt %}<|assistant|>\n{% endif %}";
fn jinja(cfg: JsonValue) -> ChatFormatter {
ChatFormatter::from_tokenizer_config(cfg, None)
.unwrap()
.expect("config has a chat_template")
}
fn request(messages: JsonValue) -> JsonValue {
json!({"model": "m", "messages": messages})
}
fn deepseek_v4() -> ChatFormatter {
ChatFormatter::deepseek_native(Some("deepseek_v4"), "any").unwrap()
}
#[test]
fn no_chat_template_returns_none() {
let cfg = json!({"bos_token": "<s>", "eos_token": "</s>"});
assert!(ChatFormatter::from_tokenizer_config(cfg, None)
.unwrap()
.is_none());
}
/// A sibling `chat_template.jinja` wins over `tokenizer_config.json`, as in
/// transformers, and suffices on its own.
#[test]
fn chat_template_jinja_file_takes_precedence() {
let cfg = json!({"chat_template": "CONFIG"});
let enc = ChatFormatter::from_tokenizer_config(cfg, Some("FILE"))
.unwrap()
.unwrap();
assert_eq!(enc.render(&request(json!([]))).unwrap(), "FILE");
let enc = ChatFormatter::from_tokenizer_config(json!({}), Some("FILE"))
.unwrap()
.unwrap();
assert_eq!(enc.render(&request(json!([]))).unwrap(), "FILE");
}
/// Both the string and the `AddedToken` object forms are accepted.
#[test]
fn additional_special_tokens_are_supplied() {
let enc = jinja(json!({
"chat_template": "{{ additional_special_tokens | join(',') }}",
"additional_special_tokens": ["<a>", {"content": "<b>", "special": true}]
}));
assert_eq!(enc.render(&request(json!([]))).unwrap(), "<a>,<b>");
}
#[test]
fn renders_roles_bos_and_generation_prompt() {
let enc = jinja(json!({"chat_template": SIMPLE_TEMPLATE, "bos_token": "<s>"}));
let out = enc
.render(&request(json!([
{"role": "system", "content": "be brief"},
{"role": "user", "content": "hi"}
])))
.unwrap();
assert_eq!(
out,
"<s><|system|>\nbe brief<|end|>\n<|user|>\nhi<|end|>\n<|assistant|>\n"
);
}
/// The list form `[{name, template}, ...]` and the `AddedToken` object form
/// of `bos_token` are both accepted.
#[test]
fn list_form_and_bos_object_form() {
let enc = jinja(json!({
"chat_template": [
{"name": "tool_use", "template": "TOOLS"},
{"name": "default", "template": "{{ bos_token }}X"},
],
"bos_token": {"content": "<|begin|>", "lstrip": false, "normalized": false,
"rstrip": false, "single_word": false, "special": true},
}));
assert_eq!(enc.render(&request(json!([]))).unwrap(), "<|begin|>X");
}
#[test]
fn absent_documents_match_huggingface_default() {
let enc = jinja(json!({
"chat_template": "{% if documents is not none %}DOCS{% endif %}{% for m in messages %}{{ m.content }}{% endfor %}"
}));
let mut req = request(json!([{"role":"user","content":"hi"}]));
assert_eq!(enc.render(&req).unwrap(), "hi");
req["chat_template_kwargs"] = json!({"documents": [{"text": "reference"}]});
assert_eq!(enc.render(&req).unwrap(), "DOCShi");
}
#[test]
fn absent_special_tokens_render_empty() {
let enc = jinja(json!({
"chat_template": "A{{ bos_token }}{{ eos_token }}{{ unk_token }}{{ sep_token }}{{ pad_token }}{{ cls_token }}{{ mask_token }}B"
}));
assert_eq!(enc.render(&request(json!([]))).unwrap(), "AB");
}
#[test]
fn all_special_tokens_from_config_are_supplied() {
let enc = jinja(json!({
"chat_template": "{{ bos_token }}{{ eos_token }}{{ unk_token }}{{ sep_token }}{{ pad_token }}{{ cls_token }}{{ mask_token }}",
"bos_token": {"content": "<s>"},
"eos_token": "</s>",
"unk_token": {"content": "<unk>"},
"sep_token": "<sep>",
"pad_token": {"content": "<pad>"},
"cls_token": "<cls>",
"mask_token": "<mask>"
}));
assert_eq!(
enc.render(&request(json!([]))).unwrap(),
"<s></s><unk><sep><pad><cls><mask>"
);
}
/// An unrecognized token shape is a load error, not a silent "".
#[test]
fn malformed_special_token_fails_to_load() {
let cfg = json!({"chat_template": "X", "eos_token": ["</s>"]});
assert!(ChatFormatter::from_tokenizer_config(cfg, None).is_err());
}
#[test]
fn tokenizer_settings_do_not_affect_chat_rendering() {
let enc = jinja(json!({
"chat_template": "{{ bos_token }}{% for m in messages %}{{ m.content }}{% endfor %}",
"bos_token": "<s>",
"sp_model_kwargs": {"enable_sampling": false, "nbest_size": -1, "alpha": 0.1},
"added_tokens_decoder": {"0": {"content": "<s>"}},
"truncation_size": 4096
}));
assert_eq!(
enc.render(&request(json!([{"role": "user", "content": "hi"}])))
.unwrap(),
"<s>hi"
);
}
#[test]
fn chat_template_kwargs_reach_the_template() {
let enc = jinja(json!({"chat_template": "t={{ enable_thinking }}"}));
let mut req = request(json!([]));
assert_eq!(enc.render(&req).unwrap(), "t=");
req["chat_template_kwargs"] = json!({"enable_thinking": true});
assert_eq!(enc.render(&req).unwrap(), "t=True");
req["chat_template_kwargs"] = json!("not an object");
assert!(enc.render(&req).is_err());
}
/// Tools pass through to the template; an empty list counts as no tools.
#[test]
fn tools_reach_the_template_and_empty_means_none() {
let enc = jinja(json!({
"chat_template": "{% if tools is not none %}T:{{ tools | length }}{% endif %}X"
}));
let mut req = request(json!([]));
assert_eq!(enc.render(&req).unwrap(), "X");
req["tools"] = json!([]);
assert_eq!(enc.render(&req).unwrap(), "X");
req["tools"] = json!([{"type": "function", "function": {"name": "f"}}]);
assert_eq!(enc.render(&req).unwrap(), "T:1X");
}
#[test]
fn raise_exception_surfaces_as_error() {
let enc = jinja(json!({"chat_template": "{{ raise_exception('bad messages') }}"}));
let err = enc.render(&request(json!([]))).unwrap_err();
assert!(format!("{err:#}").contains("bad messages"), "got: {err:#}");
}
#[test]
fn missing_messages_is_an_error() {
assert!(deepseek_v4().render(&json!({"model": "m"})).is_err());
}
#[test]
fn deepseek_native_detection() {
assert!(ChatFormatter::deepseek_native(None, "deepseek-ai/DeepSeek-V4-Flash").is_some());
assert!(ChatFormatter::deepseek_native(None, "deepseek-v4-tiny").is_some());
assert!(ChatFormatter::deepseek_native(Some("deepseek_v4"), "alias").is_some());
assert!(ChatFormatter::deepseek_native(Some("deepseek_v41"), "alias").is_some());
assert!(ChatFormatter::deepseek_native(Some("deepseek_v32"), "DeepSeek-V3.2").is_some());
assert!(ChatFormatter::deepseek_native(Some("inkling_mm_model"), "inkling").is_none());
assert!(ChatFormatter::deepseek_native(Some("llama"), "deepseek-v4").is_none());
assert!(ChatFormatter::deepseek_native(None, "deepseek-ai/DeepSeek-V3.2-Exp").is_none());
assert!(ChatFormatter::deepseek_native(None, "Qwen/Qwen3-0.6B").is_none());
}
/// Byte-exact against the engine's `/tokenize` in its default chat mode:
/// `[{user:"ABCD"}]` -> `[0, 128803, 51453, 128804, 128822]`.
#[test]
fn v4_single_user_turn() {
let out = deepseek_v4()
.render(&request(json!([{"role":"user","content":"ABCD"}])))
.unwrap();
assert_eq!(
out,
"<begin▁of▁sentence><User>ABCD<Assistant></think>"
);
}
#[test]
fn v4_system_then_multi_turn() {
let out = deepseek_v4()
.render(&request(json!([
{"role":"system","content":"SYS"},
{"role":"user","content":"U1"},
{"role":"assistant","content":"A1"},
{"role":"user","content":"U2"}
])))
.unwrap();
assert_eq!(
out,
"<begin▁of▁sentence>SYS<User>U1<Assistant></think>A1<end▁of▁sentence><User>U2<Assistant></think>"
);
}
/// Request kwargs override the chat-mode default.
#[test]
fn v4_thinking_kwarg_overrides_chat_default() {
let mut req = request(json!([{"role":"user","content":"ABCD"}]));
req["chat_template_kwargs"] = json!({"thinking": true});
let out = deepseek_v4().render(&req).unwrap();
assert!(out.ends_with("<Assistant><think>"), "got: {out}");
}
/// The engine never renders `response_format` into the prompt.
#[test]
fn v4_ignores_response_format() {
let mut req = request(json!([{"role":"user","content":"ABCD"}]));
let plain = deepseek_v4().render(&req).unwrap();
req["response_format"] = json!({"type": "json_object"});
assert_eq!(deepseek_v4().render(&req).unwrap(), plain);
}
}
@@ -1,365 +0,0 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
//! Chat-template rendering for cache-aware routing.
//!
//! The engine caches KV blocks keyed on tokens it produces *after* applying the
//! model's chat template (BOS + role/special markers + content). The router's
//! cache-aware selection must hash the same token sequence, so it renders the
//! same template before tokenizing; otherwise its query hashes never match the
//! engine's stored blocks.
//!
//! The template and its special-token strings come from the model's
//! `tokenizer_config.json` — the HuggingFace built-in template, which is what
//! the engine uses unless launched with an explicit chat-template override.
//!
//! Tokenization does not auto-prepend special tokens (the `dynamo_tokenizers`
//! HF wrapper hardcodes `add_special_tokens = false`; [`super::adapter::encode`]
//! adds none of its own), so the rendered text must already contain `bos_token`
//! and the role markers as literal text. That matches HuggingFace
//! `apply_chat_template(tokenize=True)` semantics, where the template — not the
//! tokenizer's special-token insertion — is the single source of the leading
//! specials.
use anyhow::{Context, Result};
use minijinja::{
value::Value as JinjaValue, Environment, Error as JinjaError, ErrorKind as JinjaErrorKind,
UndefinedBehavior,
};
use std::collections::BTreeMap;
/// Template registered under a fixed name in the per-model environment.
const TEMPLATE_NAME: &str = "chat";
/// The named special tokens HuggingFace injects into the template context via
/// `special_tokens_map`. Each is supplied from `tokenizer_config.json`, or as
/// the empty string when absent — jinja2 renders an undefined name as `""`, so
/// an absent token must not surface as anything else (minijinja would otherwise
/// print a `none` value as the literal string "none", silently diverging every
/// block hash from the engine's).
const SPECIAL_TOKEN_KEYS: [&str; 7] = [
"bos_token",
"eos_token",
"unk_token",
"sep_token",
"pad_token",
"cls_token",
"mask_token",
];
/// A compiled chat template plus the special-token strings it references.
///
/// One per model, built once at startup from `tokenizer_config.json` and held
/// in the [`super::TokenizerRegistry`]. Rendering is read-only and thread-safe.
pub struct ChatTemplate {
env: Environment<'static>,
/// `(name, token)` pairs for [`SPECIAL_TOKEN_KEYS`]; absent tokens are `""`.
special_tokens: Vec<(&'static str, String)>,
}
impl std::fmt::Debug for ChatTemplate {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ChatTemplate")
.field("special_tokens", &self.special_tokens)
.finish()
}
}
impl ChatTemplate {
/// Build from a parsed `tokenizer_config.json`. Returns `Ok(None)` when the
/// config carries no `chat_template` (the model is then routed via the raw
/// prompt-text path, unchanged).
pub fn from_tokenizer_config(cfg: &serde_json::Value) -> Result<Option<Self>> {
let Some(template_src) = extract_chat_template(cfg) else {
return Ok(None);
};
let special_tokens = SPECIAL_TOKEN_KEYS
.iter()
.map(|&key| (key, extract_token_str(cfg, key).unwrap_or_default()))
.collect();
let mut env = Environment::new();
// HuggingFace compiles chat templates with trim_blocks + lstrip_blocks;
// mirror that or rendered whitespace (and thus tokens) diverge.
env.set_trim_blocks(true);
env.set_lstrip_blocks(true);
// Printing a variable the router didn't supply (a custom
// `chat_template_kwargs` entry, a date var, ...) must be a render
// error so the caller falls back to raw-text hashing — under the
// default lenient behavior it would render as `""` and produce a
// plausible-but-divergent prompt whose hashes silently never match
// the engine's. If-tests and iteration over undefined stay permitted
// (`{% if enable_thinking is defined %}`-style guards are common).
env.set_undefined_behavior(UndefinedBehavior::SemiStrict);
// Python str/dict methods used by real templates (.startswith, .items,
// .strip, ...) that minijinja doesn't implement natively.
env.set_unknown_method_callback(minijinja_contrib::pycompat::unknown_method_callback);
env.add_function("raise_exception", raise_exception);
env.add_function("strftime_now", strftime_now);
env.add_template_owned(TEMPLATE_NAME, template_src)
.context("compile chat template from tokenizer_config.json")?;
Ok(Some(Self {
env,
special_tokens,
}))
}
/// Render `messages` (the request's `messages` array) into the prompt text
/// the engine would tokenize, with `add_generation_prompt = true`.
///
/// `messages` is passed through as-is; templates expect string `content`.
/// Multimodal content arrays are out of scope (text-only routing): a
/// template may stringify the array (divergent hashes → min-load) or error
/// (raw prompt-text fallback); neither fails the request.
///
/// `tools` and `documents` are supplied as `none` — the context HuggingFace
/// renders with when a request carries neither, so tools-branching
/// templates take the no-tools path. A request that does carry them renders
/// the no-tools form, so its hashes won't match the engine and it routes by
/// min-load — no worse than before this path existed. Any other variable
/// the template prints is a render error (semi-strict undefined), falling
/// back to raw rather than hashing a silently divergent prompt.
pub fn render(&self, messages: &serde_json::Value) -> Result<String> {
let tmpl = self
.env
.get_template(TEMPLATE_NAME)
.context("chat template not registered")?;
let mut ctx: BTreeMap<&str, JinjaValue> = BTreeMap::new();
ctx.insert("messages", JinjaValue::from_serialize(messages));
ctx.insert("add_generation_prompt", JinjaValue::from(true));
ctx.insert("tools", JinjaValue::from(()));
ctx.insert("documents", JinjaValue::from(()));
for (name, token) in &self.special_tokens {
ctx.insert(name, JinjaValue::from(token.clone()));
}
tmpl.render(ctx).context("render chat template")
}
}
/// Pull the chat-template source out of `tokenizer_config.json`.
///
/// Accepts both shapes HuggingFace ships:
/// - `"chat_template": "<jinja>"` — the common single-template case.
/// - `"chat_template": [{"name": "default", "template": "<jinja>"}, ...]` —
/// multi-template models; we take the entry named `default`, else the first.
fn extract_chat_template(cfg: &serde_json::Value) -> Option<String> {
match cfg.get("chat_template")? {
serde_json::Value::String(s) => Some(s.clone()),
serde_json::Value::Array(arr) => arr
.iter()
.find(|e| e.get("name").and_then(|n| n.as_str()) == Some("default"))
.or_else(|| arr.first())
.and_then(|e| e.get("template").and_then(|t| t.as_str()))
.map(str::to_owned),
_ => None,
}
}
/// Read a special-token string, accepting both the plain-string form and the
/// `AddedToken` object form (`{"content": "<tok>", ...}`) HuggingFace uses.
fn extract_token_str(cfg: &serde_json::Value, key: &str) -> Option<String> {
match cfg.get(key)? {
serde_json::Value::String(s) => Some(s.clone()),
serde_json::Value::Object(o) => {
o.get("content").and_then(|c| c.as_str()).map(str::to_owned)
}
_ => None,
}
}
/// `raise_exception(msg)` — templates call this to reject malformed message
/// sequences (e.g. a non-alternating role order). Surfaces as a render error.
fn raise_exception(msg: String) -> std::result::Result<String, JinjaError> {
Err(JinjaError::new(JinjaErrorKind::InvalidOperation, msg))
}
/// `strftime_now(format)` — current local time, matching the helper HuggingFace
/// injects so templates can stamp the date. Both engine and router render
/// within the same day, so the date prefix is stable enough to share a cache
/// block.
fn strftime_now(format: String) -> String {
chrono::Local::now().format(&format).to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
/// A small but representative instruct template: emits `bos_token`, wraps
/// each turn in role markers, and appends a generation prompt. Exercises the
/// variables the renderer must supply (`messages`, `bos_token`,
/// `add_generation_prompt`).
const SIMPLE_TEMPLATE: &str = "{{ bos_token }}{% for m in messages %}<|{{ m['role'] }}|>\n{{ m['content'] }}<|end|>\n{% endfor %}{% if add_generation_prompt %}<|assistant|>\n{% endif %}";
fn messages() -> serde_json::Value {
json!([
{"role": "system", "content": "be brief"},
{"role": "user", "content": "hi"}
])
}
#[test]
fn no_chat_template_returns_none() {
let cfg = json!({"bos_token": "<s>", "eos_token": "</s>"});
assert!(ChatTemplate::from_tokenizer_config(&cfg).unwrap().is_none());
}
#[test]
fn renders_roles_bos_and_generation_prompt() {
let cfg = json!({
"chat_template": SIMPLE_TEMPLATE,
"bos_token": "<s>",
"eos_token": "</s>",
});
let tmpl = ChatTemplate::from_tokenizer_config(&cfg).unwrap().unwrap();
let out = tmpl.render(&messages()).unwrap();
assert_eq!(
out,
"<s><|system|>\nbe brief<|end|>\n<|user|>\nhi<|end|>\n<|assistant|>\n"
);
}
/// `add_generation_prompt` is always true on the routing side (we hash the
/// prompt the engine will prefill, which includes the assistant header).
#[test]
fn generation_prompt_is_always_appended() {
let cfg = json!({ "chat_template": SIMPLE_TEMPLATE, "bos_token": "<s>" });
let tmpl = ChatTemplate::from_tokenizer_config(&cfg).unwrap().unwrap();
assert!(tmpl
.render(&messages())
.unwrap()
.ends_with("<|assistant|>\n"));
}
/// The list form `[{name, template}, ...]` selects the `default` entry.
#[test]
fn list_form_selects_default_template() {
let cfg = json!({
"chat_template": [
{"name": "tool_use", "template": "TOOLS"},
{"name": "default", "template": SIMPLE_TEMPLATE},
],
"bos_token": "<s>",
});
let tmpl = ChatTemplate::from_tokenizer_config(&cfg).unwrap().unwrap();
assert!(tmpl
.render(&messages())
.unwrap()
.starts_with("<s><|system|>"));
}
/// `bos_token` in the `AddedToken` object form is read from `.content`.
#[test]
fn bos_token_object_form_is_extracted() {
let cfg = json!({
"chat_template": "{{ bos_token }}X",
"bos_token": {"content": "<|begin|>", "lstrip": false},
});
let tmpl = ChatTemplate::from_tokenizer_config(&cfg).unwrap().unwrap();
assert_eq!(tmpl.render(&json!([])).unwrap(), "<|begin|>X");
}
/// `raise_exception` surfaces as a render error (caller then falls back to
/// the raw prompt-text path rather than failing the request).
#[test]
fn raise_exception_surfaces_as_error() {
let cfg = json!({
"chat_template": "{{ raise_exception('bad messages') }}",
"bos_token": "<s>",
});
let tmpl = ChatTemplate::from_tokenizer_config(&cfg).unwrap().unwrap();
let err = tmpl.render(&messages()).unwrap_err();
// The minijinja message is the cause; check the full anyhow chain.
assert!(format!("{err:#}").contains("bad messages"), "got: {err:#}");
}
/// pycompat exposes Python str methods (`.startswith`, `.upper`, ...) that
/// real HuggingFace templates lean on; without the callback these error.
#[test]
fn pycompat_string_methods_available() {
let cfg = json!({
"chat_template": "{% for m in messages %}{% if m['role'].startswith('sys') %}{{ m['content'].upper() }}{% endif %}{% endfor %}",
"bos_token": "<s>",
});
let tmpl = ChatTemplate::from_tokenizer_config(&cfg).unwrap().unwrap();
assert_eq!(tmpl.render(&messages()).unwrap(), "BE BRIEF");
}
/// An absent special token renders as `""` exactly like an undefined name
/// under HuggingFace's jinja2 — never as minijinja's literal `"none"`,
/// which would corrupt block 0 (and thus every chained block hash).
#[test]
fn absent_special_tokens_render_empty() {
let cfg = json!({"chat_template": "A{{ bos_token }}{{ pad_token }}B"});
let tmpl = ChatTemplate::from_tokenizer_config(&cfg).unwrap().unwrap();
assert_eq!(tmpl.render(&json!([])).unwrap(), "AB");
}
/// Every name in HuggingFace's `special_tokens_map` is threaded from
/// `tokenizer_config.json`, not just `bos_token`/`eos_token`.
#[test]
fn named_special_tokens_from_config_are_supplied() {
let cfg = json!({
"chat_template": "{{ pad_token }}|{{ unk_token }}",
"pad_token": "<pad>",
"unk_token": {"content": "<unk>"},
});
let tmpl = ChatTemplate::from_tokenizer_config(&cfg).unwrap().unwrap();
assert_eq!(tmpl.render(&json!([])).unwrap(), "<pad>|<unk>");
}
/// Printing a variable the router doesn't supply is a render error
/// (semi-strict undefined) so the caller falls back to raw-text hashing,
/// instead of rendering a plausible-but-divergent prompt.
#[test]
fn printing_unsupplied_variable_fails_render() {
let cfg = json!({
"chat_template": "{{ custom_kwarg }}",
"bos_token": "<s>",
});
let tmpl = ChatTemplate::from_tokenizer_config(&cfg).unwrap().unwrap();
tmpl.render(&messages()).unwrap_err();
}
/// Undefined names stay usable in if-tests (semi-strict only rejects
/// printing them); common `{% if enable_thinking is defined %}`-style
/// guards must keep rendering.
#[test]
fn undefined_in_if_test_is_permitted() {
let cfg = json!({
"chat_template": "{% if enable_thinking is defined and enable_thinking %}T{% endif %}X",
});
let tmpl = ChatTemplate::from_tokenizer_config(&cfg).unwrap().unwrap();
assert_eq!(tmpl.render(&messages()).unwrap(), "X");
}
/// `tools` is `none` in the render context — the same context HuggingFace
/// renders with for a request that carries no tools — so tools-branching
/// templates take the no-tools path instead of erroring or mis-branching.
#[test]
fn tools_supplied_as_none_takes_no_tools_branch() {
let cfg = json!({
"chat_template": "{% if tools is not none %}TOOLS{% endif %}X",
});
let tmpl = ChatTemplate::from_tokenizer_config(&cfg).unwrap().unwrap();
assert_eq!(tmpl.render(&messages()).unwrap(), "X");
}
/// trim_blocks + lstrip_blocks match HuggingFace's compilation: the newline
/// after a block tag and leading whitespace before one are stripped, so a
/// block-per-line template renders without spurious blank lines.
#[test]
fn trim_and_lstrip_blocks_match_huggingface() {
let cfg = json!({
"chat_template": "{% for m in messages %}\n {% if true %}\n{{ m['role'] }}\n {% endif %}\n{% endfor %}",
"bos_token": "<s>",
});
let tmpl = ChatTemplate::from_tokenizer_config(&cfg).unwrap().unwrap();
// Each iteration emits just "<role>\n"; lstrip removes the two leading
// spaces before the `{% if %}`/`{% endif %}`, trim removes the newline
// immediately after each block tag.
assert_eq!(tmpl.render(&messages()).unwrap(), "system\nuser\n");
}
}
@@ -1,274 +0,0 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 The SGLang Authors
// SPDX-License-Identifier: Apache-2.0
//! DeepSeek-V4 prompt encoder for cache-aware routing.
//!
//! DeepSeek-V4 ships no Jinja chat template; the engine builds the prompt in
//! code (`python/sglang/srt/entrypoints/openai/encoding_dsv4.py`, selected for
//! the `DeepseekV4` architecture). So to make the router's query tokens match
//! the engine's cached blocks, this reproduces that encoder's output for the
//! routing-relevant subset.
//!
//! # Scope
//!
//! Text content, chat (non-thinking) mode — the engine default
//! (`SGLANG_DEFAULT_THINKING=false`). For a user turn the engine emits
//! `BOS <User> content <Assistant> </think>`. Two preprocessing steps mirror
//! `serving_chat.py`/`encode_messages`: an empty system message is inserted when
//! the first message isn't a system message, and consecutive user turns are
//! merged into one (joined with `\n\n`). Tools, tasks, and per-turn reasoning
//! content are out of scope: the engine renders tools immediately after the
//! system content at the front of the prompt, so a tools-carrying request
//! diverges from the first block and routes by min-load; tasks alter only the
//! trailing turn transition; reasoning content is never emitted in chat mode,
//! so it causes no divergence.
//!
//! Tokenization does not auto-prepend special tokens (the `dynamo_tokenizers`
//! HF wrapper hardcodes `add_special_tokens = false`;
//! [`super::adapter::encode`] adds none of its own), so the literal marker text
//! below is what maps to the special token ids. Pinned byte-exact against the
//! live engine's `/tokenize` (DeepSeek-V4-Flash, snapshot `6976c7ff`):
//! `[{user:"ABCD"}]` → `[0, 128803, 51453, 128804, 128822]`.
/// Beginning-of-sequence marker (token id 0).
const BOS: &str = "<begin▁of▁sentence>";
/// End-of-sequence marker, closing each prior assistant turn (token id 1).
const EOS: &str = "<end▁of▁sentence>";
/// User-turn marker (token id 128803).
const USER: &str = "<User>";
/// Assistant-turn marker, opening the generation prompt (token id 128804).
const ASSISTANT: &str = "<Assistant>";
/// Thinking-end marker; the chat-mode generation prompt ends with it (128822).
const THINK_END: &str = "</think>";
/// Render `messages` into the DeepSeek-V4 chat prompt for routing.
///
/// Mirrors `encoding_dsv4.encode_messages` for the routing subset (chat mode,
/// text content, no tools/tasks). `messages` is the request's `messages` array;
/// non-array input renders to just the BOS marker (the caller then tokenizes it
/// and, finding no useful prefix, degrades to min-load like any short prompt).
pub fn render_messages(messages: &serde_json::Value) -> String {
let mut msgs: Vec<(String, String)> = messages
.as_array()
.map(|arr| {
arr.iter()
.map(|m| {
let role = m
.get("role")
.and_then(|r| r.as_str())
.unwrap_or("")
.to_string();
(role, content_to_string(m.get("content")))
})
.collect()
})
.unwrap_or_default();
// The engine inserts an empty system message when the first message isn't a
// system message; it renders to nothing but keeps the index logic aligned.
if msgs.first().map(|(r, _)| r != "system").unwrap_or(true) {
msgs.insert(0, ("system".to_string(), String::new()));
}
merge_consecutive_user_turns(&mut msgs);
let mut out = String::from(BOS);
for i in 0..msgs.len() {
render_one(i, &msgs, &mut out);
}
out
}
/// Collapse runs of consecutive `user` turns into one, joining their content
/// with `\n\n` — the engine merges them (`merge_tool_messages`) before encoding,
/// so back-to-back user messages must hash like the single turn it builds.
/// `developer` and other roles break a run and are left as-is.
fn merge_consecutive_user_turns(msgs: &mut Vec<(String, String)>) {
let mut merged: Vec<(String, String)> = Vec::with_capacity(msgs.len());
for (role, content) in msgs.drain(..) {
match merged.last_mut() {
Some((last_role, last_content)) if last_role == "user" && role == "user" => {
last_content.push_str("\n\n");
last_content.push_str(&content);
}
_ => merged.push((role, content)),
}
}
*msgs = merged;
}
/// Append message `i`'s encoded form to `out`.
fn render_one(i: usize, msgs: &[(String, String)], out: &mut String) {
let (role, content) = &msgs[i];
match role.as_str() {
"system" => out.push_str(content),
"user" | "developer" => {
out.push_str(USER);
out.push_str(content);
}
"assistant" => {
// Chat mode emits no reasoning block, so a prior assistant turn is
// just its content closed by EOS.
out.push_str(content);
out.push_str(EOS);
}
// Unknown roles aren't part of routing traffic; emit the content so a
// stray role still contributes something rather than vanishing.
_ => out.push_str(content),
}
// Generation-prompt transition. The engine appends it only when this is the
// last message OR the next message is an assistant/reminder turn, and only
// for user/developer messages.
let next_takes_transition = match msgs.get(i + 1) {
Some((next_role, _)) => next_role == "assistant" || next_role == "latest_reminder",
None => true,
};
if next_takes_transition && (role == "user" || role == "developer") {
out.push_str(ASSISTANT);
out.push_str(THINK_END);
}
}
/// Flatten a message `content` field to a string: a plain string as-is, an
/// OpenAI parts array to its concatenated `text` parts, anything else to empty.
fn content_to_string(content: Option<&serde_json::Value>) -> String {
match content {
Some(serde_json::Value::String(s)) => s.clone(),
Some(serde_json::Value::Array(parts)) => parts
.iter()
.filter_map(|p| p.get("text").and_then(|t| t.as_str()))
.collect(),
_ => String::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
/// Byte-exact against the engine's `/tokenize`: a single user turn renders
/// `BOS <User> content <Assistant> </think>`.
#[test]
fn single_user_turn() {
let out = render_messages(&json!([{"role":"user","content":"ABCD"}]));
assert_eq!(
out,
"<begin▁of▁sentence><User>ABCD<Assistant></think>"
);
}
/// A leading system message renders as bare content (no marker), before the
/// user turn.
#[test]
fn system_then_user() {
let out = render_messages(&json!([
{"role":"system","content":"SYS"},
{"role":"user","content":"ABCD"}
]));
assert_eq!(
out,
"<begin▁of▁sentence>SYS<User>ABCD<Assistant></think>"
);
}
/// Multi-turn: each prior user turn gets the generation prompt, the prior
/// assistant turn is closed by EOS. Matches the engine token stream
/// `[0,128803,55,19,128804,128822,35,19,1,128803,55,20,128804,128822]`.
#[test]
fn multi_turn() {
let out = render_messages(&json!([
{"role":"user","content":"U1"},
{"role":"assistant","content":"A1"},
{"role":"user","content":"U2"}
]));
assert_eq!(
out,
"<begin▁of▁sentence><User>U1<Assistant></think>A1<end▁of▁sentence><User>U2<Assistant></think>"
);
}
/// An empty leading system message (already present) is not duplicated and
/// renders to nothing — same result as a bare user turn.
#[test]
fn explicit_empty_system_is_not_duplicated() {
let out = render_messages(&json!([
{"role":"system","content":""},
{"role":"user","content":"ABCD"}
]));
assert_eq!(
out,
"<begin▁of▁sentence><User>ABCD<Assistant></think>"
);
}
/// Array (multimodal) content flattens to its text parts.
#[test]
fn array_content_flattens_text_parts() {
let out = render_messages(&json!([
{"role":"user","content":[{"type":"text","text":"AB"},{"type":"text","text":"CD"}]}
]));
assert_eq!(
out,
"<begin▁of▁sentence><User>ABCD<Assistant></think>"
);
}
/// Consecutive user turns merge into one `<User>` turn joined with `\n\n`
/// (the engine's `merge_tool_messages`), so only one user marker and one
/// generation prompt are emitted — not a marker per message.
#[test]
fn consecutive_user_turns_merge() {
let out = render_messages(&json!([
{"role":"user","content":"U1"},
{"role":"user","content":"U2"}
]));
assert_eq!(
out,
"<begin▁of▁sentence><User>U1\n\nU2<Assistant></think>"
);
}
/// A run of user turns split by an assistant turn does NOT merge across the
/// assistant: each side is its own user turn.
#[test]
fn user_runs_do_not_merge_across_assistant() {
let out = render_messages(&json!([
{"role":"user","content":"U1"},
{"role":"user","content":"U2"},
{"role":"assistant","content":"A1"},
{"role":"user","content":"U3"}
]));
assert_eq!(
out,
"<begin▁of▁sentence><User>U1\n\nU2<Assistant></think>A1<end▁of▁sentence><User>U3<Assistant></think>"
);
}
/// A `developer` turn renders identically to a user turn for text content
/// (the engine nests the same `<User>` marker) and takes the generation
/// prompt. Developer turns are not merged (only `user` runs merge), so two
/// developers emit two markers.
#[test]
fn developer_role_renders_like_user_without_merging() {
assert_eq!(
render_messages(&json!([{"role":"developer","content":"D1"}])),
"<begin▁of▁sentence><User>D1<Assistant></think>"
);
assert_eq!(
render_messages(&json!([
{"role":"developer","content":"D1"},
{"role":"developer","content":"D2"}
])),
"<begin▁of▁sentence><User>D1<User>D2<Assistant></think>"
);
}
/// An empty messages list renders to just the BOS marker — the documented
/// degrade path (the caller then routes by min-load on the empty prefix).
#[test]
fn empty_messages_renders_bos_only() {
assert_eq!(render_messages(&json!([])), "<begin▁of▁sentence>");
}
}
+116 -122
View File
@@ -2,38 +2,15 @@
// SPDX-License-Identifier: Apache-2.0
pub mod adapter;
pub mod chat_template;
pub mod dsv4;
pub mod chat_formatter;
use anyhow::Result;
use chat_template::ChatTemplate;
use chat_formatter::ChatFormatter;
use dashmap::DashMap;
use dynamo_tokenizers::Tokenizer;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
/// How to turn a chat request's `messages` into the prompt the engine tokenizes
/// and caches. Cache-aware routing renders this before hashing so its query
/// tokens match the engine's stored blocks.
pub enum ChatFormatter {
/// HuggingFace Jinja chat template from `tokenizer_config.json` (most
/// models). Boxed: it holds a minijinja `Environment`, far larger than the
/// other variants.
Jinja(Box<ChatTemplate>),
/// DeepSeek-V4 ships no template; the engine encodes in code. See [`dsv4`].
DeepSeekV4,
}
impl ChatFormatter {
/// Render `messages` into the engine-equivalent prompt text.
fn render(&self, messages: &serde_json::Value) -> Result<String> {
match self {
ChatFormatter::Jinja(t) => t.render(messages),
ChatFormatter::DeepSeekV4 => Ok(dsv4::render_messages(messages)),
}
}
}
/// A model's chat formatter plus its fallback-logging state.
struct ChatFormatterEntry {
formatter: ChatFormatter,
@@ -69,10 +46,7 @@ impl ChatFormatterEntry {
pub struct TokenizerRegistry {
inner: DashMap<String, Arc<Tokenizer>>,
/// Per-model chat formatter, present only when the model's prompt format is
/// known (a `tokenizer_config.json` chat template, or a built-in formatter
/// like DeepSeek-V4's). Cache-aware routing uses it to tokenize chat
/// requests the way the engine does; models without one fall back to raw
/// prompt-text tokenization.
/// known; models without one fall back to raw prompt-text tokenization.
formatters: DashMap<String, Arc<ChatFormatterEntry>>,
}
@@ -90,51 +64,33 @@ impl TokenizerRegistry {
let m = &cfg.model;
let t = adapter::load(&m.tokenizer_path)?;
me.inner.insert(m.id.clone(), t);
// Resolve the chat formatter, best-effort: a Jinja template from
// tokenizer_config.json, else a built-in formatter for a recognized model
// (DeepSeek-V4), else none (chat traffic routes via raw text). Every
// path logs its outcome — whether chat-aware routing is live for this
// model is the single most useful signal for diagnosing "cache-aware
// routing degraded to overlap=0 on chat traffic", so it must never be
// silent.
if let Some(formatter) = me.resolve_chat_formatter(&m.id, &m.tokenizer_path) {
me.formatters
.insert(m.id.clone(), Arc::new(ChatFormatterEntry::new(formatter)));
match ChatFormatter::load(&m.id, &m.tokenizer_path) {
Ok(Some(formatter)) => {
me.formatters
.insert(m.id.clone(), Arc::new(ChatFormatterEntry::new(formatter)));
tracing::info!(model = %m.id, "dynamo-render chat rendering enabled");
}
Ok(None) => tracing::info!(model = %m.id,
"no supported chat formatter; chat traffic routes via raw prompt text"),
Err(e) => tracing::warn!(model = %m.id, error = %format!("{e:#}"),
"failed to load chat formatter; chat traffic routes via raw prompt text"),
}
if m.disable_input_ids_forwarding {
tracing::info!(model = %m.id,
"router-generated input_ids forwarding disabled; workers tokenize messages; \
routing tokenization remains available");
} else if me.has_chat_formatter(&m.id) {
tracing::warn!(model = %m.id,
"router-generated input_ids forwarding enabled: requires matching worker model \
files and template defaults; native DeepSeek assumes SGLANG_DEFAULT_THINKING=false \
and no SGLANG_DSV4_REASONING_EFFORT preamble; worker parser overrides \
(including --tool-call-parser deepseekv32), content-format detection, and \
conversation-template stop strings are not replicated. Use \
--disable-input-ids-forwarding for array-only templates or when these assumptions do not hold");
}
Ok(me)
}
/// Pick the chat formatter for a model, logging the outcome on every branch.
fn resolve_chat_formatter(
&self,
model_id: &str,
tokenizer_path: &str,
) -> Option<ChatFormatter> {
match adapter::load_tokenizer_config(tokenizer_path) {
Ok(Some(cfg_json)) => match ChatTemplate::from_tokenizer_config(&cfg_json) {
Ok(Some(tmpl)) => {
tracing::info!(model = %model_id,
"chat-template routing enabled; chat requests route by templated tokens");
return Some(ChatFormatter::Jinja(Box::new(tmpl)));
}
Ok(None) => {} // no template — fall through to built-in detection
Err(e) => tracing::warn!(model = %model_id, error = %e,
"failed to compile chat template; falling back to built-in detection"),
},
Ok(None) => {}
Err(e) => tracing::warn!(model = %model_id, error = %e,
"failed to load tokenizer_config.json; falling back to built-in detection"),
}
if is_deepseek_v4(model_id) {
tracing::info!(model = %model_id,
"DeepSeek-V4 routing enabled; chat requests route via the built-in V4 formatter");
return Some(ChatFormatter::DeepSeekV4);
}
tracing::info!(model = %model_id,
"no chat template or built-in formatter; chat traffic routes via raw prompt text");
None
}
pub fn get(&self, model_id: &str) -> Option<Arc<Tokenizer>> {
self.inner.get(model_id).map(|r| Arc::clone(&*r))
}
@@ -145,34 +101,20 @@ impl TokenizerRegistry {
self.formatters.contains_key(model_id)
}
/// Render `messages` through the model's chat formatter, then tokenize the
/// result the same way the engine does (`add_special_tokens = false`, so the
/// formatter's literal `bos_token`/role markers carry the specials). Returns
/// `None` — caller falls back to raw routing — when the model has no
/// formatter, no tokenizer, or rendering/encoding fails or yields no tokens.
pub fn encode_chat(&self, model_id: &str, messages: &serde_json::Value) -> Option<Vec<u32>> {
/// Render with dynamo-render and tokenize; return `None` when unavailable or unsuccessful.
pub fn encode_chat(&self, model_id: &str, request: &serde_json::Value) -> Option<Vec<u32>> {
// Clone the Arc and drop the DashMap guard before the CPU-bound
// render+encode (mirrors `get`), so no shard read-lock is held across it.
let entry = Arc::clone(&*self.formatters.get(model_id)?);
let tokenizer = self.get(model_id)?;
let rendered = entry
.formatter
.render(messages)
.inspect_err(|e| {
// `{e:#}` prints the full anyhow chain, so the underlying
// minijinja cause (e.g. a `raise_exception` message) is
// visible, not just the "render chat template" context.
entry.log_fallback(model_id, &format!("render failed: {e:#}"))
})
.ok()?;
match adapter::encode(&tokenizer, &rendered) {
match entry.formatter.encode(&tokenizer, request) {
Ok(ids) if !ids.is_empty() => Some(ids),
Ok(_) => {
entry.log_fallback(model_id, "rendered prompt tokenized to zero tokens");
None
}
Err(e) => {
entry.log_fallback(model_id, &format!("tokenize failed: {e:#}"));
entry.log_fallback(model_id, &format!("render or tokenize failed: {e:#}"));
None
}
}
@@ -201,23 +143,13 @@ impl TokenizerRegistry {
model_id: &str,
tokenizer_config: &serde_json::Value,
) {
let template = ChatTemplate::from_tokenizer_config(tokenizer_config)
let formatter = ChatFormatter::from_tokenizer_config(tokenizer_config.clone(), None)
.expect("valid test chat template")
.expect("test tokenizer_config has a chat_template");
self.attach_chat_formatter_for_test(model_id, ChatFormatter::Jinja(Box::new(template)));
self.attach_chat_formatter_for_test(model_id, formatter);
}
}
/// Whether `model_id` denotes a DeepSeek-V4 model, which the engine encodes via
/// the built-in [`dsv4`] formatter rather than a Jinja template. Heuristic on the
/// served model id (the router has no model architecture from `/server_info`);
/// scoped to "deepseek" + "v4" so it doesn't claim V3-family models, whose
/// encoding differs.
fn is_deepseek_v4(model_id: &str) -> bool {
let id = model_id.to_ascii_lowercase();
id.contains("deepseek") && id.contains("v4")
}
#[cfg(test)]
mod tests {
use super::*;
@@ -236,6 +168,7 @@ mod tests {
model: crate::config::ModelConfig {
id: "tiny".into(),
tokenizer_path: "tests/fixtures/tiny_tokenizer.json".into(),
disable_input_ids_forwarding: false,
policy: PolicyKind::RoundRobin,
decode_policy: Default::default(),
bucket_config: None,
@@ -384,7 +317,7 @@ mod tests {
}
#[test]
fn load_tokenizer_config_reads_sibling() {
fn model_files_json_reads_sibling() {
let dir = tempfile::tempdir().unwrap();
let tok = dir.path().join("tokenizer.json");
std::fs::write(&tok, "{}").unwrap();
@@ -393,18 +326,64 @@ mod tests {
r#"{"chat_template":"X","bos_token":"<s>"}"#,
)
.unwrap();
let cfg = adapter::load_tokenizer_config(tok.to_str().unwrap())
let cfg = adapter::ModelFiles::open(tok.to_str().unwrap())
.json("tokenizer_config.json")
.unwrap()
.expect("sibling tokenizer_config.json is loaded");
assert_eq!(cfg["chat_template"], "X");
}
/// Families the engine encodes in code skip a shipped template; V4.1 counts as V4.
#[test]
fn load_tokenizer_config_absent_returns_none() {
fn chat_formatter_load_preserves_native_precedence() {
let dir = tempfile::tempdir().unwrap();
let tok = dir.path().join("tokenizer.json");
std::fs::write(&tok, "{}").unwrap();
assert!(adapter::load_tokenizer_config(tok.to_str().unwrap())
std::fs::write(dir.path().join("chat_template.jinja"), "T").unwrap();
let resolve = |model_type: &str| {
let cfg = serde_json::json!({ "model_type": model_type }).to_string();
std::fs::write(dir.path().join("config.json"), cfg).unwrap();
ChatFormatter::load("m", tok.to_str().unwrap()).unwrap()
};
let request = serde_json::json!({"messages": [{"role": "user", "content": "hi"}]});
for model_type in ["llama", "deepseek_v32"] {
assert_eq!(resolve(model_type).unwrap().render(&request).unwrap(), "T");
}
assert!(resolve("inkling_mm_model").is_none());
assert!(resolve("kimi_k3").is_none());
assert_eq!(
resolve("deepseek_v41").unwrap().render(&request).unwrap(),
"<begin▁of▁sentence><User>hi<Assistant></think>"
);
}
#[test]
fn invalid_chat_template_keeps_tokenizer_available() {
let dir = tempfile::tempdir().unwrap();
let tok = dir.path().join("tokenizer.json");
std::fs::copy("tests/fixtures/tiny_tokenizer.json", &tok).unwrap();
std::fs::write(
dir.path().join("config.json"),
r#"{"model_type":"deepseek_v32"}"#,
)
.unwrap();
std::fs::write(dir.path().join("chat_template.jinja"), "{% invalid %}").unwrap();
let mut cfg = cfg();
cfg.model.tokenizer_path = tok.to_str().unwrap().to_owned();
let reg = TokenizerRegistry::load_from_config(&cfg).unwrap();
let tokenizer = reg.get(&cfg.model.id).unwrap();
assert!(!adapter::encode(&tokenizer, "hello").unwrap().is_empty());
assert!(!reg.has_chat_formatter(&cfg.model.id));
}
#[test]
fn model_files_json_absent_returns_none() {
let dir = tempfile::tempdir().unwrap();
let tok = dir.path().join("tokenizer.json");
std::fs::write(&tok, "{}").unwrap();
assert!(adapter::ModelFiles::open(tok.to_str().unwrap())
.json("tokenizer_config.json")
.unwrap()
.is_none());
}
@@ -426,8 +405,8 @@ mod tests {
reg.attach_chat_template_for_test("tiny", &cfg);
assert!(reg.has_chat_formatter("tiny"));
let messages = serde_json::json!([{"role":"user","content":"hi"}]);
let chat_ids = reg.encode_chat("tiny", &messages).expect("encode_chat");
let request = serde_json::json!({"messages": [{"role":"user","content":"hi"}]});
let chat_ids = reg.encode_chat("tiny", &request).expect("encode_chat");
assert!(!chat_ids.is_empty());
let tok = reg.get("tiny").unwrap();
@@ -437,17 +416,42 @@ mod tests {
"chat-templated tokens must differ from raw-content tokens"
);
// encode_chat is exactly tokenize(render(messages)).
// encode_chat is exactly tokenize(render(request)).
let rendered = reg
.formatters
.get("tiny")
.unwrap()
.formatter
.render(&messages)
.render(&request)
.unwrap();
assert_eq!(chat_ids, adapter::encode(&tok, &rendered).unwrap());
}
#[test]
fn routing_tokenization_receives_tools_and_template_kwargs() {
let reg = TokenizerRegistry::default();
let tok = adapter::load("tests/fixtures/tiny_tokenizer.json").unwrap();
reg.inner.insert("tiny".into(), Arc::clone(&tok));
reg.attach_chat_template_for_test(
"tiny",
&serde_json::json!({
"chat_template": "{{ tools[0].function.name }} {{ greeting }}"
}),
);
let request = serde_json::json!({
"messages": [{"role": "user", "content": "hi"}],
"tools": [{"type": "function", "function": {"name": "hello"}}],
"chat_template_kwargs": {"greeting": "world"}
});
let tokens = crate::policies::request_tokens_for(
&reg,
&crate::discovery::ModelId("tiny".into()),
&request,
)
.expect("request tokenizes");
assert_eq!(tokens.ids, adapter::encode(&tok, "hello world").unwrap());
}
#[test]
fn encode_chat_none_without_template() {
let reg = TokenizerRegistry::default();
@@ -456,8 +460,8 @@ mod tests {
adapter::load("tests/fixtures/tiny_tokenizer.json").unwrap(),
);
assert!(!reg.has_chat_formatter("tiny"));
let messages = serde_json::json!([{"role":"user","content":"hi"}]);
assert!(reg.encode_chat("tiny", &messages).is_none());
let request = serde_json::json!({"messages": [{"role":"user","content":"hi"}]});
assert!(reg.encode_chat("tiny", &request).is_none());
}
/// A template that fails to render (here, one that calls `raise_exception`)
@@ -478,20 +482,10 @@ mod tests {
}),
);
assert!(reg.has_chat_formatter("tiny"));
let messages = serde_json::json!([{"role":"user","content":"hi"}]);
let request = serde_json::json!({"messages": [{"role":"user","content":"hi"}]});
assert!(
reg.encode_chat("tiny", &messages).is_none(),
reg.encode_chat("tiny", &request).is_none(),
"a failing render must yield None so routing falls back to raw text"
);
}
#[test]
fn is_deepseek_v4_matches_v4_only() {
assert!(is_deepseek_v4("deepseek-ai/DeepSeek-V4-Flash"));
assert!(is_deepseek_v4("DeepSeek-V4-Pro"));
// Not V4-family models.
assert!(!is_deepseek_v4("deepseek-ai/DeepSeek-V3.2"));
assert!(!is_deepseek_v4("Qwen/Qwen3-0.6B"));
assert!(!is_deepseek_v4("tiny"));
}
}
@@ -591,6 +591,7 @@ mod tests {
model: ModelConfig {
id: id.into(),
tokenizer_path: "/tmp/x".into(),
disable_input_ids_forwarding: false,
policy: PolicyKind::RoundRobin,
decode_policy: Default::default(),
bucket_config: None,
@@ -130,6 +130,7 @@ async fn static_urls_pd_role_resolved_end_to_end() {
model: sgl_router::config::ModelConfig {
id: "tiny".into(),
tokenizer_path: "tests/fixtures/tiny_tokenizer.json".into(),
disable_input_ids_forwarding: false,
policy: sgl_router::config::PolicyKind::RoundRobin,
decode_policy: Default::default(),
bucket_config: None,
@@ -61,6 +61,7 @@ fn build_app_context(
model: ModelConfig {
id: "tiny".into(),
tokenizer_path: "tests/fixtures/tiny_tokenizer.json".into(),
disable_input_ids_forwarding: false,
policy,
decode_policy: Default::default(),
bucket_config: Some(bucket_config),
@@ -34,6 +34,7 @@ fn config_for(_worker_url: &str) -> Config {
model: ModelConfig {
id: "tiny".into(),
tokenizer_path: "tests/fixtures/tiny_tokenizer.json".into(),
disable_input_ids_forwarding: false,
policy: PolicyKind::RoundRobin,
decode_policy: Default::default(),
bucket_config: None,
@@ -26,6 +26,7 @@ pub fn config() -> Config {
model: ModelConfig {
id: MODEL.into(),
tokenizer_path: "tests/fixtures/tiny_tokenizer.json".into(),
disable_input_ids_forwarding: false,
policy: PolicyKind::CacheAware,
decode_policy: Default::default(),
bucket_config: None,
@@ -35,6 +35,7 @@ async fn failover_when_one_worker_dies() {
model: ModelConfig {
id: "tiny".into(),
tokenizer_path: "tests/fixtures/tiny_tokenizer.json".into(),
disable_input_ids_forwarding: false,
policy: PolicyKind::RoundRobin,
decode_policy: Default::default(),
bucket_config: None,
@@ -53,6 +53,7 @@ fn build_ctx_with_worker(worker_url: &str) -> Arc<AppContext> {
model: ModelConfig {
id: "tiny".into(),
tokenizer_path: "tests/fixtures/tiny_tokenizer.json".into(),
disable_input_ids_forwarding: false,
policy: PolicyKind::RoundRobin,
decode_policy: Default::default(),
bucket_config: None,
@@ -31,6 +31,7 @@ async fn forwards_whitelisted_headers_strips_others() {
model: ModelConfig {
id: "tiny".into(),
tokenizer_path: "tests/fixtures/tiny_tokenizer.json".into(),
disable_input_ids_forwarding: false,
policy: PolicyKind::RoundRobin,
decode_policy: Default::default(),
bucket_config: None,
@@ -46,6 +46,7 @@ fn config() -> Config {
model: ModelConfig {
id: "tiny".into(),
tokenizer_path: "tests/fixtures/tiny_tokenizer.json".into(),
disable_input_ids_forwarding: false,
policy: PolicyKind::RoundRobin,
decode_policy: Default::default(),
bucket_config: None,
@@ -45,6 +45,7 @@ fn config() -> Config {
model: ModelConfig {
id: "tiny".into(),
tokenizer_path: "tests/fixtures/tiny_tokenizer.json".into(),
disable_input_ids_forwarding: false,
policy: PolicyKind::RoundRobin,
decode_policy: Default::default(),
bucket_config: None,
@@ -133,6 +133,7 @@ fn config() -> Config {
model: ModelConfig {
id: "tiny".into(),
tokenizer_path: "tests/fixtures/tiny_tokenizer.json".into(),
disable_input_ids_forwarding: false,
policy: PolicyKind::RoundRobin,
decode_policy: Default::default(),
bucket_config: None,
@@ -16,11 +16,12 @@ use sgl_router::config::{
};
use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
use sgl_router::policies::factory::build_registry_with_defaults;
use sgl_router::policies::{Policy, SelectionContext};
use sgl_router::proxy::Proxy;
use sgl_router::server::app::build_router;
use sgl_router::server::app_context::AppContext;
use sgl_router::tokenizer::TokenizerRegistry;
use sgl_router::workers::WorkerRegistry;
use sgl_router::workers::{Worker, WorkerRegistry};
use std::sync::Arc;
use std::time::Duration;
use tower::ServiceExt;
@@ -42,6 +43,7 @@ fn config() -> Config {
model: ModelConfig {
id: MODEL.into(),
tokenizer_path: "tests/fixtures/tiny_tokenizer.json".into(),
disable_input_ids_forwarding: false,
policy: PolicyKind::RoundRobin,
decode_policy: Default::default(),
bucket_config: None,
@@ -62,7 +64,10 @@ fn config() -> Config {
}
fn build_ctx(url: String) -> Arc<AppContext> {
let cfg = config();
build_ctx_with_config(url, config())
}
fn build_ctx_with_config(url: String, cfg: Config) -> Arc<AppContext> {
// The handler tokenizes via the AppContext's registry (which carries the V4
// encoder); the RoundRobin policy itself needs no tokenizer.
let tokenizers = Arc::new(TokenizerRegistry::load_from_config(&cfg).unwrap());
@@ -80,6 +85,36 @@ fn build_ctx(url: String) -> Arc<AppContext> {
Arc::new(AppContext::new(cfg, tokenizers, proxy, registry, policies))
}
fn template_config(tokenizer_config: Value) -> (tempfile::TempDir, Config) {
let dir = tempfile::tempdir().unwrap();
let tokenizer = dir.path().join("tokenizer.json");
std::fs::copy("tests/fixtures/tiny_tokenizer.json", &tokenizer).unwrap();
std::fs::write(
dir.path().join("tokenizer_config.json"),
tokenizer_config.to_string(),
)
.unwrap();
let mut cfg = config();
cfg.model.tokenizer_path = tokenizer.to_str().unwrap().into();
(dir, cfg)
}
fn without_forwarding(mut cfg: Config, policy: PolicyKind) -> Config {
cfg.model.policy = policy;
cfg.model.cache_aware = (policy == PolicyKind::CacheAware).then(Default::default);
cfg.model.disable_input_ids_forwarding = true;
cfg
}
async fn assert_forwarded_unchanged(ctx: &Arc<AppContext>, mock: &MockWorker, request: &Value) {
assert_eq!(send(Arc::clone(ctx), request.clone()).await, StatusCode::OK);
assert_eq!(captured(mock), *request);
assert!(!ctx
.metrics
.render()
.contains("sgl_router_ingress_tokenize_errors_total{"));
}
async fn send(ctx: Arc<AppContext>, body: Value) -> StatusCode {
let app = build_router(ctx);
let req = Request::builder()
@@ -130,6 +165,98 @@ async fn round_robin_plain_chat_forwards_input_ids() {
);
}
#[tokio::test]
async fn forwarding_opt_out_preserves_messages_and_caller_ids() {
for policy in [PolicyKind::RoundRobin, PolicyKind::CacheAware] {
let mock = MockWorker::start(vec![]).await;
let ctx = build_ctx_with_config(mock.url.clone(), without_forwarding(config(), policy));
let mut request =
json!({"model": MODEL, "messages": [{"role": "user", "content": "hello"}]});
assert_forwarded_unchanged(&ctx, &mock, &request).await;
request["input_ids"] = json!([42, 43]);
assert_forwarded_unchanged(&ctx, &mock, &request).await;
}
}
#[tokio::test]
async fn forwarding_opt_out_keeps_ingress_tokens_for_routing() {
#[derive(Debug)]
struct ExpectTokens(Vec<u32>);
impl Policy for ExpectTokens {
fn needs_request_tokens(&self) -> bool {
true
}
fn select(
&self,
workers: &[Arc<Worker>],
ctx: &SelectionContext<'_>,
) -> Option<Arc<Worker>> {
assert_eq!(ctx.request_tokens(), Some(self.0.as_slice()));
workers.first().cloned()
}
}
let mock = MockWorker::start(vec![]).await;
let cfg = without_forwarding(config(), PolicyKind::RoundRobin);
let ctx = build_ctx_with_config(mock.url.clone(), cfg);
let request = json!({"model": MODEL, "messages": [{"role": "user", "content": "hello"}]});
let expected = ctx.tokenizers.encode_chat(MODEL, &request).unwrap();
ctx.policies
.insert(ModelId(MODEL.into()), Arc::new(ExpectTokens(expected)));
assert_forwarded_unchanged(&ctx, &mock, &request).await;
}
/// Array-only deployments must opt out until Dynamo exposes its conversion flag.
#[tokio::test]
async fn array_only_template_opt_out_preserves_engine_processing() {
let (_dir, cfg) = template_config(json!({
"chat_template": "{% for m in messages %}{% for part in m.content %}{{ part.text }}{% endfor %}{% endfor %}"
}));
let mock = MockWorker::start(vec![]).await;
let ctx = build_ctx_with_config(
mock.url.clone(),
without_forwarding(cfg, PolicyKind::CacheAware),
);
let request = json!({"model": MODEL, "messages": [{"role": "user", "content": "hello"}]});
assert!(!ctx
.tokenizers
.encode_chat(MODEL, &request)
.unwrap()
.is_empty());
assert_forwarded_unchanged(&ctx, &mock, &request).await;
}
#[tokio::test]
async fn template_with_date_helper_forwards_input_ids() {
// GPT-OSS uses strftime_now; a bare Jinja probe incorrectly blocks it.
let (_dir, cfg) = template_config(json!({
"chat_template": "{{ strftime_now('%Y-%m-%d') }}{% for m in messages %}{{ m.content }}{% endfor %}"
}));
let mock = MockWorker::start(vec![]).await;
let ctx = build_ctx_with_config(mock.url.clone(), cfg);
let request = json!({"model": MODEL, "messages": [{"role": "user", "content": "hello"}]});
let expected = ctx.tokenizers.encode_chat(MODEL, &request).unwrap();
assert_eq!(send(ctx, request.clone()).await, StatusCode::OK);
let body = captured(&mock);
assert_eq!(body["input_ids"], json!(expected));
assert_eq!(body["messages"], request["messages"]);
}
#[tokio::test]
async fn disabled_forwarding_does_not_count_routing_render_failures_as_offload_errors() {
let (_dir, cfg) =
template_config(json!({"chat_template": "{{ raise_exception('cannot render') }}"}));
let mock = MockWorker::start(vec![]).await;
let ctx = build_ctx_with_config(
mock.url.clone(),
without_forwarding(cfg, PolicyKind::CacheAware),
);
let request = json!({"model": MODEL, "messages": [{"role": "user", "content": "hello"}]});
assert!(ctx.tokenizers.encode_chat(MODEL, &request).is_none());
assert_forwarded_unchanged(&ctx, &mock, &request).await;
}
/// Even under round-robin, a tool request omits `input_ids` (the safe predicate
/// is policy-independent too).
#[tokio::test]
@@ -196,3 +323,79 @@ async fn successful_forward_does_not_emit_ingress_tokenize_error() {
"healthy forwards (and expected omissions) must not emit the error counter; got:\n{m}",
);
}
/// History that dynamo-render rewrites stays intact for engine-side tokenization.
#[tokio::test]
async fn reasoning_history_preserves_messages_without_forwarding_ids() {
let (_dir, cfg) = template_config(json!({
"chat_template": "{% for m in messages %}{{ m.role }}:{{ m.content }};{% endfor %}"
}));
let mock = MockWorker::start(vec![]).await;
let ctx = build_ctx_with_config(mock.url.clone(), cfg);
let mut request = json!({"model": MODEL, "messages": [
{"role":"user", "content":"hi"},
{"role":"assistant", "content":"answer", "reasoning_content":"prior reasoning"},
{"role":"user", "content":"next"}
]});
assert!(!ctx
.tokenizers
.encode_chat(MODEL, &request)
.unwrap()
.is_empty());
assert_forwarded_unchanged(&ctx, &mock, &request).await;
request["messages"][1]
.as_object_mut()
.unwrap()
.remove("reasoning_content");
assert_eq!(send(ctx, request).await, StatusCode::OK);
assert!(captured(&mock).get("input_ids").is_some());
}
/// Strict-template rewrites are used for routing only; the engine gets the original turns.
#[tokio::test]
async fn role_rewrites_preserve_messages_without_forwarding_ids() {
let template = concat!(
"{%- set ns = namespace(prev='') -%}",
"{%- for m in messages -%}",
"{%- if m.role == 'system' and not loop.first -%}",
"{{ raise_exception('System message must be first.') }}",
"{%- endif -%}",
"{%- if m.role == 'user' and ns.prev == 'user' -%}",
"{{ raise_exception('Conversation roles must alternate.') }}",
"{%- endif -%}",
"{{ m.role }}:{{ m.content }};",
"{%- set ns.prev = m.role -%}",
"{%- endfor -%}"
);
let (_dir, cfg) = template_config(json!({
"chat_template": template, "sp_model_kwargs": {"enable_sampling": false}
}));
let mock = MockWorker::start(vec![]).await;
let ctx = build_ctx_with_config(mock.url.clone(), cfg);
for roles in [
vec!["user", "user"],
vec!["system", "system", "user"],
vec!["user", "assistant", "system", "user"],
] {
let messages: Vec<_> = roles
.iter()
.map(|role| json!({"role": role, "content": "text"}))
.collect();
let request = json!({"model": MODEL, "messages": messages});
assert!(!ctx
.tokenizers
.encode_chat(MODEL, &request)
.unwrap()
.is_empty());
assert_forwarded_unchanged(&ctx, &mock, &request).await;
}
let request = json!({"model": MODEL, "messages": [
{"role": "system", "content": "instructions"},
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "hello"},
{"role": "user", "content": "next"}
]});
assert_eq!(send(ctx, request).await, StatusCode::OK);
assert!(captured(&mock).get("input_ids").is_some());
}
@@ -149,6 +149,7 @@ fn config(policy: PolicyKind) -> Config {
model: ModelConfig {
id: "tiny".into(),
tokenizer_path: "tests/fixtures/tiny_tokenizer.json".into(),
disable_input_ids_forwarding: false,
policy,
decode_policy: Default::default(),
bucket_config: None,
@@ -54,6 +54,7 @@ fn config() -> Config {
model: ModelConfig {
id: MODEL.into(),
tokenizer_path: "tests/fixtures/tiny_tokenizer.json".into(),
disable_input_ids_forwarding: false,
policy: PolicyKind::Sticky,
decode_policy: Default::default(),
bucket_config: None,
@@ -42,6 +42,7 @@ fn build_sticky_ctx(header_name: &str, worker_urls: &[String]) -> Arc<AppContext
model: ModelConfig {
id: "tiny".into(),
tokenizer_path: "tests/fixtures/tiny_tokenizer.json".into(),
disable_input_ids_forwarding: false,
policy: PolicyKind::Sticky,
decode_policy: Default::default(),
bucket_config: None,
@@ -38,6 +38,7 @@ fn config(_worker_url: &str) -> Config {
model: ModelConfig {
id: "tiny".into(),
tokenizer_path: "tests/fixtures/tiny_tokenizer.json".into(),
disable_input_ids_forwarding: false,
policy: PolicyKind::RoundRobin,
decode_policy: Default::default(),
bucket_config: None,