[sgl-router] Rename chat encoder to chat formatter (#39459)
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
5aaa18207c
commit
ad94978adf
@@ -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]
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user