Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
169 changes: 169 additions & 0 deletions tensorrt_llm/inputs/chat_template_guard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
"""Validate caller controls against the active Jinja chat template."""

import hashlib
import os
from collections.abc import Collection
from functools import lru_cache

import jinja2
import jinja2.ext
import jinja2.nodes

from ..logger import logger

ALLOW_UNUSED_CHAT_TEMPLATE_KWARGS_ENV_VAR = "TRTLLM_ALLOW_UNUSED_CHAT_TEMPLATE_KWARGS"


class UnusedChatTemplateKwargsError(ValueError):
"""A caller-supplied chat_template_kwargs key is not read by the template.

A distinct type so serving layers can map this client mistake to HTTP 400
without treating every ValueError raised during rendering the same way.
"""


# Renderer parameters and standard context overrides retain their existing
# behavior. Only additional template controls are checked.
#
# `enable_thinking`, `thinking` and `force_nonempty_content` are not
# template-only controls: the reasoning parsers read them from
# chat_template_kwargs after generation (llmapi/reasoning_parser.py,
# serve/postprocess_handlers.py) to decide how to split reasoning from the
# visible answer, and the servers set `enable_thinking`/`thinking` from
# API-level fields (Responses `reasoning.effort`, Anthropic `thinking`).
# `force_nonempty_content` is consumed by NemotronV3ReasoningParser
# independently of the template. A template that never reads them still
# leaves them with a consumer, so they are accepted everywhere.
ALWAYS_ALLOWED_CHAT_TEMPLATE_KWARGS = frozenset(
{
"enable_thinking",
"thinking",
"force_nonempty_content",
"messages",
"tools",
"documents",
"add_generation_prompt",
"bos_token",
"eos_token",
"unk_token",
"sep_token",
"pad_token",
"cls_token",
"mask_token",
"additional_special_tokens",
"chat_template",
"continue_final_message",
"tokenize",
"padding",
"truncation",
"max_length",
"return_tensors",
"return_dict",
"return_assistant_tokens_mask",
"tokenizer_kwargs",
}
)


class _GenerationTagExtension(jinja2.ext.Extension):
"""Preserve the body of Transformers' generation tag for AST analysis."""

tags = {"generation"}

def parse(self, parser: jinja2.parser.Parser) -> jinja2.nodes.Scope:
lineno = next(parser.stream).lineno
body = parser.parse_statements(("name:endgeneration",), drop_needle=True)
return jinja2.nodes.Scope(body).set_lineno(lineno)


@lru_cache(maxsize=128)
def _referenced_template_variables(template_source: str) -> frozenset[str] | None:
"""Find possible reads; return None when static analysis is incomplete."""
# This environment only parses templates into an AST (env.parse below); it
# never renders, so autoescape has no effect here. Enable it anyway for the
# secure default (bandit B701).
env = jinja2.Environment(
autoescape=True,
extensions=[jinja2.ext.loopcontrols, _GenerationTagExtension],
)
try:
ast = env.parse(template_source)
except jinja2.TemplateSyntaxError:
# Leave syntax errors and custom extensions to the actual renderer.
# A parse failure is not evidence that a caller's control is unused.
return None
if any(
ast.find_all(
(
jinja2.nodes.Include,
jinja2.nodes.Import,
jinja2.nodes.FromImport,
jinja2.nodes.Extends,
)
)
):
return None
# Conservatively include all reads, including globals, self-defaulted
# variables and macro arguments. Meta analysis excludes Jinja globals
# even though caller kwargs can override them.
return frozenset(node.name for node in ast.find_all(jinja2.nodes.Name) if node.ctx == "load")


@lru_cache(maxsize=1024)
def _warn_unused_key(template_hash: str, key: str) -> None:
logger.warning(
f"chat_template_kwargs key '{key}' is not referenced by the active chat template."
)


def validate_chat_template_kwargs(
template_source: str | None,
chat_template_kwargs: dict[str, object] | None,
strict: bool | None = None,
injected_keys: Collection[str] | None = None,
) -> None:
"""Reject unused controls, or warn when the compatibility escape hatch is set.

Non-Jinja renderers and templates that cannot be fully analyzed retain
their existing behavior. Standard renderer options are always accepted.

Args:
template_source: Resolved Jinja source, or None for a native renderer.
chat_template_kwargs: The controls handed to the renderer.
strict: Override the default rejection policy. When omitted,
TRTLLM_ALLOW_UNUSED_CHAT_TEMPLATE_KWARGS=1 selects warnings.
injected_keys: Keys the server derived from API-level fields rather
than the caller. The Anthropic adapter, for instance, sends both
`clear_thinking` (GLM templates) and `drop_thinking` (DeepSeek-V4)
because it cannot know which one the template reads. The caller
cannot remove these, so they are exempt; a template that does not
read one simply ignores it, as before.

Raises:
UnusedChatTemplateKwargsError: A caller-supplied control is not read
by an analyzable template.
"""
if not chat_template_kwargs or not isinstance(template_source, str):
return
referenced = _referenced_template_variables(template_source)
if referenced is None:
return
unknown = set(chat_template_kwargs) - referenced - ALWAYS_ALLOWED_CHAT_TEMPLATE_KWARGS
if injected_keys:
unknown -= set(injected_keys)
unknown = sorted(unknown)
if not unknown:
return
if strict is None:
strict = os.getenv(ALLOW_UNUSED_CHAT_TEMPLATE_KWARGS_ENV_VAR, "0") != "1"
if strict:
raise UnusedChatTemplateKwargsError(
f"chat_template_kwargs {unknown} are not referenced by the active chat template. "
"Remove them, or set "
f"{ALLOW_UNUSED_CHAT_TEMPLATE_KWARGS_ENV_VAR}=1 to allow them with a warning."
)
template_hash = hashlib.sha256(template_source.encode("utf-8")).hexdigest()
for key in unknown:
_warn_unused_key(template_hash, key)
19 changes: 18 additions & 1 deletion tensorrt_llm/inputs/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
from collections import defaultdict
from io import BytesIO
from pathlib import Path
from typing import Any, Coroutine, Dict, List, Optional, Tuple, TypedDict, Union
from typing import (Any, Collection, Coroutine, Dict, List, Optional, Tuple,
TypedDict, Union)
from urllib.parse import urlparse

import numpy as np
Expand All @@ -18,6 +19,8 @@
from transformers import AutoProcessor, PreTrainedTokenizerBase, ProcessorMixin
from transformers.utils import logging

from tensorrt_llm.inputs.chat_template_guard import \
validate_chat_template_kwargs
from tensorrt_llm.inputs.content_format import (ContentFormat,
detect_content_format)
from tensorrt_llm.inputs.data import prompt_inputs
Expand Down Expand Up @@ -648,6 +651,9 @@ def resolve_hf_chat_template(

# 1. If chat_template is not None, return it
if chat_template is not None:
templates = getattr(tokenizer, "chat_template", None)
if isinstance(templates, dict) and chat_template in templates:
return templates[chat_template]
return chat_template

# 2. If tool is not provided, use the processor's default chat template
Expand Down Expand Up @@ -738,9 +744,14 @@ def apply_chat_template(
chat_template: Optional[str] = None,
chat_template_kwargs: Optional[dict[str, Any]] = None,
enable_tokenize: bool = False,
injected_chat_template_kwargs: Optional[Collection[str]] = None,
) -> (str | List[str]):
"""Apply chat template to the conversation.

`injected_chat_template_kwargs` names the keys of `chat_template_kwargs`
the server derived from API-level fields rather than the caller; the
unused-kwargs guard exempts them (see `validate_chat_template_kwargs`).

Uses content-format-driven dispatch:
- PASSTHROUGH: skip template rendering, just concatenate content strings
- PYTHON: use a tokenizer-native renderer when no Jinja template is declared
Expand Down Expand Up @@ -790,6 +801,10 @@ def apply_chat_template(
raise ValueError(
"No chat template found for the given tokenizer and tools.")

validate_chat_template_kwargs(hf_chat_template,
chat_template_kwargs,
injected_keys=injected_chat_template_kwargs)

# Determine content format and prepare conversation accordingly
content_format = _resolve_content_format(model_type, hf_chat_template)

Expand Down Expand Up @@ -829,6 +844,7 @@ async def async_apply_chat_template(
chat_template: Optional[str] = None,
chat_template_kwargs: Optional[dict[str, Any]] = None,
enable_tokenize: bool = False,
injected_chat_template_kwargs: Optional[Collection[str]] = None,
) -> (str | List[str]):
"""Apply chat template without blocking the event loop."""
return await asyncio.to_thread(
Expand All @@ -844,6 +860,7 @@ async def async_apply_chat_template(
chat_template=chat_template,
chat_template_kwargs=chat_template_kwargs,
enable_tokenize=enable_tokenize,
injected_chat_template_kwargs=injected_chat_template_kwargs,
)


Expand Down
13 changes: 10 additions & 3 deletions tensorrt_llm/serve/anthropic_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -398,11 +398,14 @@ def _convert_tool_choice(


# Chat-template kwargs that turn off pruning of earlier-turn reasoning. The
# name differs per model family but the meaning is identical, and a template
# that does not know a key simply ignores it, so both are always sent
# together:
# name differs per model family but the meaning is identical, and the adapter
# cannot know which one the template reads, so both are always sent together:
# * `clear_thinking` - GLM-family Jinja templates
# * `drop_thinking` - DeepSeek-V4 (`DeepseekV4Tokenizer.apply_chat_template`)
# The unused-kwargs guard would reject the one the template does not read, so
# `convert_anthropic_request` lists every adapter-derived key in
# `injected_chat_template_kwargs`; the guard exempts those and the template
# ignores the key it does not know, as before.
_KEEP_ALL_THINKING_KWARGS = {"clear_thinking": False, "drop_thinking": False}


Expand Down Expand Up @@ -516,6 +519,10 @@ def convert_anthropic_request(request: AnthropicMessagesRequest) -> ChatCompleti

if chat_template_kwargs:
chat_request["chat_template_kwargs"] = chat_template_kwargs
# The Anthropic API has no chat_template_kwargs of its own: every key
# here was derived from an API-level field, so none of them is a
# caller mistake for the unused-kwargs guard to reject.
chat_request["injected_chat_template_kwargs"] = sorted(chat_template_kwargs)
if request.stream:
chat_request["stream_options"] = {
"include_usage": True,
Expand Down
33 changes: 31 additions & 2 deletions tensorrt_llm/serve/chat_tokenization.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,15 +92,44 @@ def tokenize_harmony_chat_request(
def render_chat_request_for_tokenizer(
request: ChatCompletionRequest, tokenizer: object
) -> str | list[int]:
from tensorrt_llm.inputs.chat_template_guard import validate_chat_template_kwargs
from tensorrt_llm.inputs.utils import resolve_hf_chat_template
from tensorrt_llm.serve.chat_utils import _parse_assistant_message_content

chat_template_kwargs = (
dict(request.chat_template_kwargs) if getattr(request, "chat_template_kwargs", None) else {}
)
chat_template_kwargs["tools"] = get_chat_completion_tool_dicts(request)
tools = get_chat_completion_tool_dicts(request)
template_selection = request.chat_template
if template_selection is None:
template_selection = chat_template_kwargs.get("chat_template")
template = resolve_hf_chat_template(tokenizer, None, template_selection, tools)
validate_chat_template_kwargs(
template,
chat_template_kwargs,
injected_keys=getattr(request, "injected_chat_template_kwargs", None),
)
messages = [dict(msg) for msg in request.messages]
if isinstance(template, str):
# Match the server's assistant metadata normalization without loading
# media or changing the content representation expected by tokenizers.
for message in messages:
if message["role"] == "assistant":
message.update(_parse_assistant_message_content(message))
else:
# Native renderers receive the request unnormalized, but strict-parse
# requests deliver `tool_calls` as a single-use pydantic
# `ValidatorIterator`; materialize it so tokenizers can index it.
for message in messages:
tool_calls = message.get("tool_calls")
if tool_calls is not None and not isinstance(tool_calls, list):
message["tool_calls"] = list(tool_calls)
chat_template_kwargs["tools"] = tools
chat_template_kwargs["documents"] = request.documents
if request.chat_template is not None:
chat_template_kwargs["chat_template"] = request.chat_template
rendered = tokenizer.apply_chat_template(
[msg if isinstance(msg, dict) else dict(msg) for msg in request.messages],
messages,
add_generation_prompt=request.add_generation_prompt,
tokenize=False,
return_dict=False,
Expand Down
9 changes: 9 additions & 0 deletions tensorrt_llm/serve/openai_disagg_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@

from tensorrt_llm.executor import CppExecutorError
from tensorrt_llm.executor.executor import CppExecutorError
from tensorrt_llm.inputs.chat_template_guard import \
UnusedChatTemplateKwargsError
from tensorrt_llm.llmapi import tracing
from tensorrt_llm.llmapi.disagg_utils import (DisaggServerConfig,
MetadataServerConfig, ServerRole)
Expand Down Expand Up @@ -536,6 +538,13 @@ def _handle_exception(self, exception):
self._perf_metrics_collector.http_exceptions.inc()
logger.error(f"HTTPException {exception.status_code} {exception.detail}: ", traceback.format_exc())
raise exception
elif isinstance(exception, UnusedChatTemplateKwargsError):
# Raised while this server tokenizes a chat request for routing.
# It is a client mistake (a chat_template_kwargs key the template
# never reads), not a server fault, so it must not fall through to
# the generic 500 below.
self._perf_metrics_collector.http_exceptions.inc()
raise HTTPException(status_code=400, detail=str(exception)) from exception
else:
self._perf_metrics_collector.internal_errors.inc()
logger.error("Internal server error: ", traceback.format_exc())
Expand Down
11 changes: 11 additions & 0 deletions tensorrt_llm/serve/openai_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -1119,6 +1119,17 @@ class ChatCompletionRequest(OpenAIBaseModel):
description=("Additional kwargs to pass to the template renderer. "
"Will be accessible by the chat template."),
)
injected_chat_template_kwargs: Optional[List[str]] = Field(
default=None,
description=(
"Server-populated. Keys of `chat_template_kwargs` that the server "
"derived from API-level fields (for example the Anthropic "
"`thinking` and `context_management` controls) rather than the "
"caller. The unused-kwargs guard exempts them, since the caller "
"cannot remove a control the server added. Relayed with the "
"request so a disaggregated worker rendering the prompt applies "
"the same exemption."),
)

media_io_kwargs: Optional[Dict[MediaModality, Dict[str, Any]]] = Field(
default=None,
Expand Down
4 changes: 4 additions & 0 deletions tensorrt_llm/serve/openai_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -2285,6 +2285,8 @@ async def chat_stream_generator(
documents=request.documents,
chat_template=request.chat_template or self.chat_template,
chat_template_kwargs=request.chat_template_kwargs or {},
injected_chat_template_kwargs=request.
injected_chat_template_kwargs,
)
prompt, (mm_data, mm_embeddings) = await asyncio.gather(
prompt_task, mm_coroutines)
Expand Down Expand Up @@ -2807,6 +2809,8 @@ async def create_mm_embedding_response(promise: RequestOutput):
documents=request.documents,
chat_template=request.chat_template,
chat_template_kwargs=request.chat_template_kwargs or {},
injected_chat_template_kwargs=request.
injected_chat_template_kwargs,
)
prompt, (mm_data, mm_embeddings) = await asyncio.gather(
prompt_task, mm_coroutines)
Expand Down
Loading
Loading