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

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kan Wu
2026-09-17 15:57:20 +08:00
committed by GitHub
co-authored by Claude Fable 5.1
parent 15b256bdb0
commit 3ce7e2a29f
33 changed files with 1048 additions and 864 deletions
@@ -16,11 +16,12 @@ use sgl_router::config::{
};
use sgl_router::discovery::{ModelId, WorkerId, WorkerMode, WorkerSpec};
use sgl_router::policies::factory::build_registry_with_defaults;
use sgl_router::policies::{Policy, SelectionContext};
use sgl_router::proxy::Proxy;
use sgl_router::server::app::build_router;
use sgl_router::server::app_context::AppContext;
use sgl_router::tokenizer::TokenizerRegistry;
use sgl_router::workers::WorkerRegistry;
use sgl_router::workers::{Worker, WorkerRegistry};
use std::sync::Arc;
use std::time::Duration;
use tower::ServiceExt;
@@ -42,6 +43,7 @@ fn config() -> Config {
model: ModelConfig {
id: MODEL.into(),
tokenizer_path: "tests/fixtures/tiny_tokenizer.json".into(),
disable_input_ids_forwarding: false,
policy: PolicyKind::RoundRobin,
decode_policy: Default::default(),
bucket_config: None,
@@ -62,7 +64,10 @@ fn config() -> Config {
}
fn build_ctx(url: String) -> Arc<AppContext> {
let cfg = config();
build_ctx_with_config(url, config())
}
fn build_ctx_with_config(url: String, cfg: Config) -> Arc<AppContext> {
// The handler tokenizes via the AppContext's registry (which carries the V4
// encoder); the RoundRobin policy itself needs no tokenizer.
let tokenizers = Arc::new(TokenizerRegistry::load_from_config(&cfg).unwrap());
@@ -80,6 +85,36 @@ fn build_ctx(url: String) -> Arc<AppContext> {
Arc::new(AppContext::new(cfg, tokenizers, proxy, registry, policies))
}
fn template_config(tokenizer_config: Value) -> (tempfile::TempDir, Config) {
let dir = tempfile::tempdir().unwrap();
let tokenizer = dir.path().join("tokenizer.json");
std::fs::copy("tests/fixtures/tiny_tokenizer.json", &tokenizer).unwrap();
std::fs::write(
dir.path().join("tokenizer_config.json"),
tokenizer_config.to_string(),
)
.unwrap();
let mut cfg = config();
cfg.model.tokenizer_path = tokenizer.to_str().unwrap().into();
(dir, cfg)
}
fn without_forwarding(mut cfg: Config, policy: PolicyKind) -> Config {
cfg.model.policy = policy;
cfg.model.cache_aware = (policy == PolicyKind::CacheAware).then(Default::default);
cfg.model.disable_input_ids_forwarding = true;
cfg
}
async fn assert_forwarded_unchanged(ctx: &Arc<AppContext>, mock: &MockWorker, request: &Value) {
assert_eq!(send(Arc::clone(ctx), request.clone()).await, StatusCode::OK);
assert_eq!(captured(mock), *request);
assert!(!ctx
.metrics
.render()
.contains("sgl_router_ingress_tokenize_errors_total{"));
}
async fn send(ctx: Arc<AppContext>, body: Value) -> StatusCode {
let app = build_router(ctx);
let req = Request::builder()
@@ -130,6 +165,98 @@ async fn round_robin_plain_chat_forwards_input_ids() {
);
}
#[tokio::test]
async fn forwarding_opt_out_preserves_messages_and_caller_ids() {
for policy in [PolicyKind::RoundRobin, PolicyKind::CacheAware] {
let mock = MockWorker::start(vec![]).await;
let ctx = build_ctx_with_config(mock.url.clone(), without_forwarding(config(), policy));
let mut request =
json!({"model": MODEL, "messages": [{"role": "user", "content": "hello"}]});
assert_forwarded_unchanged(&ctx, &mock, &request).await;
request["input_ids"] = json!([42, 43]);
assert_forwarded_unchanged(&ctx, &mock, &request).await;
}
}
#[tokio::test]
async fn forwarding_opt_out_keeps_ingress_tokens_for_routing() {
#[derive(Debug)]
struct ExpectTokens(Vec<u32>);
impl Policy for ExpectTokens {
fn needs_request_tokens(&self) -> bool {
true
}
fn select(
&self,
workers: &[Arc<Worker>],
ctx: &SelectionContext<'_>,
) -> Option<Arc<Worker>> {
assert_eq!(ctx.request_tokens(), Some(self.0.as_slice()));
workers.first().cloned()
}
}
let mock = MockWorker::start(vec![]).await;
let cfg = without_forwarding(config(), PolicyKind::RoundRobin);
let ctx = build_ctx_with_config(mock.url.clone(), cfg);
let request = json!({"model": MODEL, "messages": [{"role": "user", "content": "hello"}]});
let expected = ctx.tokenizers.encode_chat(MODEL, &request).unwrap();
ctx.policies
.insert(ModelId(MODEL.into()), Arc::new(ExpectTokens(expected)));
assert_forwarded_unchanged(&ctx, &mock, &request).await;
}
/// Array-only deployments must opt out until Dynamo exposes its conversion flag.
#[tokio::test]
async fn array_only_template_opt_out_preserves_engine_processing() {
let (_dir, cfg) = template_config(json!({
"chat_template": "{% for m in messages %}{% for part in m.content %}{{ part.text }}{% endfor %}{% endfor %}"
}));
let mock = MockWorker::start(vec![]).await;
let ctx = build_ctx_with_config(
mock.url.clone(),
without_forwarding(cfg, PolicyKind::CacheAware),
);
let request = json!({"model": MODEL, "messages": [{"role": "user", "content": "hello"}]});
assert!(!ctx
.tokenizers
.encode_chat(MODEL, &request)
.unwrap()
.is_empty());
assert_forwarded_unchanged(&ctx, &mock, &request).await;
}
#[tokio::test]
async fn template_with_date_helper_forwards_input_ids() {
// GPT-OSS uses strftime_now; a bare Jinja probe incorrectly blocks it.
let (_dir, cfg) = template_config(json!({
"chat_template": "{{ strftime_now('%Y-%m-%d') }}{% for m in messages %}{{ m.content }}{% endfor %}"
}));
let mock = MockWorker::start(vec![]).await;
let ctx = build_ctx_with_config(mock.url.clone(), cfg);
let request = json!({"model": MODEL, "messages": [{"role": "user", "content": "hello"}]});
let expected = ctx.tokenizers.encode_chat(MODEL, &request).unwrap();
assert_eq!(send(ctx, request.clone()).await, StatusCode::OK);
let body = captured(&mock);
assert_eq!(body["input_ids"], json!(expected));
assert_eq!(body["messages"], request["messages"]);
}
#[tokio::test]
async fn disabled_forwarding_does_not_count_routing_render_failures_as_offload_errors() {
let (_dir, cfg) =
template_config(json!({"chat_template": "{{ raise_exception('cannot render') }}"}));
let mock = MockWorker::start(vec![]).await;
let ctx = build_ctx_with_config(
mock.url.clone(),
without_forwarding(cfg, PolicyKind::CacheAware),
);
let request = json!({"model": MODEL, "messages": [{"role": "user", "content": "hello"}]});
assert!(ctx.tokenizers.encode_chat(MODEL, &request).is_none());
assert_forwarded_unchanged(&ctx, &mock, &request).await;
}
/// Even under round-robin, a tool request omits `input_ids` (the safe predicate
/// is policy-independent too).
#[tokio::test]
@@ -196,3 +323,79 @@ async fn successful_forward_does_not_emit_ingress_tokenize_error() {
"healthy forwards (and expected omissions) must not emit the error counter; got:\n{m}",
);
}
/// History that dynamo-render rewrites stays intact for engine-side tokenization.
#[tokio::test]
async fn reasoning_history_preserves_messages_without_forwarding_ids() {
let (_dir, cfg) = template_config(json!({
"chat_template": "{% for m in messages %}{{ m.role }}:{{ m.content }};{% endfor %}"
}));
let mock = MockWorker::start(vec![]).await;
let ctx = build_ctx_with_config(mock.url.clone(), cfg);
let mut request = json!({"model": MODEL, "messages": [
{"role":"user", "content":"hi"},
{"role":"assistant", "content":"answer", "reasoning_content":"prior reasoning"},
{"role":"user", "content":"next"}
]});
assert!(!ctx
.tokenizers
.encode_chat(MODEL, &request)
.unwrap()
.is_empty());
assert_forwarded_unchanged(&ctx, &mock, &request).await;
request["messages"][1]
.as_object_mut()
.unwrap()
.remove("reasoning_content");
assert_eq!(send(ctx, request).await, StatusCode::OK);
assert!(captured(&mock).get("input_ids").is_some());
}
/// Strict-template rewrites are used for routing only; the engine gets the original turns.
#[tokio::test]
async fn role_rewrites_preserve_messages_without_forwarding_ids() {
let template = concat!(
"{%- set ns = namespace(prev='') -%}",
"{%- for m in messages -%}",
"{%- if m.role == 'system' and not loop.first -%}",
"{{ raise_exception('System message must be first.') }}",
"{%- endif -%}",
"{%- if m.role == 'user' and ns.prev == 'user' -%}",
"{{ raise_exception('Conversation roles must alternate.') }}",
"{%- endif -%}",
"{{ m.role }}:{{ m.content }};",
"{%- set ns.prev = m.role -%}",
"{%- endfor -%}"
);
let (_dir, cfg) = template_config(json!({
"chat_template": template, "sp_model_kwargs": {"enable_sampling": false}
}));
let mock = MockWorker::start(vec![]).await;
let ctx = build_ctx_with_config(mock.url.clone(), cfg);
for roles in [
vec!["user", "user"],
vec!["system", "system", "user"],
vec!["user", "assistant", "system", "user"],
] {
let messages: Vec<_> = roles
.iter()
.map(|role| json!({"role": role, "content": "text"}))
.collect();
let request = json!({"model": MODEL, "messages": messages});
assert!(!ctx
.tokenizers
.encode_chat(MODEL, &request)
.unwrap()
.is_empty());
assert_forwarded_unchanged(&ctx, &mock, &request).await;
}
let request = json!({"model": MODEL, "messages": [
{"role": "system", "content": "instructions"},
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "hello"},
{"role": "user", "content": "next"}
]});
assert_eq!(send(ctx, request).await, StatusCode::OK);
assert!(captured(&mock).get("input_ids").is_some());
}