# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import asyncio
import copy
import json
import pickle
from dataclasses import fields
from typing import Any
import pytest
import torch
from tensorrt_llm.llmapi.llm import BaseLLM
from tensorrt_llm.llmapi.llm_args import TorchLlmArgs
from tensorrt_llm.llmapi.thinking_budget import (
ThinkingBudgetLogitsProcessor,
add_thinking_budget_logits_processor,
)
from tensorrt_llm.sampling_params import MAX_TOP_LOGPROBS, SamplingParams, check_logprobs_limit
from tensorrt_llm.serve.openai_protocol import (
ChatCompletionRequest,
CompletionRequest,
KVCacheTruncateRequest,
ResponsesRequest,
ResponsesResponse,
ensure_request_chat_template_allowed,
)
from tensorrt_llm.serve.resource_governor import ResourceGovernor
pytestmark = pytest.mark.cpu_only
# BaseLLM.__init__ builds a model and executor. This lightweight subclass keeps
# these method-level tests CPU-only while using the production argument types.
class _TestLLM(BaseLLM):
def __init__(self, **args_overrides: Any) -> None:
self.args = TorchLlmArgs(
model="dummy",
skip_tokenizer_init=True,
**args_overrides,
)
def _apply_generation_config_sampling_defaults(
mode: str,
sampling_params: SamplingParams,
generation_config_explicit_values: dict,
) -> SamplingParams:
llm = _TestLLM(generation_config=mode)
llm._generation_config_explicit_values = generation_config_explicit_values
llm._apply_generation_config_sampling_defaults(sampling_params)
return sampling_params
def test_generation_config_mode_controls_sampling_defaults():
values = {"temperature": 0.7}
trtllm_params = _apply_generation_config_sampling_defaults(
"trtllm", SamplingParams(end_id=1), values
)
auto_params = _apply_generation_config_sampling_defaults(
"auto", SamplingParams(end_id=1), values
)
assert trtllm_params.temperature is None
assert auto_params.temperature == 0.7
def test_generation_config_sampling_precedence():
params = SamplingParams(end_id=1, temperature=0.2)
prepared = _apply_generation_config_sampling_defaults(
"auto",
params,
{
"temperature": 0.7,
"top_p": 0.9,
},
)
# Check each precedence level:
# 1. Request-provided values
# 2. Generation config values
# 3. TRT-LLM defaults
assert prepared.temperature == 0.2
assert prepared.top_p == 0.9
assert prepared.top_k is None
def test_generation_config_applies_all_supported_sampling_fields():
# Use typical_p because it is not supported by the generation config.
values = {
"early_stopping": True,
"length_penalty": 0.8,
"min_p": 0.05,
"no_repeat_ngram_size": 3,
"repetition_penalty": 1.1,
"temperature": 0.7,
"top_k": 20,
"top_p": 0.9,
"typical_p": 0.95,
}
prepared = _apply_generation_config_sampling_defaults("auto", SamplingParams(end_id=1), values)
for field_name, expected in values.items():
if field_name != "typical_p":
assert getattr(prepared, field_name) == expected
unsupported_values = _apply_generation_config_sampling_defaults(
"auto",
SamplingParams(end_id=1),
{
"top_p": None,
"early_stopping": "never",
},
)
assert unsupported_values.top_p is None
assert unsupported_values.early_stopping is None
@pytest.mark.parametrize(
"request_obj",
[
CompletionRequest(model="test", prompt="hi"),
ChatCompletionRequest(model="test", messages=[{"role": "user", "content": "hi"}]),
ResponsesRequest(model="test", input="hi"),
],
)
def test_generation_config_overrides_omitted_serve_defaults(request_obj):
params = request_obj.to_sampling_params()
assert params.temperature == 1.0
prepared = _apply_generation_config_sampling_defaults(
"auto", params, {"temperature": 0.7, "top_p": 0.9}
)
assert prepared.temperature == 0.7
assert prepared.top_p == 0.9
def test_responses_request_asks_for_no_logprobs_unless_told_to():
"""`SamplingParams.logprobs=0` is a request, not an absence.
`_get_output_config` sets `return_log_probs = self.logprobs is not None`,
so 0 turns log probabilities on for every request, and the speculative
sampler rejects a request that asks for them. Only None means none.
"""
def logprobs_for(**kwargs):
request = ResponsesRequest(model="test", input="hi", **kwargs)
return request.to_sampling_params().logprobs
assert logprobs_for() is None
# The before-validator drops explicit nulls, so this takes the default too.
assert logprobs_for(top_logprobs=None) is None
assert logprobs_for(top_logprobs=0) == 0
assert logprobs_for(top_logprobs=3) == 3
def test_responses_response_echoes_an_absent_top_logprobs():
"""The response carries back what the sampling params hold, None included."""
def echoed(**kwargs):
request = ResponsesRequest(model="test", input="hi", **kwargs)
sampling_params = request.to_sampling_params()
response = ResponsesResponse.from_request(request, sampling_params,
"test", 0, [], "completed")
return response.top_logprobs
assert echoed() is None
assert echoed(top_logprobs=0) == 0
assert echoed(top_logprobs=3) == 3
@pytest.mark.parametrize(
"request_obj",
[
CompletionRequest(model="test", prompt="hi", temperature=0.2),
ChatCompletionRequest(
model="test",
messages=[{"role": "user", "content": "hi"}],
temperature=0.2,
),
ResponsesRequest(model="test", input="hi", temperature=0.2),
],
)
def test_explicit_serve_request_overrides_generation_config(request_obj):
prepared = _apply_generation_config_sampling_defaults(
"auto",
request_obj.to_sampling_params(),
{"temperature": 0.7},
)
assert prepared.temperature == 0.2
def test_absent_generation_config_value_preserves_existing_defaults():
direct_params = _apply_generation_config_sampling_defaults("auto", SamplingParams(end_id=1), {})
serve_params = _apply_generation_config_sampling_defaults(
"auto",
CompletionRequest(model="test", prompt="hi").to_sampling_params(),
{},
)
assert direct_params.temperature is None
assert serve_params.temperature == 1.0
@pytest.mark.parametrize("field", ["logprobs", "prompt_logprobs", "top_logprobs"])
def test_check_logprobs_limit(field):
check_logprobs_limit(field, None)
check_logprobs_limit(field, 0)
check_logprobs_limit(field, MAX_TOP_LOGPROBS)
with pytest.raises(ValueError, match=f"{field} must be positive"):
check_logprobs_limit(field, -1)
with pytest.raises(ValueError, match=f"less than or equal to {MAX_TOP_LOGPROBS}"):
check_logprobs_limit(field, MAX_TOP_LOGPROBS + 1)
@pytest.mark.parametrize("field", ["logprobs", "prompt_logprobs"])
def test_logprobs_request_limit(field):
SamplingParams(**{field: MAX_TOP_LOGPROBS})
with pytest.raises(ValueError, match=f"less than or equal to {MAX_TOP_LOGPROBS}"):
SamplingParams(**{field: MAX_TOP_LOGPROBS + 1})
def test_chat_top_logprobs_request_limit():
with pytest.raises(ValueError, match=f"less than or equal to {MAX_TOP_LOGPROBS}"):
ChatCompletionRequest(
model="test",
messages=[{"role": "user", "content": "hi"}],
logprobs=True,
top_logprobs=MAX_TOP_LOGPROBS + 1,
)
def test_chat_template_request_override_respects_runtime_policy():
request = ChatCompletionRequest(
model="test",
messages=[{"role": "user", "content": "hi"}],
chat_template="{{ messages }}",
)
with pytest.raises(ValueError, match="chat_template cannot be supplied"):
ensure_request_chat_template_allowed(request, allow_request_chat_template=False)
ensure_request_chat_template_allowed(request, allow_request_chat_template=True)
def test_kv_cache_truncate_chat_template_respects_runtime_policy():
request = KVCacheTruncateRequest(
model="test",
messages=[{"role": "user", "content": "hi"}],
chat_template="{{ messages }}",
)
with pytest.raises(ValueError, match="chat_template cannot be supplied"):
ensure_request_chat_template_allowed(request, allow_request_chat_template=False)
ensure_request_chat_template_allowed(request, allow_request_chat_template=True)
def test_resource_governor_rejects_request_chat_template_by_default():
governor = ResourceGovernor(
resource_governor_queue=None,
tokenizer=None,
model_config=None,
)
request = KVCacheTruncateRequest(
model="test",
messages=[{"role": "user", "content": "hi"}],
chat_template="{{ messages }}",
)
response = asyncio.run(governor._truncate_kv_cache(request))
assert response.status_code == 400
assert json.loads(response.body)["error"]
def test_completion_logprobs_assignment_revalidates():
request = CompletionRequest(model="test", prompt="hi", logprobs=MAX_TOP_LOGPROBS)
request.logprobs = MAX_TOP_LOGPROBS + 1
with pytest.raises(ValueError, match=f"less than or equal to {MAX_TOP_LOGPROBS}"):
request.to_sampling_params(backend="pytorch")
@pytest.mark.parametrize("field", ["top_p", "min_p", "temperature"])
def test_sampling_params_rejects_nan(field):
with pytest.raises(ValueError, match=field):
SamplingParams(**{field: float("nan")})
@pytest.mark.parametrize(
("field", "value"),
[
("top_p", -0.1),
("top_p", 1.1),
("min_p", -0.1),
("min_p", 1.1),
("temperature", -1.0),
],
)
def test_sampling_params_rejects_out_of_range(field, value):
with pytest.raises(ValueError, match=field):
SamplingParams(**{field: value})
@pytest.mark.parametrize(
("field", "value"),
[
("top_p", 0.0),
("top_p", 0.9),
("top_p", 1.0),
("min_p", 0.0),
("min_p", 0.5),
("min_p", 1.0),
("temperature", 0.0),
("temperature", 1.0),
],
)
def test_sampling_params_accepts_in_range_values(field, value):
assert getattr(SamplingParams(**{field: value}), field) == value
@pytest.mark.parametrize(
"round_trip",
[lambda params: pickle.loads(pickle.dumps(params)), copy.copy, copy.deepcopy],
)
def test_sampling_params_copy_round_trip_keeps_every_field(round_trip):
# A distinct value in every field, so a field the pickling misses fails.
params = SamplingParams()
for f in fields(SamplingParams):
object.__setattr__(params, f.name, f"value of {f.name}")
restored = round_trip(params)
assert type(restored) is SamplingParams
for f in fields(SamplingParams):
assert getattr(restored, f.name) == getattr(params, f.name), f.name
@pytest.mark.parametrize("value", [None, -1])
def test_thinking_token_budget_unlimited_values(value):
assert SamplingParams(thinking_token_budget=value).thinking_token_budget is None
@pytest.mark.parametrize("value", [-2, True, 1.5, "1"])
def test_thinking_token_budget_rejects_invalid_values(value):
with pytest.raises(ValueError, match="thinking_token_budget"):
SamplingParams(thinking_token_budget=value)
def test_chat_thinking_token_budget_request_validation():
request = ChatCompletionRequest(
model="test",
messages=[{"role": "user", "content": "hi"}],
thinking_token_budget=8,
)
assert request.to_sampling_params().thinking_token_budget == 8
with pytest.raises(ValueError, match="thinking_token_budget"):
ChatCompletionRequest(
model="test",
messages=[{"role": "user", "content": "hi"}],
thinking_token_budget=True,
)
def test_thinking_budget_logits_processor_forces_end_token():
processor = ThinkingBudgetLogitsProcessor(
thinking_token_budget=2,
reasoning_start_token_ids=[1],
reasoning_end_token_ids=[2, 3],
)
logits = torch.zeros(1, 1, 8)
processor(0, logits, [[1, 5, 6]], None, None)
assert logits[0, 0, 2] == 0
mask = torch.ones(8, dtype=torch.bool)
mask[2] = False
assert torch.isneginf(logits[0, 0, mask]).all()
def test_thinking_budget_logits_processor_completes_partial_end_sequence():
processor = ThinkingBudgetLogitsProcessor(
thinking_token_budget=2,
reasoning_start_token_ids=[1],
reasoning_end_token_ids=[2, 3],
)
logits = torch.zeros(1, 1, 8)
processor(0, logits, [[1, 5, 6, 2]], None, None)
assert logits[0, 0, 3] == 0
mask = torch.ones(8, dtype=torch.bool)
mask[3] = False
assert torch.isneginf(logits[0, 0, mask]).all()
def test_thinking_budget_logits_processor_ignores_partial_end_before_budget():
processor = ThinkingBudgetLogitsProcessor(
thinking_token_budget=4,
reasoning_start_token_ids=[1],
reasoning_end_token_ids=[2, 3],
)
logits = torch.zeros(1, 1, 8)
processor(0, logits, [[1, 5, 6, 2]], None, None)
assert torch.equal(logits, torch.zeros(1, 1, 8))
def test_thinking_budget_logits_processor_ignores_closed_reasoning_block():
processor = ThinkingBudgetLogitsProcessor(
thinking_token_budget=1,
reasoning_start_token_ids=[1],
reasoning_end_token_ids=[2],
)
logits = torch.zeros(1, 1, 8)
processor(0, logits, [[1, 5, 2, 6]], None, None)
assert torch.equal(logits, torch.zeros(1, 1, 8))
def _run(processor, token_ids):
"""Call the processor on a fresh logits row and return it."""
logits = torch.zeros(1, 1, 8)
processor(0, logits, token_ids, None, None)
return logits
def test_thinking_budget_logits_processor_does_not_force_end_twice_on_stale_view():
"""Regression: a stale token view must not re-force the end tag.
With the overlap scheduler, logits processors run one step
behind the sampled tokens, so the call right after forcing the end token
sees token_ids WITHOUT it. A stateless processor forces the end token a
second time, producing e.g. `` — the reasoning parser
consumes the first tag and the second leaks into message.content.
"""
processor = ThinkingBudgetLogitsProcessor(
thinking_token_budget=2,
reasoning_start_token_ids=[1],
reasoning_end_token_ids=[2],
)
stale = [[1, 5, 6]] # budget spent, forced end not visible yet
logits = _run(processor, stale)
assert logits[0, 0, 2] == 0 and torch.isneginf(logits[0, 0, 3])
# Next step: the forced end token is still not visible (stale view).
assert torch.equal(_run(processor, stale), torch.zeros(1, 1, 8))
# Once the view catches up (end tag present), progress state resets so a
# later reasoning block is budgeted again.
assert torch.equal(_run(processor, [[1, 5, 6, 2, 7]]), torch.zeros(1, 1, 8))
logits = _run(processor, [[1, 5, 6, 2, 7, 1, 8, 9]])
assert logits[0, 0, 2] == 0 and torch.isneginf(logits[0, 0, 3])
def test_thinking_budget_logits_processor_continues_end_sequence_on_stale_view():
"""Multi-token end sequence must continue, not restart, on a stale view.
Under the overlap scheduler the processor must continue from its
recorded progress rather than re-derive from token_ids.
"""
processor = ThinkingBudgetLogitsProcessor(
thinking_token_budget=2,
reasoning_start_token_ids=[1],
reasoning_end_token_ids=[2, 3],
)
stale = [[1, 5, 6]]
assert _run(processor, stale)[0, 0, 2] == 0
# Stale view: forced first end token not visible; must force the SECOND
# end token, not the first again.
logits = _run(processor, stale)
assert logits[0, 0, 3] == 0 and torch.isneginf(logits[0, 0, 2])
# Sequence complete: no further forcing even while the view is stale.
assert torch.equal(_run(processor, stale), torch.zeros(1, 1, 8))
def test_add_thinking_budget_logits_processor_uses_reasoning_parser_tokens():
class FakeTokenizer:
def encode(self, text, add_special_tokens=False):
del add_special_tokens
return {
"": [1],
"": [2],
}[text]
sampling_params = SamplingParams(thinking_token_budget=4)
add_thinking_budget_logits_processor(
sampling_params,
reasoning_parser="qwen3",
tokenizer=FakeTokenizer(),
)
assert isinstance(sampling_params.logits_processor, ThinkingBudgetLogitsProcessor)
@pytest.mark.parametrize("thinking_token_budget", [0, 2])
def test_thinking_budget_text_generation_caps_reasoning_tokens(thinking_token_budget):
class FakeTokenizer:
token_to_id = {
"Question:": 0,
"": 1,
"": 2,
"alpha": 3,
"beta": 4,
"gamma": 5,
"delta": 6,
"answer": 7,
}
id_to_token = {token_id: token for token, token_id in token_to_id.items()}
def encode(self, text, add_special_tokens=False):
del add_special_tokens
return [self.token_to_id[token] for token in text.split()]
def decode(self, token_ids):
return " ".join(self.id_to_token[token_id] for token_id in token_ids)
tokenizer = FakeTokenizer()
sampling_params = SamplingParams(thinking_token_budget=thinking_token_budget)
add_thinking_budget_logits_processor(
sampling_params,
reasoning_parser="qwen3",
tokenizer=tokenizer,
)
prompt_ids = tokenizer.encode("Question:")
generated_ids = []
reasoning_plan = tokenizer.encode("alpha beta gamma delta")
start_id = tokenizer.encode("")[0]
end_id = tokenizer.encode("")[0]
answer_id = tokenizer.encode("answer")[0]
def next_model_token():
if not generated_ids:
return start_id
if end_id in generated_ids:
return answer_id
reasoning_tokens = generated_ids[generated_ids.index(start_id) + 1 :]
if len(reasoning_tokens) < len(reasoning_plan):
return reasoning_plan[len(reasoning_tokens)]
return end_id
for _ in range(8):
logits = torch.full((1, 1, len(tokenizer.token_to_id)), -100.0)
logits[0, 0, next_model_token()] = 100.0
sampling_params.logits_processor(0, logits, [prompt_ids + generated_ids], None, None)
generated_ids.append(int(torch.argmax(logits[0, 0]).item()))
if generated_ids[-1] == answer_id:
break
start_idx = generated_ids.index(start_id)
end_idx = generated_ids.index(end_id)
reasoning_ids = generated_ids[start_idx + 1 : end_idx]
expected_reasoning_ids = reasoning_plan[:thinking_token_budget]
assert tokenizer.decode(prompt_ids) == "Question:"
assert reasoning_ids == expected_reasoning_ids
assert len(reasoning_ids) <= thinking_token_budget
assert generated_ids[end_idx + 1 :] == [answer_id]
assert tokenizer.decode(generated_ids) == tokenizer.decode(
[start_id, *expected_reasoning_ids, end_id, answer_id]
)