[sgl-router] Rename chat encoder to chat formatter (#39459)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kan Wu
2026-09-16 08:46:11 -07:00
committed by GitHub
co-authored by Claude Fable 5.1
parent 5aaa18207c
commit ad94978adf
8 changed files with 117 additions and 113 deletions
+10 -10
View File
@@ -34,8 +34,9 @@ use std::sync::Arc;
pub struct RequestTokens { pub struct RequestTokens {
/// The prompt token ids. /// The prompt token ids.
pub ids: Vec<u32>, pub ids: Vec<u32>,
/// Whether the token ids are safe to forward as engine `input_ids`. /// Whether the IDs came from rendered chat messages.
pub engine_equivalent: bool, /// Forwarding also requires the request safety guard.
pub rendered_from_chat: bool,
} }
/// External indexer answer prepared by the async ingress path for the /// External indexer answer prepared by the async ingress path for the
@@ -45,19 +46,18 @@ pub struct ExternalPrefixSignal {
pub query_blocks: usize, pub query_blocks: usize,
} }
/// Tokenizes a request for routing. Chat-encoder tokens are engine-equivalent; /// Tokenizes requests for routing, preferring chat rendering over raw text.
/// raw prompt tokens are used only for routing.
pub fn request_tokens_for( pub fn request_tokens_for(
tokenizers: &TokenizerRegistry, tokenizers: &TokenizerRegistry,
model_id: &ModelId, model_id: &ModelId,
value: &serde_json::Value, value: &serde_json::Value,
) -> Option<RequestTokens> { ) -> Option<RequestTokens> {
if tokenizers.has_chat_encoder(&model_id.0) { if tokenizers.has_chat_formatter(&model_id.0) {
if let Some(messages) = value.get("messages").filter(|m| m.is_array()) { if let Some(messages) = value.get("messages").filter(|m| m.is_array()) {
if let Some(ids) = tokenizers.encode_chat(&model_id.0, messages) { if let Some(ids) = tokenizers.encode_chat(&model_id.0, messages) {
return Some(RequestTokens { return Some(RequestTokens {
ids, ids,
engine_equivalent: true, rendered_from_chat: true,
}); });
} }
} }
@@ -66,7 +66,7 @@ pub fn request_tokens_for(
let ids = tokenize_text(tokenizers, model_id, &text)?; let ids = tokenize_text(tokenizers, model_id, &text)?;
Some(RequestTokens { Some(RequestTokens {
ids, ids,
engine_equivalent: false, rendered_from_chat: false,
}) })
} }
@@ -511,11 +511,11 @@ pub trait Policy: Send + Sync + std::fmt::Debug {
/// Whether this policy's routing decision needs request tokens (i.e. /// Whether this policy's routing decision needs request tokens (i.e.
/// it routes by prompt prefix). Ingress tokenization itself is no longer /// it routes by prompt prefix). Ingress tokenization itself is no longer
/// gated on this — that is a model property (`has_chat_encoder`) decided at /// 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 /// ingress via [`request_tokens_for`]. This flag is the EXTRA gate that
/// keeps the cache-aware policy's RAW-prompt routing path alive: a /// keeps the cache-aware policy's RAW-prompt routing path alive: a
/// cache-aware model with no chat encoder still wants its `/v1/completions` /// cache-aware model with no chat formatter still wants its `/v1/completions`
/// /`text` prompt tokenized for tree matching, which `has_chat_encoder` /// /`text` prompt tokenized for tree matching, which `has_chat_formatter`
/// alone would not trigger. Default `false` for load-only and sticky /// alone would not trigger. Default `false` for load-only and sticky
/// routes; only the cache-aware policy overrides it. /// routes; only the cache-aware policy overrides it.
fn needs_request_tokens(&self) -> bool { fn needs_request_tokens(&self) -> bool {
@@ -735,7 +735,7 @@ impl MetricsRegistry {
/// Bump `sgl_router_ingress_tokenize_errors_total{model_id}`. /// Bump `sgl_router_ingress_tokenize_errors_total{model_id}`.
/// ///
/// Count encoder failures only for chats eligible for `input_ids` /// Count formatter failures only for chats eligible for `input_ids`
/// forwarding. Requests excluded by the guard are expected fallbacks. /// forwarding. Requests excluded by the guard are expected fallbacks.
/// Pairs with the per-model WARN log in `encode_chat`. /// Pairs with the per-model WARN log in `encode_chat`.
pub fn record_ingress_tokenize_error(&self, model_id: &str) { pub fn record_ingress_tokenize_error(&self, model_id: &str) {
@@ -1191,7 +1191,7 @@ impl MetricsRegistry {
// ingress_tokenize_errors_total // ingress_tokenize_errors_total
out.push_str( out.push_str(
"# HELP sgl_router_ingress_tokenize_errors_total Plain text chat requests on a chat-encoder model whose ingress rendering or tokenization failed, silently falling back to engine-side tokenization (the input_ids offload was defeated).\n", "# HELP sgl_router_ingress_tokenize_errors_total Plain text chat requests on a chat-formatter model whose ingress rendering or tokenization failed, silently falling back to engine-side tokenization (the input_ids offload was defeated).\n",
); );
out.push_str("# TYPE sgl_router_ingress_tokenize_errors_total counter\n"); out.push_str("# TYPE sgl_router_ingress_tokenize_errors_total counter\n");
let guard = self.ingress_tokenize_errors_total.lock(); let guard = self.ingress_tokenize_errors_total.lock();
@@ -504,16 +504,16 @@ pub async fn chat_completions(
// Tokenize once at ingress whenever it can pay off — decoupled from the // Tokenize once at ingress whenever it can pay off — decoupled from the
// routing policy, because forwarding `input_ids` is a property of the // routing policy, because forwarding `input_ids` is a property of the
// MODEL (does it have a chat encoder so the router can produce // MODEL (does it have a chat formatter so the router can produce
// engine-equivalent tokens?), not of how we pick the worker. Two gates: // engine-equivalent tokens?), not of how we pick the worker. Two gates:
// //
// * `has_chat_encoder` → a chat request on this model yields // * `has_chat_formatter` → a chat request on this model yields
// engine-equivalent ids we can forward as `input_ids` so the engine // engine-equivalent ids we can forward as `input_ids` so the engine
// skips re-tokenizing. This enables the offload for EVERY policy — // skips re-tokenizing. This enables the offload for EVERY policy —
// sticky and round-robin included — not just cache-aware. // sticky and round-robin included — not just cache-aware.
// * `needs_request_tokens()` → the cache-aware policy ALSO wants the // * `needs_request_tokens()` → the cache-aware policy ALSO wants the
// raw-prompt path tokenized for tree matching even on a model with no // raw-prompt path tokenized for tree matching even on a model with no
// chat encoder (`/v1/completions` / `text`), which the first gate // chat formatter (`/v1/completions` / `text`), which the first gate
// alone wouldn't trigger. // alone wouldn't trigger.
// //
// * Bucket routing also needs the prompt token count. // * Bucket routing also needs the prompt token count.
@@ -524,7 +524,7 @@ pub async fn chat_completions(
// tokenization and the outgoing-body injection below (and PD bootstrap // tokenization and the outgoing-body injection below (and PD bootstrap
// injection). `parse_probe` already validated the object shape. // injection). `parse_probe` already validated the object shape.
let want_tokens = should_tokenize_request( let want_tokens = should_tokenize_request(
ctx.tokenizers.has_chat_encoder(&model_str), ctx.tokenizers.has_chat_formatter(&model_str),
policy.needs_request_tokens(), policy.needs_request_tokens(),
ctx.bucket_selector.is_enabled(), ctx.bucket_selector.is_enabled(),
); );
@@ -798,8 +798,8 @@ pub async fn chat_completions(
// Forward the router-computed tokens to the engine as `input_ids` so it // Forward the router-computed tokens to the engine as `input_ids` so it
// skips re-tokenizing the same prompt — but only when they are // skips re-tokenizing the same prompt — but only when they are
// engine-equivalent (chat-encoder path) AND the request contains nothing // engine-equivalent (chat-formatter path) AND the request contains nothing
// the router's encoder didn't replicate (see `input_ids_safe_to_forward`). // the router's formatter didn't replicate (see `input_ids_safe_to_forward`).
// Otherwise omit them and the engine tokenizes from `messages` as usual — // Otherwise omit them and the engine tokenizes from `messages` as usual —
// a transparent, always-correct fallback (`messages` are always retained // a transparent, always-correct fallback (`messages` are always retained
// in the forwarded body). `forward_input_ids` is `Some` only when // in the forwarded body). `forward_input_ids` is `Some` only when
@@ -807,19 +807,19 @@ pub async fn chat_completions(
// predicate always has a parsed body to inspect. // predicate always has a parsed body to inspect.
let forward_input_ids: Option<&[u32]> = match (request_tokens.as_ref(), request_value.as_ref()) let forward_input_ids: Option<&[u32]> = match (request_tokens.as_ref(), request_value.as_ref())
{ {
(Some(t), Some(v)) if t.engine_equivalent && input_ids_safe_to_forward(v) => { (Some(t), Some(v)) if t.rendered_from_chat && input_ids_safe_to_forward(v) => {
Some(t.ids.as_slice()) Some(t.ids.as_slice())
} }
_ => None, _ => None,
}; };
// Surface a broken offload: when the encoder SHOULD have produced // Surface a broken offload: when the formatter SHOULD have produced
// engine-equivalent ids but didn't, the chat request silently fell back to // engine-equivalent ids but didn't, the chat request silently fell back to
// engine-side tokenization. Count only that case (see // engine-side tokenization. Count only that case (see
// `ingress_tokenize_offload_failed`); successful forwards and expected // `ingress_tokenize_offload_failed`); successful forwards and expected
// omissions are not problems. // omissions are not problems.
if ingress_tokenize_offload_failed( if ingress_tokenize_offload_failed(
ctx.tokenizers.has_chat_encoder(&model_str), ctx.tokenizers.has_chat_formatter(&model_str),
request_value.as_ref(), request_value.as_ref(),
request_tokens.as_ref(), request_tokens.as_ref(),
) { ) {
@@ -1198,11 +1198,11 @@ fn parse_optional_positive_f64_header(
} }
fn should_tokenize_request( fn should_tokenize_request(
has_chat_encoder: bool, has_chat_formatter: bool,
policy_needs_request_tokens: bool, policy_needs_request_tokens: bool,
bucket_enabled: bool, bucket_enabled: bool,
) -> bool { ) -> bool {
has_chat_encoder || policy_needs_request_tokens || bucket_enabled has_chat_formatter || policy_needs_request_tokens || bucket_enabled
} }
/// Estimate prefill-token count from the raw request body for use as /// Estimate prefill-token count from the raw request body for use as
@@ -1339,28 +1339,28 @@ fn build_outgoing_body(
/// uses it verbatim and ignores everything that would otherwise steer its /// uses it verbatim and ignores everything that would otherwise steer its
/// `messages`-side tokenization (only stop tokens / tool-call constraint are /// `messages`-side tokenization (only stop tokens / tool-call constraint are
/// still taken from `messages`). So any request field that changes that /// still taken from `messages`). So any request field that changes that
/// tokenization but which the router's chat encoder does not replicate makes /// tokenization but which the router's chat formatter does not replicate makes
/// the forwarded ids wrong. This predicate is conservative by construction — /// the forwarded ids wrong. This predicate is conservative by construction —
/// any such signal returns `false` and the engine tokenizes from `messages` /// any such signal returns `false` and the engine tokenizes from `messages`
/// (always correct). /// (always correct).
/// ///
/// Replicated-and-safe: plain text `messages` with a string `content`. /// Replicated-and-safe: plain text `messages` with a string `content`.
/// Not replicated → omit: /// Not replicated → omit:
/// * `tools` / `functions` — the encoder doesn't render tool schemas. /// * `tools` / `functions` — the formatter doesn't render tool schemas.
/// * non-string or missing `content` (arrays, `null`): the engine normalizes /// * non-string or missing `content` (arrays, `null`): the engine normalizes
/// these before rendering; the router's encoder renders them verbatim. /// these before rendering; the router's formatter renders them verbatim.
/// * `chat_template` — an OpenAI-compatible per-request template override /// * `chat_template` — an OpenAI-compatible per-request template override
/// (e.g. vLLM); the router renders with the model's default template, so a /// (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 /// custom one would diverge. (SGLang ignores it today, but block it so the
/// offload stays correct across engines / future versions.) /// offload stays correct across engines / future versions.)
/// * `chat_template_kwargs` (carries `enable_thinking`/`thinking`), /// * `chat_template_kwargs` (carries `enable_thinking`/`thinking`),
/// `reasoning` / `reasoning_effort`, `task` — thinking/mode toggles the /// `reasoning` / `reasoning_effort`, `task` — thinking/mode toggles the
/// encoder renders in the engine's default mode only. /// formatter renders in the engine's default mode only.
/// * `continue_final_message: true`, or a trailing `assistant` message — the /// * `continue_final_message: true`, or a trailing `assistant` message — the
/// engine rewrites/strips the final assistant turn; the encoder renders it /// engine rewrites/strips the final assistant turn; the formatter renders it
/// verbatim. /// verbatim.
/// ///
/// NOTE: the router's chat encoder renders in the engine's default /// NOTE: the router's chat formatter renders in the engine's default
/// (non-thinking) mode. Current sglang derives thinking from the request /// (non-thinking) mode. Current sglang derives thinking from the request
/// (`chat_template_kwargs`), which this guard already omits, so a plain 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 /// the router rendered matches the engine. The only way to diverge is an engine
@@ -1376,7 +1376,7 @@ fn input_ids_safe_to_forward(value: &serde_json::Value) -> bool {
return false; return false;
} }
// Fields that steer the engine's template tokenization but which the // Fields that steer the engine's template tokenization but which the
// router's encoder does not thread through. // router's formatter does not thread through.
for key in [ for key in [
"chat_template", "chat_template",
"chat_template_kwargs", "chat_template_kwargs",
@@ -1400,15 +1400,15 @@ fn input_ids_safe_to_forward(value: &serde_json::Value) -> bool {
/// Whether to increment `sgl_router_ingress_tokenize_errors_total`. /// Whether to increment `sgl_router_ingress_tokenize_errors_total`.
/// ///
/// Count chats with a configured encoder that pass the forwarding guard /// Count chats with a configured formatter that pass the forwarding guard
/// but lack engine-equivalent tokens. Excluded requests are expected fallbacks, /// but lack chat-rendered tokens. Excluded requests are expected fallbacks,
/// even when rendering fails. /// even when rendering fails.
fn ingress_tokenize_offload_failed( fn ingress_tokenize_offload_failed(
has_chat_encoder: bool, has_chat_formatter: bool,
request_value: Option<&serde_json::Value>, request_value: Option<&serde_json::Value>,
request_tokens: Option<&RequestTokens>, request_tokens: Option<&RequestTokens>,
) -> bool { ) -> bool {
if !has_chat_encoder { if !has_chat_formatter {
return false; return false;
} }
let chat_request = request_value.is_some_and(|v| { let chat_request = request_value.is_some_and(|v| {
@@ -1417,7 +1417,7 @@ fn ingress_tokenize_offload_failed(
if !chat_request { if !chat_request {
return false; return false;
} }
!request_tokens.is_some_and(|t| t.engine_equivalent) !request_tokens.is_some_and(|t| t.rendered_from_chat)
} }
/// Whether the final chat message has `role: "assistant"` (a prefix / /// Whether the final chat message has `role: "assistant"` (a prefix /
@@ -1433,7 +1433,7 @@ fn last_message_is_assistant(value: &serde_json::Value) -> bool {
} }
/// Whether the request carries tool / function definitions. The router's chat /// Whether the request carries tool / function definitions. The router's chat
/// encoder renders only `messages`, so its `input_ids` would omit the tool /// 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 /// schemas the engine's template injects into the prompt — the caller must let
/// the engine tokenize these itself. /// the engine tokenize these itself.
fn request_has_tools(value: &serde_json::Value) -> bool { fn request_has_tools(value: &serde_json::Value) -> bool {
@@ -1448,7 +1448,7 @@ fn request_has_tools(value: &serde_json::Value) -> bool {
} }
/// Detect non-string or missing content, which requires engine tokenization: /// Detect non-string or missing content, which requires engine tokenization:
/// the engine normalizes arrays and nulls differently from the router's encoder. /// the engine normalizes arrays and nulls differently from the router's formatter.
fn request_has_non_text_content(value: &serde_json::Value) -> bool { fn request_has_non_text_content(value: &serde_json::Value) -> bool {
value value
.get("messages") .get("messages")
@@ -1747,7 +1747,7 @@ mod tests {
} }
/// Tool / function requests are detected so the caller omits `input_ids` /// Tool / function requests are detected so the caller omits `input_ids`
/// (the router's encoder doesn't render tools). /// (the router's formatter doesn't render tools).
#[test] #[test]
fn request_has_tools_detects_tools_and_functions() { fn request_has_tools_detects_tools_and_functions() {
assert!(request_has_tools( assert!(request_has_tools(
@@ -1792,7 +1792,7 @@ mod tests {
} }
/// Every field the engine honors on the `messages` path but which the /// Every field the engine honors on the `messages` path but which the
/// router's encoder does not replicate must block forwarding — otherwise /// router's formatter does not replicate must block forwarding — otherwise
/// the engine uses the router's ids verbatim and silently runs a different /// the engine uses the router's ids verbatim and silently runs a different
/// prompt than the request asked for. /// prompt than the request asked for.
#[test] #[test]
@@ -1848,14 +1848,14 @@ mod tests {
assert!(parsed.get("input_ids").is_none()); assert!(parsed.get("input_ids").is_none());
} }
/// A chat request on a chat-encoder model that yields engine-equivalent /// A chat request on a chat-formatter model that yields engine-equivalent
/// ids (encode succeeded) is NOT a failure — the offload worked. /// ids (encode succeeded) is NOT a failure — the offload worked.
#[test] #[test]
fn offload_failed_false_when_tokens_engine_equivalent() { fn offload_failed_false_when_tokens_rendered_from_chat() {
let value = serde_json::json!({"messages":[{"role":"user","content":"hi"}]}); let value = serde_json::json!({"messages":[{"role":"user","content":"hi"}]});
let tokens = RequestTokens { let tokens = RequestTokens {
ids: vec![1, 2, 3], ids: vec![1, 2, 3],
engine_equivalent: true, rendered_from_chat: true,
}; };
assert!(!ingress_tokenize_offload_failed( assert!(!ingress_tokenize_offload_failed(
true, true,
@@ -1874,22 +1874,22 @@ mod tests {
assert!(!ingress_tokenize_offload_failed(true, Some(&value), None)); assert!(!ingress_tokenize_offload_failed(true, Some(&value), None));
} }
/// Missing tokens count as a failure for an eligible chat with an encoder. /// Missing tokens count as a failure for an eligible chat with a formatter.
#[test] #[test]
fn offload_failed_true_when_chat_encoder_request_has_no_tokens() { fn offload_failed_true_when_chat_formatter_request_has_no_tokens() {
let value = serde_json::json!({"messages":[{"role":"user","content":"hi"}]}); let value = serde_json::json!({"messages":[{"role":"user","content":"hi"}]});
assert!(ingress_tokenize_offload_failed(true, Some(&value), None)); assert!(ingress_tokenize_offload_failed(true, Some(&value), None));
} }
/// Encode produced ids but NOT via the chat encoder (raw fallback, /// Encode produced ids but NOT via the chat formatter (raw fallback,
/// `engine_equivalent = false`) on a chat-encoder model + chat request → /// `rendered_from_chat = false`) on a chat-formatter model + chat request →
/// the chat-encode render/encode failed and fell through to the raw path. /// the chat-encode render/encode failed and fell through to the raw path.
#[test] #[test]
fn offload_failed_true_when_tokens_not_engine_equivalent() { fn offload_failed_true_when_tokens_not_rendered_from_chat() {
let value = serde_json::json!({"messages":[{"role":"user","content":"hi"}]}); let value = serde_json::json!({"messages":[{"role":"user","content":"hi"}]});
let tokens = RequestTokens { let tokens = RequestTokens {
ids: vec![1, 2, 3], ids: vec![1, 2, 3],
engine_equivalent: false, rendered_from_chat: false,
}; };
assert!(ingress_tokenize_offload_failed( assert!(ingress_tokenize_offload_failed(
true, true,
@@ -1898,15 +1898,15 @@ mod tests {
)); ));
} }
/// Non-chat-encoder models never expected the offload → not a failure even /// Non-chat-formatter models never expected the offload → not a failure even
/// with no tokens. /// with no tokens.
#[test] #[test]
fn offload_failed_false_without_chat_encoder() { fn offload_failed_false_without_chat_formatter() {
let value = serde_json::json!({"messages":[{"role":"user","content":"hi"}]}); let value = serde_json::json!({"messages":[{"role":"user","content":"hi"}]});
assert!(!ingress_tokenize_offload_failed(false, Some(&value), None)); assert!(!ingress_tokenize_offload_failed(false, Some(&value), None));
} }
/// A non-chat (no `messages`) request on a chat-encoder 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 /// `/v1/completions` `prompt` — never expected the chat-encode offload, so
/// the absence of engine-equivalent ids is not a failure. /// the absence of engine-equivalent ids is not a failure.
#[test] #[test]
+50 -46
View File
@@ -15,7 +15,7 @@ use std::sync::Arc;
/// How to turn a chat request's `messages` into the prompt the engine tokenizes /// 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 /// and caches. Cache-aware routing renders this before hashing so its query
/// tokens match the engine's stored blocks. /// tokens match the engine's stored blocks.
pub enum ChatEncoder { pub enum ChatFormatter {
/// HuggingFace Jinja chat template from `tokenizer_config.json` (most /// HuggingFace Jinja chat template from `tokenizer_config.json` (most
/// models). Boxed: it holds a minijinja `Environment`, far larger than the /// models). Boxed: it holds a minijinja `Environment`, far larger than the
/// other variants. /// other variants.
@@ -24,26 +24,26 @@ pub enum ChatEncoder {
DeepSeekV4, DeepSeekV4,
} }
impl ChatEncoder { impl ChatFormatter {
/// Render `messages` into the engine-equivalent prompt text. /// Render `messages` into the engine-equivalent prompt text.
fn render(&self, messages: &serde_json::Value) -> Result<String> { fn render(&self, messages: &serde_json::Value) -> Result<String> {
match self { match self {
ChatEncoder::Jinja(t) => t.render(messages), ChatFormatter::Jinja(t) => t.render(messages),
ChatEncoder::DeepSeekV4 => Ok(dsv4::render_messages(messages)), ChatFormatter::DeepSeekV4 => Ok(dsv4::render_messages(messages)),
} }
} }
} }
/// A model's chat encoder plus its fallback-logging state. /// A model's chat formatter plus its fallback-logging state.
struct ChatEncoderEntry { struct ChatFormatterEntry {
encoder: ChatEncoder, formatter: ChatFormatter,
fallback_warned: AtomicBool, fallback_warned: AtomicBool,
} }
impl ChatEncoderEntry { impl ChatFormatterEntry {
fn new(encoder: ChatEncoder) -> Self { fn new(formatter: ChatFormatter) -> Self {
Self { Self {
encoder, formatter,
fallback_warned: AtomicBool::new(false), fallback_warned: AtomicBool::new(false),
} }
} }
@@ -56,11 +56,11 @@ impl ChatEncoderEntry {
fn log_fallback(&self, model_id: &str, cause: &str) { fn log_fallback(&self, model_id: &str, cause: &str) {
if !self.fallback_warned.swap(true, Ordering::Relaxed) { if !self.fallback_warned.swap(true, Ordering::Relaxed) {
tracing::warn!(model = %model_id, %cause, tracing::warn!(model = %model_id, %cause,
"chat-encoder failed; falling back to raw prompt-text hashing \ "chat-formatter failed; falling back to raw prompt-text hashing \
(cache-aware overlap degrades for this model; further failures log at debug)"); (cache-aware overlap degrades for this model; further failures log at debug)");
} else { } else {
tracing::debug!(model = %model_id, %cause, tracing::debug!(model = %model_id, %cause,
"chat-encoder failed; falling back to raw prompt-text hashing"); "chat-formatter failed; falling back to raw prompt-text hashing");
} }
} }
} }
@@ -68,12 +68,12 @@ impl ChatEncoderEntry {
#[derive(Default)] #[derive(Default)]
pub struct TokenizerRegistry { pub struct TokenizerRegistry {
inner: DashMap<String, Arc<Tokenizer>>, inner: DashMap<String, Arc<Tokenizer>>,
/// Per-model chat encoder, present only when the model's prompt format is /// Per-model chat formatter, present only when the model's prompt format is
/// known (a `tokenizer_config.json` chat template, or a built-in encoder /// known (a `tokenizer_config.json` chat template, or a built-in formatter
/// like DeepSeek-V4's). Cache-aware routing uses it to tokenize chat /// 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 /// requests the way the engine does; models without one fall back to raw
/// prompt-text tokenization. /// prompt-text tokenization.
encoders: DashMap<String, Arc<ChatEncoderEntry>>, formatters: DashMap<String, Arc<ChatFormatterEntry>>,
} }
impl std::fmt::Debug for TokenizerRegistry { impl std::fmt::Debug for TokenizerRegistry {
@@ -90,28 +90,32 @@ impl TokenizerRegistry {
let m = &cfg.model; let m = &cfg.model;
let t = adapter::load(&m.tokenizer_path)?; let t = adapter::load(&m.tokenizer_path)?;
me.inner.insert(m.id.clone(), t); me.inner.insert(m.id.clone(), t);
// Resolve the chat encoder, best-effort: a Jinja template from // Resolve the chat formatter, best-effort: a Jinja template from
// tokenizer_config.json, else a built-in encoder for a recognized model // tokenizer_config.json, else a built-in formatter for a recognized model
// (DeepSeek-V4), else none (chat traffic routes via raw text). Every // (DeepSeek-V4), else none (chat traffic routes via raw text). Every
// path logs its outcome — whether chat-aware routing is live for this // path logs its outcome — whether chat-aware routing is live for this
// model is the single most useful signal for diagnosing "cache-aware // model is the single most useful signal for diagnosing "cache-aware
// routing degraded to overlap=0 on chat traffic", so it must never be // routing degraded to overlap=0 on chat traffic", so it must never be
// silent. // silent.
if let Some(encoder) = me.resolve_chat_encoder(&m.id, &m.tokenizer_path) { if let Some(formatter) = me.resolve_chat_formatter(&m.id, &m.tokenizer_path) {
me.encoders me.formatters
.insert(m.id.clone(), Arc::new(ChatEncoderEntry::new(encoder))); .insert(m.id.clone(), Arc::new(ChatFormatterEntry::new(formatter)));
} }
Ok(me) Ok(me)
} }
/// Pick the chat encoder for a model, logging the outcome on every branch. /// Pick the chat formatter for a model, logging the outcome on every branch.
fn resolve_chat_encoder(&self, model_id: &str, tokenizer_path: &str) -> Option<ChatEncoder> { fn resolve_chat_formatter(
&self,
model_id: &str,
tokenizer_path: &str,
) -> Option<ChatFormatter> {
match adapter::load_tokenizer_config(tokenizer_path) { match adapter::load_tokenizer_config(tokenizer_path) {
Ok(Some(cfg_json)) => match ChatTemplate::from_tokenizer_config(&cfg_json) { Ok(Some(cfg_json)) => match ChatTemplate::from_tokenizer_config(&cfg_json) {
Ok(Some(tmpl)) => { Ok(Some(tmpl)) => {
tracing::info!(model = %model_id, tracing::info!(model = %model_id,
"chat-template routing enabled; chat requests route by templated tokens"); "chat-template routing enabled; chat requests route by templated tokens");
return Some(ChatEncoder::Jinja(Box::new(tmpl))); return Some(ChatFormatter::Jinja(Box::new(tmpl)));
} }
Ok(None) => {} // no template — fall through to built-in detection Ok(None) => {} // no template — fall through to built-in detection
Err(e) => tracing::warn!(model = %model_id, error = %e, Err(e) => tracing::warn!(model = %model_id, error = %e,
@@ -123,11 +127,11 @@ impl TokenizerRegistry {
} }
if is_deepseek_v4(model_id) { if is_deepseek_v4(model_id) {
tracing::info!(model = %model_id, tracing::info!(model = %model_id,
"DeepSeek-V4 routing enabled; chat requests route via the built-in V4 encoder"); "DeepSeek-V4 routing enabled; chat requests route via the built-in V4 formatter");
return Some(ChatEncoder::DeepSeekV4); return Some(ChatFormatter::DeepSeekV4);
} }
tracing::info!(model = %model_id, tracing::info!(model = %model_id,
"no chat template or built-in encoder; chat traffic routes via raw prompt text"); "no chat template or built-in formatter; chat traffic routes via raw prompt text");
None None
} }
@@ -135,24 +139,24 @@ impl TokenizerRegistry {
self.inner.get(model_id).map(|r| Arc::clone(&*r)) self.inner.get(model_id).map(|r| Arc::clone(&*r))
} }
/// Whether this model has a chat encoder (and thus the chat-aware /// Whether this model has a chat formatter (and thus the chat-aware
/// tokenization path is available for it). /// tokenization path is available for it).
pub fn has_chat_encoder(&self, model_id: &str) -> bool { pub fn has_chat_formatter(&self, model_id: &str) -> bool {
self.encoders.contains_key(model_id) self.formatters.contains_key(model_id)
} }
/// Render `messages` through the model's chat encoder, then tokenize the /// Render `messages` through the model's chat formatter, then tokenize the
/// result the same way the engine does (`add_special_tokens = false`, so the /// result the same way the engine does (`add_special_tokens = false`, so the
/// encoder's literal `bos_token`/role markers carry the specials). Returns /// formatter's literal `bos_token`/role markers carry the specials). Returns
/// `None` — caller falls back to raw routing — when the model has no /// `None` — caller falls back to raw routing — when the model has no
/// encoder, no tokenizer, or rendering/encoding fails or yields no tokens. /// 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>> { pub fn encode_chat(&self, model_id: &str, messages: &serde_json::Value) -> Option<Vec<u32>> {
// Clone the Arc and drop the DashMap guard before the CPU-bound // 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. // render+encode (mirrors `get`), so no shard read-lock is held across it.
let entry = Arc::clone(&*self.encoders.get(model_id)?); let entry = Arc::clone(&*self.formatters.get(model_id)?);
let tokenizer = self.get(model_id)?; let tokenizer = self.get(model_id)?;
let rendered = entry let rendered = entry
.encoder .formatter
.render(messages) .render(messages)
.inspect_err(|e| { .inspect_err(|e| {
// `{e:#}` prints the full anyhow chain, so the underlying // `{e:#}` prints the full anyhow chain, so the underlying
@@ -178,18 +182,18 @@ impl TokenizerRegistry {
self.inner.iter().map(|kv| kv.key().clone()).collect() self.inner.iter().map(|kv| kv.key().clone()).collect()
} }
/// Attach a chat encoder to an already-loaded model. Lets policy tests in /// Attach a chat formatter to an already-loaded model. Lets policy tests in
/// other modules exercise the chat-aware routing path without a co-located /// other modules exercise the chat-aware routing path without a co-located
/// fixture. /// fixture.
#[cfg(test)] #[cfg(test)]
pub(crate) fn attach_chat_encoder_for_test(&self, model_id: &str, encoder: ChatEncoder) { pub(crate) fn attach_chat_formatter_for_test(&self, model_id: &str, formatter: ChatFormatter) {
self.encoders.insert( self.formatters.insert(
model_id.to_string(), model_id.to_string(),
Arc::new(ChatEncoderEntry::new(encoder)), Arc::new(ChatFormatterEntry::new(formatter)),
); );
} }
/// Convenience: attach a Jinja chat encoder built from an inline /// Convenience: attach a Jinja chat formatter built from an inline
/// `tokenizer_config.json` value. /// `tokenizer_config.json` value.
#[cfg(test)] #[cfg(test)]
pub(crate) fn attach_chat_template_for_test( pub(crate) fn attach_chat_template_for_test(
@@ -200,12 +204,12 @@ impl TokenizerRegistry {
let template = ChatTemplate::from_tokenizer_config(tokenizer_config) let template = ChatTemplate::from_tokenizer_config(tokenizer_config)
.expect("valid test chat template") .expect("valid test chat template")
.expect("test tokenizer_config has a chat_template"); .expect("test tokenizer_config has a chat_template");
self.attach_chat_encoder_for_test(model_id, ChatEncoder::Jinja(Box::new(template))); self.attach_chat_formatter_for_test(model_id, ChatFormatter::Jinja(Box::new(template)));
} }
} }
/// Whether `model_id` denotes a DeepSeek-V4 model, which the engine encodes via /// Whether `model_id` denotes a DeepSeek-V4 model, which the engine encodes via
/// the built-in [`dsv4`] encoder rather than a Jinja template. Heuristic on the /// 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`); /// 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 /// scoped to "deepseek" + "v4" so it doesn't claim V3-family models, whose
/// encoding differs. /// encoding differs.
@@ -420,7 +424,7 @@ mod tests {
"bos_token": "<s>", "bos_token": "<s>",
}); });
reg.attach_chat_template_for_test("tiny", &cfg); reg.attach_chat_template_for_test("tiny", &cfg);
assert!(reg.has_chat_encoder("tiny")); assert!(reg.has_chat_formatter("tiny"));
let messages = serde_json::json!([{"role":"user","content":"hi"}]); let messages = serde_json::json!([{"role":"user","content":"hi"}]);
let chat_ids = reg.encode_chat("tiny", &messages).expect("encode_chat"); let chat_ids = reg.encode_chat("tiny", &messages).expect("encode_chat");
@@ -435,10 +439,10 @@ mod tests {
// encode_chat is exactly tokenize(render(messages)). // encode_chat is exactly tokenize(render(messages)).
let rendered = reg let rendered = reg
.encoders .formatters
.get("tiny") .get("tiny")
.unwrap() .unwrap()
.encoder .formatter
.render(&messages) .render(&messages)
.unwrap(); .unwrap();
assert_eq!(chat_ids, adapter::encode(&tok, &rendered).unwrap()); assert_eq!(chat_ids, adapter::encode(&tok, &rendered).unwrap());
@@ -451,7 +455,7 @@ mod tests {
"tiny".into(), "tiny".into(),
adapter::load("tests/fixtures/tiny_tokenizer.json").unwrap(), adapter::load("tests/fixtures/tiny_tokenizer.json").unwrap(),
); );
assert!(!reg.has_chat_encoder("tiny")); assert!(!reg.has_chat_formatter("tiny"));
let messages = serde_json::json!([{"role":"user","content":"hi"}]); let messages = serde_json::json!([{"role":"user","content":"hi"}]);
assert!(reg.encode_chat("tiny", &messages).is_none()); assert!(reg.encode_chat("tiny", &messages).is_none());
} }
@@ -473,7 +477,7 @@ mod tests {
"bos_token": "<s>", "bos_token": "<s>",
}), }),
); );
assert!(reg.has_chat_encoder("tiny")); assert!(reg.has_chat_formatter("tiny"));
let messages = serde_json::json!([{"role":"user","content":"hi"}]); let messages = serde_json::json!([{"role":"user","content":"hi"}]);
assert!( assert!(
reg.encode_chat("tiny", &messages).is_none(), reg.encode_chat("tiny", &messages).is_none(),
@@ -6,7 +6,7 @@
//! skips re-tokenizing the same prompt). Asserts the gating contract through //! skips re-tokenizing the same prompt). Asserts the gating contract through
//! the real chat handler + a MockWorker backend: //! the real chat handler + a MockWorker backend:
//! //!
//! * A plain text chat request on the engine-equivalent chat-encoder path → //! * A plain text chat request on the engine-equivalent chat-formatter path →
//! the forwarded body carries `input_ids` AND retains `messages`. //! the forwarded body carries `input_ids` AND retains `messages`.
//! * A request carrying `tools` → `input_ids` omitted (the router's encoder //! * A request carrying `tools` → `input_ids` omitted (the router's encoder
//! doesn't render tool schemas, so its ids would diverge from the engine). //! doesn't render tool schemas, so its ids would diverge from the engine).
@@ -35,8 +35,8 @@ fn build_ctx(url: String) -> Arc<AppContext> {
let cfg = config(); let cfg = config();
let tokenizers = Arc::new(TokenizerRegistry::load_from_config(&cfg).unwrap()); let tokenizers = Arc::new(TokenizerRegistry::load_from_config(&cfg).unwrap());
assert!( assert!(
tokenizers.has_chat_encoder(MODEL), tokenizers.has_chat_formatter(MODEL),
"deepseek-v4 model id must auto-attach the built-in chat encoder" "deepseek-v4 model id must auto-attach the built-in chat formatter"
); );
let registry = Arc::new(WorkerRegistry::default()); let registry = Arc::new(WorkerRegistry::default());
let _ = registry.add(WorkerSpec { let _ = registry.add(WorkerSpec {
@@ -4,7 +4,7 @@
//! Shared router config for the cache-aware proxy tests. //! Shared router config for the cache-aware proxy tests.
//! //!
//! The model id contains `deepseek-v4` so the tokenizer registry auto-attaches the //! The model id contains `deepseek-v4` so the tokenizer registry auto-attaches the
//! built-in V4 chat encoder — the engine-equivalent path — with no template fixture. //! built-in V4 chat formatter — the engine-equivalent path — with no template fixture.
use sgl_router::config::{ use sgl_router::config::{
ActiveLoadConfig, CacheAwareConfig, Config, DiscoveryBackend, ModelConfig, ObservabilityConfig, ActiveLoadConfig, CacheAwareConfig, Config, DiscoveryBackend, ModelConfig, ObservabilityConfig,
@@ -2,10 +2,10 @@
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
//! `input_ids` forwarding is policy-independent: a load-only **round-robin** //! `input_ids` forwarding is policy-independent: a load-only **round-robin**
//! policy on a chat-encoder model still forwards `input_ids` to the engine //! policy on a chat-formatter model still forwards `input_ids` to the engine
//! (the engine-tokenization offload), even though it picks workers round-robin //! (the engine-tokenization offload), even though it picks workers round-robin
//! and ignores the tokens for routing. Tokenization is gated on the model's //! and ignores the tokens for routing. Tokenization is gated on the model's
//! chat encoder at ingress, not on the policy. //! chat formatter at ingress, not on the policy.
use axum::body::Body; use axum::body::Body;
use axum::http::{Request, StatusCode}; use axum::http::{Request, StatusCode};
@@ -66,7 +66,7 @@ fn build_ctx(url: String) -> Arc<AppContext> {
// The handler tokenizes via the AppContext's registry (which carries the V4 // The handler tokenizes via the AppContext's registry (which carries the V4
// encoder); the RoundRobin policy itself needs no tokenizer. // encoder); the RoundRobin policy itself needs no tokenizer.
let tokenizers = Arc::new(TokenizerRegistry::load_from_config(&cfg).unwrap()); let tokenizers = Arc::new(TokenizerRegistry::load_from_config(&cfg).unwrap());
assert!(tokenizers.has_chat_encoder(MODEL)); assert!(tokenizers.has_chat_formatter(MODEL));
let registry = Arc::new(WorkerRegistry::default()); let registry = Arc::new(WorkerRegistry::default());
let _ = registry.add(WorkerSpec { let _ = registry.add(WorkerSpec {
id: WorkerId(url.clone()), id: WorkerId(url.clone()),
@@ -103,7 +103,7 @@ fn captured(mock: &MockWorker) -> Value {
} }
/// A round-robin (load-only) policy still forwards `input_ids` on a /// A round-robin (load-only) policy still forwards `input_ids` on a
/// chat-encoder model — the offload is decoupled from routing. /// chat-formatter model — the offload is decoupled from routing.
#[tokio::test] #[tokio::test]
async fn round_robin_plain_chat_forwards_input_ids() { async fn round_robin_plain_chat_forwards_input_ids() {
let mock = MockWorker::start(vec![]).await; let mock = MockWorker::start(vec![]).await;
@@ -122,7 +122,7 @@ async fn round_robin_plain_chat_forwards_input_ids() {
let ids = body.get("input_ids").and_then(|v| v.as_array()); let ids = body.get("input_ids").and_then(|v| v.as_array());
assert!( assert!(
ids.is_some_and(|a| !a.is_empty()), ids.is_some_and(|a| !a.is_empty()),
"round-robin must forward input_ids on a chat-encoder model; got {body}" "round-robin must forward input_ids on a chat-formatter model; got {body}"
); );
assert!( assert!(
body.get("messages").is_some(), body.get("messages").is_some(),
@@ -154,7 +154,7 @@ async fn round_robin_tool_request_omits_input_ids() {
); );
} }
/// A successful plain-chat forward on a chat-encoder model must NOT emit /// A successful plain-chat forward on a chat-formatter model must NOT emit
/// `sgl_router_ingress_tokenize_errors_total` — that counter fires only when the /// `sgl_router_ingress_tokenize_errors_total` — that counter fires only when the
/// offload was expected but the encoder failed. A tool request on the same model /// offload was expected but the encoder failed. A tool request on the same model
/// is an *expected* omission (its ids are still engine-equivalent; the /// is an *expected* omission (its ids are still engine-equivalent; the
@@ -3,8 +3,8 @@
//! Tokenize-once at ingress under the STICKY policy. The engine-tokenization //! Tokenize-once at ingress under the STICKY policy. The engine-tokenization
//! offload (`input_ids` forwarding) is a property of the MODEL — does it have a //! offload (`input_ids` forwarding) is a property of the MODEL — does it have a
//! chat encoder? — not of the routing policy, so a sticky-routed request on a //! chat formatter? — not of the routing policy, so a sticky-routed request on a
//! chat-encoder model must forward `input_ids` exactly like cache-aware does, //! chat-formatter model must forward `input_ids` exactly like cache-aware does,
//! while still pinning sessions O(1) by header. //! while still pinning sessions O(1) by header.
//! //!
//! Asserts through the real chat handler + `MockWorker` backends: //! Asserts through the real chat handler + `MockWorker` backends:
@@ -17,7 +17,7 @@
//! routing is unchanged by the added tokenization). //! routing is unchanged by the added tokenization).
//! //!
//! The model id contains `deepseek-v4` so the tokenizer registry auto-attaches //! The model id contains `deepseek-v4` so the tokenizer registry auto-attaches
//! the built-in V4 chat encoder — the engine-equivalent path — without a //! the built-in V4 chat formatter — the engine-equivalent path — without a
//! template fixture. //! template fixture.
use axum::body::Body; use axum::body::Body;
@@ -82,14 +82,14 @@ fn config() -> Config {
/// Build an `AppContext` running the sticky policy over the given workers. /// Build an `AppContext` running the sticky policy over the given workers.
/// The tokenizer registry is loaded from config (real tiny tokenizer + the /// The tokenizer registry is loaded from config (real tiny tokenizer + the
/// auto-attached V4 chat encoder) so the ingress can tokenize — the sticky /// auto-attached V4 chat formatter) so the ingress can tokenize — the sticky
/// policy itself holds no tokenizer. /// policy itself holds no tokenizer.
fn build_ctx(worker_urls: &[String]) -> Arc<AppContext> { fn build_ctx(worker_urls: &[String]) -> Arc<AppContext> {
let cfg = config(); let cfg = config();
let tokenizers = Arc::new(TokenizerRegistry::load_from_config(&cfg).unwrap()); let tokenizers = Arc::new(TokenizerRegistry::load_from_config(&cfg).unwrap());
assert!( assert!(
tokenizers.has_chat_encoder(MODEL), tokenizers.has_chat_formatter(MODEL),
"deepseek-v4 model id must auto-attach the built-in chat encoder" "deepseek-v4 model id must auto-attach the built-in chat formatter"
); );
let registry = Arc::new(WorkerRegistry::default()); let registry = Arc::new(WorkerRegistry::default());
for (i, url) in worker_urls.iter().enumerate() { for (i, url) in worker_urls.iter().enumerate() {