diff --git a/.github/workflows/config/.secrets.baseline b/.github/workflows/config/.secrets.baseline
index 038d9d28ce4..5d021298172 100644
--- a/.github/workflows/config/.secrets.baseline
+++ b/.github/workflows/config/.secrets.baseline
@@ -197,6 +197,24 @@
"line_number": 11
}
],
+ "tests/unit/models/generation/chat_template_parity_common.py": [
+ {
+ "type": "Hex High Entropy String",
+ "filename": "tests/unit/models/generation/chat_template_parity_common.py",
+ "hashed_secret": "f667290c92e8cd663651c7784a77467a4fec327a",
+ "is_verified": false,
+ "line_number": 21
+ }
+ ],
+ "tests/unit/models/generation/trtllm/fixtures/chat_template_parity_golden.json": [
+ {
+ "type": "Hex High Entropy String",
+ "filename": "tests/unit/models/generation/trtllm/fixtures/chat_template_parity_golden.json",
+ "hashed_secret": "f667290c92e8cd663651c7784a77467a4fec327a",
+ "is_verified": false,
+ "line_number": 3
+ }
+ ],
"tests/unit/test_version_check.py": [
{
"type": "Hex High Entropy String",
diff --git a/examples/configs/recipes/llm/grpo-qwen3-0.6b-1n4g-megatron-trtllm-tp2-noncolocated-gym.yaml b/examples/configs/recipes/llm/grpo-qwen3-0.6b-1n4g-megatron-trtllm-tp2-noncolocated-gym.yaml
new file mode 100644
index 00000000000..c94165c3fa1
--- /dev/null
+++ b/examples/configs/recipes/llm/grpo-qwen3-0.6b-1n4g-megatron-trtllm-tp2-noncolocated-gym.yaml
@@ -0,0 +1,81 @@
+defaults: ../../../nemo_gym/grpo_workplace_assistant_nemotron_nano_v2_9b.yaml
+grpo:
+ num_prompts_per_step: 4
+ num_generations_per_prompt: 2
+ adv_estimator:
+ name: reinforce_plus_plus
+ minus_baseline: false
+ async_grpo:
+ in_flight_weight_updates: true
+ val_period: 1000
+ val_at_start: false
+checkpointing:
+ checkpoint_dir: results/grpo-qwen3-0.6b-1n4g-megatron-trtllm-tp2-noncolocated-gym
+policy:
+ model_name: Qwen/Qwen3-0.6B
+ tokenizer:
+ name: Qwen/Qwen3-0.6B
+ chat_template_kwargs:
+ enable_thinking: false
+ train_global_batch_size: 8
+ max_total_sequence_length: 2048
+ make_sequence_length_divisible_by: 2
+ megatron_cfg:
+ sequence_parallel: true
+ activation_checkpointing: false
+ apply_rope_fusion: false
+ defer_fp32_logits: true
+ moe_per_layer_logging: false
+ optimizer:
+ name: torch.optim.AdamW
+ kwargs:
+ lr: 5.0e-06
+ weight_decay: 0.01
+ betas:
+ - 0.9
+ - 0.999
+ eps: 1.0e-08
+ generation:
+ backend: trtllm
+ max_new_tokens: 512
+ stop_token_ids:
+ - 151643
+ - 151645
+ trtllm_cfg:
+ tensor_parallel_size: 2
+ moe_tensor_parallel_size: null
+ moe_expert_parallel_size: null
+ max_model_len: ${policy.max_total_sequence_length}
+ precision: ${policy.precision}
+ async_engine: true
+ gpu_memory_utilization: 0.6
+ max_batch_size: 64
+ max_num_tokens: 4096
+ in_flight_weight_updates: ${grpo.async_grpo.in_flight_weight_updates}
+ recompute_kv_cache_after_weight_updates: ${grpo.async_grpo.recompute_kv_cache_after_weight_updates}
+ expose_http_server: true
+ default_chat_template_kwargs:
+ enable_thinking: false
+ tool_parser: qwen3_coder
+ reasoning_parser: qwen3
+ trtllm_kwargs:
+ batch_wait_timeout_iters: 32
+ batch_wait_max_tokens_ratio: 0.5
+ colocated:
+ enabled: false
+ resources:
+ gpus_per_node: 2
+ num_nodes: 1
+env:
+ nemo_gym:
+ policy_model:
+ responses_api_models:
+ vllm_model:
+ uses_reasoning_parser: true
+logger:
+ tensorboard_enabled: true
+ wandb:
+ project: nemo-rl
+ name: grpo-qwen3-0.6b-1n4g-megatron-trtllm-tp2-noncolocated-gym
+cluster:
+ gpus_per_node: 4
diff --git a/nemo_rl/models/generation/openai_server_utils.py b/nemo_rl/models/generation/openai_server_utils.py
index de5b2b204c3..6caaf01c5d3 100644
--- a/nemo_rl/models/generation/openai_server_utils.py
+++ b/nemo_rl/models/generation/openai_server_utils.py
@@ -22,6 +22,7 @@
so it has no retokenization drift to correct.
"""
+from collections.abc import Collection
from typing import Any
@@ -30,6 +31,7 @@ def replace_prefix_tokens(
model_prefix_token_ids: list[int],
template_prefix_token_ids: list[int],
template_token_ids: list[int],
+ model_stop_token_ids: Collection[int] | None = None,
) -> list[int]:
"""This is a subroutine used inside the OpenAI-compatible Chat Completion server.
@@ -95,11 +97,9 @@ def replace_prefix_tokens(
eos_token_id = tokenizer.eos_token_id
assert eos_token_id is not None, "Tokenizer must have an EOS token ID"
- # The model isn't guaranteed to end on EOS (e.g. it hit max_tokens); chat
- # templates always add one, so cut the model input to just before its EOS.
- model_cut_end = len(model_prefix_token_ids)
- if model_prefix_token_ids[-1] == eos_token_id:
- model_cut_end -= 1
+ effective_stop_token_ids = set(model_stop_token_ids or ())
+ effective_stop_token_ids.add(eos_token_id)
+ model_ended_with_stop = model_prefix_token_ids[-1] in effective_stop_token_ids
# Locate the turn boundary by EOS count rather than token position. Qwen3
# templates may strip prior reasoning blocks when re-rendering history;
@@ -112,7 +112,8 @@ def replace_prefix_tokens(
if tid == eos_token_id:
count_seen += 1
if count_seen == count_needed:
- template_cut_start = pos
+ # Keep sampled stops; otherwise let the template add the missing EOS.
+ template_cut_start = pos + int(model_ended_with_stop)
break
assert template_cut_start >= 0, (
@@ -124,6 +125,4 @@ def replace_prefix_tokens(
f"Template repr (detokenized): {repr(tokenizer.decode(template_token_ids))}"
)
- return (
- model_prefix_token_ids[:model_cut_end] + template_token_ids[template_cut_start:]
- )
+ return model_prefix_token_ids + template_token_ids[template_cut_start:]
diff --git a/nemo_rl/models/generation/trtllm/trtllm_http_server.py b/nemo_rl/models/generation/trtllm/trtllm_http_server.py
index bf40dd78143..eae0a6eb053 100644
--- a/nemo_rl/models/generation/trtllm/trtllm_http_server.py
+++ b/nemo_rl/models/generation/trtllm/trtllm_http_server.py
@@ -33,18 +33,31 @@
logger = logging.getLogger(__name__)
-def _build_reasoning_parser(name: str, chat_template_kwargs: dict[str, Any]) -> Any:
- from tensorrt_llm.llmapi.reasoning_parser import ReasoningParserFactory
+def _tokens_for_response_text(
+ token_ids: list[int], stop_token_ids: set[int]
+) -> list[int]:
+ text_token_end = len(token_ids)
+ while text_token_end and token_ids[text_token_end - 1] in stop_token_ids:
+ text_token_end -= 1
+ return token_ids if text_token_end == len(token_ids) else token_ids[:text_token_end]
- if name == "deepseek-r1" and "enable_thinking" in chat_template_kwargs:
- from tensorrt_llm.llmapi.reasoning_parser import DeepSeekR1Parser
- return DeepSeekR1Parser(
- reasoning_at_start=bool(chat_template_kwargs["enable_thinking"]),
- chat_template_kwargs=chat_template_kwargs,
- )
+def _build_reasoning_parser(
+ name: str,
+ chat_template_kwargs: dict[str, Any],
+ *,
+ reasoning_at_start: bool = False,
+) -> Any:
+ from tensorrt_llm.llmapi.reasoning_parser import (
+ DeepSeekR1Parser,
+ ReasoningParserFactory,
+ )
- return ReasoningParserFactory.create_reasoning_parser(name, chat_template_kwargs)
+ parser = ReasoningParserFactory.create_reasoning_parser(name, chat_template_kwargs)
+ if isinstance(parser, DeepSeekR1Parser):
+ parser.reasoning_at_start = reasoning_at_start
+ parser.in_reasoning = reasoning_at_start
+ return parser
def _build_sampling_params(
@@ -107,6 +120,10 @@ def create_app(
**(default_chat_template_kwargs or {}),
}
+ def _prompt_opens_reasoning(token_ids: list[int]) -> bool:
+ tail = tokenizer.decode(token_ids[-16:], skip_special_tokens=False)
+ return tail.rstrip().endswith("")
+
# Use the configured parser or infer one from the model config.
_tool_parser_name = _resolve_tool_parser_name(tool_parser, model_name)
_tool_parser_instance = _build_tool_parser(_tool_parser_name)
@@ -123,8 +140,7 @@ def create_app(
if reasoning_parser is not None:
_build_reasoning_parser(reasoning_parser, _server_template_kwargs)
- # Match TRT-LLM's effective EOS set so this adapter can trim every returned
- # stop token and its logprob together, preserving multi-turn continuity.
+ # Retain stop IDs in metadata but exclude them from parsed text.
_eos_token_ids: set[int] = set(stop_token_ids or [])
def _add_eos_token_ids(token_ids: Any) -> None:
@@ -170,14 +186,8 @@ async def chat_completions(request: Request):
per_request_kwargs: dict[str, Any] = body.get("chat_template_kwargs") or {}
effective_template_kwargs = {**_server_template_kwargs, **per_request_kwargs}
- _active_reasoning_parser = (
- _build_reasoning_parser(reasoning_parser, effective_template_kwargs)
- if reasoning_parser is not None
- else None
- )
-
try:
- conversation, mm_coroutine, _ = parse_chat_messages_coroutines(
+ conversation, mm_coroutine, *_ = parse_chat_messages_coroutines(
messages, model_config
)
mm_data, mm_embeddings = await mm_coroutine
@@ -216,6 +226,21 @@ async def chat_completions(request: Request):
model_prefix_token_ids=required_prefix_ids,
template_prefix_token_ids=template_prefix_ids,
template_token_ids=prompt_token_ids,
+ model_stop_token_ids=_eos_token_ids,
+ )
+
+ # Infer parser state from the exact engine prompt.
+ reasoning_at_start = reasoning_parser is not None and _prompt_opens_reasoning(
+ adj_prompt
+ )
+ _active_reasoning_parser = (
+ _build_reasoning_parser(
+ reasoning_parser,
+ effective_template_kwargs,
+ reasoning_at_start=reasoning_at_start,
+ )
+ if reasoning_parser is not None
+ else None
)
max_tokens_requested = (
@@ -272,14 +297,9 @@ async def chat_completions(request: Request):
else:
raise TypeError(f"Unsupported TRT-LLM logprob type: {type(lp)}")
- # Strip trailing stop tokens TRT-LLM appends — apply_chat_template doesn't reproduce
- # <|endoftext|>, so they'd break seen_token_ids contiguity. Trim logprobs in lockstep.
- while gen_token_ids and gen_token_ids[-1] in _eos_token_ids:
- gen_token_ids.pop()
- if gen_logprobs:
- gen_logprobs.pop()
-
- gen_text = tokenizer.decode(gen_token_ids, skip_special_tokens=False)
+ # Parse without trailing stops; retain full response metadata.
+ text_token_ids = _tokens_for_response_text(gen_token_ids, _eos_token_ids)
+ gen_text = tokenizer.decode(text_token_ids, skip_special_tokens=False)
finish_reason = "stop"
if gen.finish_reason is not None:
diff --git a/nemo_rl/models/generation/vllm/vllm_worker_async.py b/nemo_rl/models/generation/vllm/vllm_worker_async.py
index 01076d19481..3c9e6b0a631 100644
--- a/nemo_rl/models/generation/vllm/vllm_worker_async.py
+++ b/nemo_rl/models/generation/vllm/vllm_worker_async.py
@@ -397,6 +397,14 @@ def _setup_vllm_openai_api_server(self, app: FastAPI) -> FastAPI:
engine_client = self.llm
model_config = self.llm_async_engine_args.create_model_config()
+ model_stop_token_ids = set(self.cfg.get("stop_token_ids") or ())
+ generation_eos_token_ids = model_config.try_get_generation_config().get(
+ "eos_token_id"
+ )
+ if isinstance(generation_eos_token_ids, int):
+ model_stop_token_ids.add(generation_eos_token_ids)
+ elif generation_eos_token_ids is not None:
+ model_stop_token_ids.update(generation_eos_token_ids)
base_model_paths = [
BaseModelPath(
name=model_config.served_model_name, model_path=model_config.model
@@ -563,10 +571,10 @@ async def preprocess_chat(
model_prefix_token_ids=request.required_prefix_token_ids,
template_prefix_token_ids=actual_corresponding_token_ids,
template_token_ids=engine_prompt["prompt_token_ids"],
+ model_stop_token_ids=model_stop_token_ids,
)
engine_prompt["prompt_token_ids"] = final_prompt_token_ids
-
# Clamp after prefix replacement since the prompt length may have changed.
if actual_request_max_tokens is not None:
self._clamp_max_tokens(
@@ -914,6 +922,10 @@ def _setup_vllm_server(self) -> "tuple[threading.Thread, str, uvicorn.Server]":
# e.g. last-run middleware.
app = FastAPI()
+ @app.get("/health")
+ async def health() -> dict[str, str]:
+ return {"status": "ok"}
+
app = self._setup_vllm_openai_api_server(app)
if self._sparse_refit_receiver is not None:
self._sparse_refit_receiver.setup_api_server(app)
diff --git a/tests/test_suites/llm/grpo-qwen3-0.6b-1n4g-megatron-trtllm-tp2-noncolocated-gym.sh b/tests/test_suites/llm/grpo-qwen3-0.6b-1n4g-megatron-trtllm-tp2-noncolocated-gym.sh
new file mode 100755
index 00000000000..2097d105256
--- /dev/null
+++ b/tests/test_suites/llm/grpo-qwen3-0.6b-1n4g-megatron-trtllm-tp2-noncolocated-gym.sh
@@ -0,0 +1,73 @@
+#!/bin/bash
+SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd)
+source "$SCRIPT_DIR/common.env"
+
+# ===== BEGIN CONFIG =====
+NUM_NODES=1
+GPUS_PER_NODE=4
+STEPS_PER_RUN=6
+MAX_STEPS=6
+NUM_RUNS=$(( (MAX_STEPS + STEPS_PER_RUN - 1) / STEPS_PER_RUN ))
+NUM_MINUTES=90
+# ===== END CONFIG =====
+
+exit_if_max_steps_reached
+
+cd "$PROJECT_ROOT"
+
+DATA_DIR="$EXP_DIR/data"
+mkdir -p "$DATA_DIR"
+cd 3rdparty/Gym-workspace/Gym
+if [[ ! -f env.yaml ]]; then
+ if [[ -z "${HF_TOKEN:-}" ]]; then
+ echo "[ERROR] HF_TOKEN is not set"
+ exit 1
+ fi
+ echo "hf_token: $HF_TOKEN" >> env.yaml
+fi
+uv run ng_prepare_data "+config_paths=[resources_servers/workplace_assistant/configs/workplace_assistant.yaml]" \
+ +output_dirpath=data/workplace_assistant \
+ +mode=train_preparation \
+ +should_download=true \
+ +data_source=huggingface
+cd -
+
+TRAIN_PATH="$DATA_DIR/workplace_assistant_train.jsonl"
+VALIDATION_PATH="$DATA_DIR/workplace_assistant_validation.jsonl"
+jq -c '.responses_create_params.tools |= (.[0:1])' \
+ 3rdparty/Gym-workspace/Gym/data/workplace_assistant/train.jsonl > "$TRAIN_PATH"
+jq -c '.responses_create_params.tools |= (.[0:1])' \
+ 3rdparty/Gym-workspace/Gym/data/workplace_assistant/validation.jsonl > "$VALIDATION_PATH"
+
+uv run examples/nemo_gym/run_grpo_nemo_gym.py \
+ --config "$CONFIG_PATH" \
+ grpo.max_num_steps="$MAX_STEPS" \
+ logger.log_dir="$LOG_DIR" \
+ logger.wandb_enabled=true \
+ logger.wandb.project=nemo-rl \
+ logger.wandb.name="$EXP_NAME" \
+ logger.monitor_gpus=true \
+ logger.tensorboard_enabled=true \
+ checkpointing.enabled=true \
+ checkpointing.checkpoint_dir="$CKPT_DIR" \
+ data.train.data_path="$TRAIN_PATH" \
+ data.validation.data_path="$VALIDATION_PATH" \
+ "$@" \
+ 2>&1 | tee "$RUN_LOG"
+
+uv run tests/json_dump_tb_logs.py "$LOG_DIR" --output_path "$JSON_METRICS"
+
+LAST_STEP=$(jq 'to_entries | .[] | select(.key == "train/loss") | .value | keys | map(tonumber) | max' "$JSON_METRICS")
+if [[ "$LAST_STEP" -lt "$MAX_STEPS" ]]; then
+ echo "[ERROR] Expected step $MAX_STEPS, reached $LAST_STEP"
+ exit 1
+fi
+
+uv run tests/check_metrics.py "$JSON_METRICS" \
+ 'mean(data["train/reward"]) > 0.05' \
+ 'median(data["train/token_mult_prob_error"]) < 1.1' \
+ 'mean(data["train/gen_kl_error"]) < 0.02' \
+ 'mean(data["train/grad_norm"], 2, 0) > 0.1' \
+ 'mean(data["train/grad_norm"], 2, 0) < 30.0'
+
+rm -rf "$CKPT_DIR"
diff --git a/tests/test_suites/nightly_gb200.txt b/tests/test_suites/nightly_gb200.txt
index 9ab02ba24ce..f22e46e6f00 100644
--- a/tests/test_suites/nightly_gb200.txt
+++ b/tests/test_suites/nightly_gb200.txt
@@ -20,6 +20,7 @@ tests/test_suites/llm/grpo-nanov3-30BA3B-4n4g-megatron_generation-noncolocated-m
# TRT-LLM generation backend
tests/test_suites/llm/grpo-qwen3-1.7b-2n4g-fsdp2-trtllm.sh
tests/test_suites/llm/grpo-qwen2.5-0.5b-1n4g-megatron-trtllm-noncolocated-async.sh
+tests/test_suites/llm/grpo-qwen3-0.6b-1n4g-megatron-trtllm-tp2-noncolocated-gym.sh
# Functional moonlight run
tests/test_suites/llm/grpo-moonlight-16ba3b-4n4g-megatron.sh
diff --git a/tests/unit/L0_Unit_Tests_Vllm_1.sh b/tests/unit/L0_Unit_Tests_Vllm_1.sh
index 033218db562..ea553a92f67 100644
--- a/tests/unit/L0_Unit_Tests_Vllm_1.sh
+++ b/tests/unit/L0_Unit_Tests_Vllm_1.sh
@@ -18,7 +18,7 @@
source "$(dirname "${BASH_SOURCE[0]}")/run_unit_shard_common.sh"
# Base run (tests without extra markers)
-uv run --no-sync bash -x ./tests/run_unit.sh "unit/models/generation/test_vllm*.py" "unit/models/generation/test_dynamo*.py" "unit/models/generation/test_swe1_dynamo_config.py" "unit/models/generation/test_openai_server_utils.py" "unit/models/generation/test_fleet_health.py" "unit/models/generation/test_generation_router.py" "${EXCLUDED_UNIT_TESTS[@]}" --shard-id=0 --num-shards=3 --cov=nemo_rl --cov-report=term-missing --cov-report=json --hf-gated
+uv run --no-sync bash -x ./tests/run_unit.sh "unit/models/generation/test_vllm*.py" "unit/models/generation/test_dynamo*.py" "unit/models/generation/test_swe1_dynamo_config.py" "unit/models/generation/test_openai_server_utils.py" "unit/models/generation/test_fleet_health.py" "unit/models/generation/test_generation_router.py" "unit/models/generation/test_chat_template_parity_common.py" "${EXCLUDED_UNIT_TESTS[@]}" --shard-id=0 --num-shards=3 --cov=nemo_rl --cov-report=term-missing --cov-report=json --hf-gated
# vllm-only run (catch-all across all unit tests)
uv run --extra vllm bash -x ./tests/run_unit.sh "unit/" "${EXCLUDED_UNIT_TESTS[@]}" --shard-id=0 --num-shards=3 --cov=nemo_rl --cov-append --cov-report=term-missing --cov-report=json --hf-gated --vllm-only
diff --git a/tests/unit/L0_Unit_Tests_Vllm_2.sh b/tests/unit/L0_Unit_Tests_Vllm_2.sh
index 6ad24ea6213..25b63126c5e 100644
--- a/tests/unit/L0_Unit_Tests_Vllm_2.sh
+++ b/tests/unit/L0_Unit_Tests_Vllm_2.sh
@@ -18,7 +18,7 @@
source "$(dirname "${BASH_SOURCE[0]}")/run_unit_shard_common.sh"
# Base run (tests without extra markers)
-uv run --no-sync bash -x ./tests/run_unit.sh "unit/models/generation/test_vllm*.py" "unit/models/generation/test_dynamo*.py" "unit/models/generation/test_swe1_dynamo_config.py" "unit/models/generation/test_openai_server_utils.py" "unit/models/generation/test_fleet_health.py" "unit/models/generation/test_generation_router.py" "${EXCLUDED_UNIT_TESTS[@]}" --shard-id=1 --num-shards=3 --cov=nemo_rl --cov-report=term-missing --cov-report=json --hf-gated
+uv run --no-sync bash -x ./tests/run_unit.sh "unit/models/generation/test_vllm*.py" "unit/models/generation/test_dynamo*.py" "unit/models/generation/test_swe1_dynamo_config.py" "unit/models/generation/test_openai_server_utils.py" "unit/models/generation/test_fleet_health.py" "unit/models/generation/test_generation_router.py" "unit/models/generation/test_chat_template_parity_common.py" "${EXCLUDED_UNIT_TESTS[@]}" --shard-id=1 --num-shards=3 --cov=nemo_rl --cov-report=term-missing --cov-report=json --hf-gated
# vllm-only run (catch-all across all unit tests)
uv run --extra vllm bash -x ./tests/run_unit.sh "unit/" "${EXCLUDED_UNIT_TESTS[@]}" --shard-id=1 --num-shards=3 --cov=nemo_rl --cov-append --cov-report=term-missing --cov-report=json --hf-gated --vllm-only
diff --git a/tests/unit/L0_Unit_Tests_Vllm_3.sh b/tests/unit/L0_Unit_Tests_Vllm_3.sh
index 9d136716b38..cf71ee235f6 100644
--- a/tests/unit/L0_Unit_Tests_Vllm_3.sh
+++ b/tests/unit/L0_Unit_Tests_Vllm_3.sh
@@ -18,7 +18,7 @@
source "$(dirname "${BASH_SOURCE[0]}")/run_unit_shard_common.sh"
# Base run (tests without extra markers)
-uv run --extra modelopt bash -x ./tests/run_unit.sh "unit/models/generation/test_vllm*.py" "unit/models/generation/test_dynamo*.py" "unit/models/generation/test_swe1_dynamo_config.py" "unit/models/generation/test_openai_server_utils.py" "unit/models/generation/test_fleet_health.py" "unit/models/generation/test_generation_router.py" "${EXCLUDED_UNIT_TESTS[@]}" --shard-id=2 --num-shards=3 --cov=nemo_rl --cov-report=term-missing --cov-report=json --hf-gated
+uv run --extra modelopt bash -x ./tests/run_unit.sh "unit/models/generation/test_vllm*.py" "unit/models/generation/test_dynamo*.py" "unit/models/generation/test_swe1_dynamo_config.py" "unit/models/generation/test_openai_server_utils.py" "unit/models/generation/test_fleet_health.py" "unit/models/generation/test_generation_router.py" "unit/models/generation/test_chat_template_parity_common.py" "${EXCLUDED_UNIT_TESTS[@]}" --shard-id=2 --num-shards=3 --cov=nemo_rl --cov-report=term-missing --cov-report=json --hf-gated
# vllm-only run (catch-all across all unit tests)
uv run --extra vllm bash -x ./tests/run_unit.sh "unit/" "${EXCLUDED_UNIT_TESTS[@]}" --shard-id=2 --num-shards=3 --cov=nemo_rl --cov-append --cov-report=term-missing --cov-report=json --hf-gated --vllm-only
diff --git a/tests/unit/models/generation/chat_template_parity_common.py b/tests/unit/models/generation/chat_template_parity_common.py
new file mode 100644
index 00000000000..dd1c88389ef
--- /dev/null
+++ b/tests/unit/models/generation/chat_template_parity_common.py
@@ -0,0 +1,205 @@
+# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved.
+#
+# 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.
+"""Shared contract for real vLLM/TRT-LLM chat-completions parity tests."""
+
+import json
+import os
+
+MODEL = os.environ.get("PARITY_MODEL", "Qwen/Qwen3-0.6B")
+MODEL_REVISION = os.environ.get(
+ "PARITY_MODEL_REVISION", "c1899de289a04d12100db370d81485cdf75e47ca"
+)
+
+TOOL_DEF = {
+ "type": "function",
+ "function": {
+ "name": "run_bash",
+ "description": "Run a bash command and return its stdout.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "command": {
+ "type": "string",
+ "description": "The bash command to run.",
+ }
+ },
+ "required": ["command"],
+ },
+ },
+}
+
+# Cover thinking with both parsers and Qwen3 without thinking.
+PARSER_SCENARIOS = {
+ "qwen3": (("qwen3_with_thinking", True), ("qwen3_without_thinking", False)),
+ "deepseek_r1": (("deepseek_r1_with_thinking", True),),
+}
+
+GENERATION_TOKEN_IDS_FIELD = "generation_token_ids"
+
+# Normalize serving-generated tool-call IDs.
+TOOL_CALL_ID = "chat-template-parity-tool-call"
+
+USER_MSG = "List the files in /tmp using run_bash."
+TOOL_RESULT = "file1.txt\nfile2.txt\nREADME.md"
+FOLLOWUP_USER_MSG = "Without calling a tool, reply with exactly: Done."
+
+
+def _tool_call_text(command: str) -> str:
+ payload = {"name": "run_bash", "arguments": {"command": command}}
+ return "\n" + json.dumps(payload) + "\n"
+
+
+# Deterministic cases exercise each backend's installed parsers.
+REASONING_PARSER_CONTRACT_CASES = (
+ {
+ "name": "explicit_reasoning_answer",
+ "raw_output": "inspect filesVisible answer",
+ "enable_thinking": True,
+ "reasoning_at_start": False,
+ "expected": {
+ "reasoning_content": "inspect files",
+ "content": "Visible answer",
+ "tool_calls": [],
+ },
+ },
+ {
+ "name": "reasoning_then_tool_call",
+ "raw_output": "need shell" + _tool_call_text("pwd"),
+ "enable_thinking": True,
+ "reasoning_at_start": False,
+ "expected": {
+ "reasoning_content": "need shell",
+ "content": None,
+ "tool_calls": [{"name": "run_bash", "arguments": {"command": "pwd"}}],
+ },
+ },
+ {
+ "name": "disabled_thinking_spontaneous_reasoning",
+ "raw_output": "unexpected thoughtVisible answer",
+ "enable_thinking": False,
+ "reasoning_at_start": False,
+ "expected": {
+ "reasoning_content": "unexpected thought",
+ "content": "Visible answer",
+ "tool_calls": [],
+ },
+ },
+ {
+ "name": "prompt_injected_reasoning_start",
+ "raw_output": "inspect filesVisible answer",
+ "enable_thinking": True,
+ "reasoning_at_start": True,
+ "expected": {
+ "reasoning_content": "inspect files",
+ "content": "Visible answer",
+ "tool_calls": [],
+ },
+ },
+)
+
+TOOL_PARSER_CONTRACT_CASES = (
+ {
+ "name": "single_tool_call",
+ "raw_output": _tool_call_text("pwd"),
+ "expected": {
+ "reasoning_content": "",
+ "content": None,
+ "tool_calls": [{"name": "run_bash", "arguments": {"command": "pwd"}}],
+ },
+ },
+ {
+ "name": "multiple_tool_calls",
+ "raw_output": _tool_call_text("pwd") + "\n" + _tool_call_text("whoami"),
+ "expected": {
+ "reasoning_content": "",
+ "content": None,
+ "tool_calls": [
+ {"name": "run_bash", "arguments": {"command": "pwd"}},
+ {"name": "run_bash", "arguments": {"command": "whoami"}},
+ ],
+ },
+ },
+ {
+ "name": "text_before_and_after_tool_call",
+ "raw_output": "before " + _tool_call_text("pwd") + " after",
+ "expected": {
+ "reasoning_content": "",
+ "content": "before",
+ "tool_calls": [{"name": "run_bash", "arguments": {"command": "pwd"}}],
+ },
+ },
+ {
+ "name": "malformed_tool_call_falls_back_to_content",
+ "raw_output": "\n{bad json}\n",
+ "expected": {
+ "reasoning_content": "",
+ "content": "\n{bad json}\n",
+ "tool_calls": [],
+ },
+ },
+)
+
+
+def token_edit_similarity(left: list[int], right: list[int]) -> float:
+ """Return normalized Levenshtein similarity for two token-ID sequences."""
+ if not left and not right:
+ return 1.0
+
+ previous = list(range(len(right) + 1))
+ for left_index, left_token in enumerate(left, start=1):
+ current = [left_index]
+ for right_index, right_token in enumerate(right, start=1):
+ current.append(
+ min(
+ current[-1] + 1,
+ previous[right_index] + 1,
+ previous[right_index - 1] + (left_token != right_token),
+ )
+ )
+ previous = current
+
+ return 1.0 - previous[-1] / max(len(left), len(right))
+
+
+def inclusive_token_span(
+ token_ids: list[int], start_marker: list[int], end_marker: list[int]
+) -> list[int]:
+ """Return the first token span bounded by the supplied marker sequences."""
+ assert start_marker, "start marker must not be empty"
+ assert end_marker, "end marker must not be empty"
+
+ def find_subsequence(needle: list[int], start: int) -> int:
+ last_start = len(token_ids) - len(needle)
+ for index in range(start, last_start + 1):
+ if token_ids[index : index + len(needle)] == needle:
+ return index
+ return -1
+
+ span_start = find_subsequence(start_marker, 0)
+ assert span_start >= 0, f"start marker {start_marker!r} not found in generation"
+ end_start = find_subsequence(end_marker, span_start + len(start_marker))
+ assert end_start >= 0, f"end marker {end_marker!r} not found in generation"
+ return token_ids[span_start : end_start + len(end_marker)]
+
+
+def prompt_suffix_after_turn(turns: list[dict], turn_index: int) -> list[int]:
+ """Return tokens appended after one turn's exact model prefix."""
+ previous_turn = turns[turn_index]
+ next_turn = turns[turn_index + 1]
+ required_prefix = (
+ previous_turn["prompt_token_ids"] + previous_turn["generation_token_ids"]
+ )
+ prompt = next_turn["prompt_token_ids"]
+ assert prompt[: len(required_prefix)] == required_prefix
+ return prompt[len(required_prefix) :]
diff --git a/tests/unit/models/generation/test_chat_template_parity_common.py b/tests/unit/models/generation/test_chat_template_parity_common.py
new file mode 100644
index 00000000000..ddd6caaf924
--- /dev/null
+++ b/tests/unit/models/generation/test_chat_template_parity_common.py
@@ -0,0 +1,120 @@
+# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved.
+#
+# 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 pytest
+
+from tests.unit.models.generation.chat_template_parity_common import (
+ inclusive_token_span,
+ prompt_suffix_after_turn,
+ token_edit_similarity,
+)
+
+
+@pytest.mark.parametrize(
+ ("left", "right", "expected"),
+ [
+ ([], [], 1.0),
+ ([1, 2, 3], [1, 2, 3], 1.0),
+ ([1, 2, 3], [1, 9, 3], 2 / 3),
+ ([1, 2], [1, 2, 3], 2 / 3),
+ ([1, 2, 3], [1, 2], 2 / 3),
+ ([1, 2], [3, 4], 0.0),
+ ([], [1, 2], 0.0),
+ ],
+)
+def test_token_edit_similarity(
+ left: list[int], right: list[int], expected: float
+) -> None:
+ assert token_edit_similarity(left, right) == pytest.approx(expected)
+
+
+def test_token_edit_similarity_is_symmetric() -> None:
+ left = [1, 2, 3]
+ right = [1, 9, 2, 3]
+ assert token_edit_similarity(left, right) == token_edit_similarity(right, left)
+
+
+def test_token_edit_similarity_090_threshold_allows_two_edits_in_twenty_tokens() -> (
+ None
+):
+ left = list(range(20))
+ two_edits = [999, 998] + list(range(2, 20))
+ assert token_edit_similarity(left, two_edits) == pytest.approx(0.9)
+
+ three_edits = [999, 998, 997] + list(range(3, 20))
+ assert token_edit_similarity(left, three_edits) < 0.9
+
+
+def test_inclusive_token_span_returns_markers_inclusive() -> None:
+ tokens = [0, 7, 8, 42, 43, 9, 10, 99]
+ assert inclusive_token_span(tokens, [7, 8], [9, 10]) == [7, 8, 42, 43, 9, 10]
+
+
+def test_inclusive_token_span_returns_first_matching_span() -> None:
+ tokens = [7, 1, 9, 7, 2, 9]
+ assert inclusive_token_span(tokens, [7], [9]) == [7, 1, 9]
+
+
+def test_inclusive_token_span_ignores_end_marker_before_start() -> None:
+ tokens = [9, 0, 7, 1, 9]
+ assert inclusive_token_span(tokens, [7], [9]) == [7, 1, 9]
+
+
+def test_inclusive_token_span_rejects_missing_start_marker() -> None:
+ with pytest.raises(AssertionError, match="start marker .* not found"):
+ inclusive_token_span([1, 2, 3], [7], [3])
+
+
+def test_inclusive_token_span_rejects_missing_end_marker() -> None:
+ with pytest.raises(AssertionError, match="end marker .* not found"):
+ inclusive_token_span([1, 2, 3], [1], [9])
+
+
+@pytest.mark.parametrize(
+ ("start_marker", "end_marker", "message"),
+ [
+ ([], [2], "start marker must not be empty"),
+ ([1], [], "end marker must not be empty"),
+ ],
+)
+def test_inclusive_token_span_rejects_empty_markers(
+ start_marker: list[int], end_marker: list[int], message: str
+) -> None:
+ with pytest.raises(AssertionError, match=message):
+ inclusive_token_span([1, 2], start_marker, end_marker)
+
+
+def test_prompt_suffix_after_turn_returns_appended_tokens() -> None:
+ turns = [
+ {"prompt_token_ids": [1, 2], "generation_token_ids": [3, 4]},
+ {"prompt_token_ids": [1, 2, 3, 4, 5, 6], "generation_token_ids": [7]},
+ ]
+ assert prompt_suffix_after_turn(turns, 0) == [5, 6]
+
+
+def test_prompt_suffix_after_turn_can_be_empty() -> None:
+ turns = [
+ {"prompt_token_ids": [1], "generation_token_ids": [2]},
+ {"prompt_token_ids": [1, 2], "generation_token_ids": [3]},
+ ]
+ assert prompt_suffix_after_turn(turns, 0) == []
+
+
+def test_prompt_suffix_after_turn_rejects_changed_prefix() -> None:
+ turns = [
+ {"prompt_token_ids": [1], "generation_token_ids": [2]},
+ {"prompt_token_ids": [1, 9, 3], "generation_token_ids": [4]},
+ ]
+ with pytest.raises(AssertionError):
+ prompt_suffix_after_turn(turns, 0)
diff --git a/tests/unit/models/generation/test_openai_server_utils.py b/tests/unit/models/generation/test_openai_server_utils.py
index 0773dab3ebb..7f06a5e01d6 100644
--- a/tests/unit/models/generation/test_openai_server_utils.py
+++ b/tests/unit/models/generation/test_openai_server_utils.py
@@ -94,6 +94,36 @@ class _T:
assert result == [100, 2, 77, 88]
+def test_replace_prefix_tokens_preserves_secondary_sampled_stop_token():
+ class _T:
+ eos_token_id = 2
+
+ result = replace_prefix_tokens(
+ tokenizer=_T(),
+ model_prefix_token_ids=[100, 3],
+ template_prefix_token_ids=[9, 2],
+ template_token_ids=[9, 2, 77, 88],
+ model_stop_token_ids={2, 3},
+ )
+
+ assert result == [100, 3, 77, 88]
+
+
+def test_replace_prefix_tokens_adds_template_eos_after_length_termination():
+ class _T:
+ eos_token_id = 2
+
+ result = replace_prefix_tokens(
+ tokenizer=_T(),
+ model_prefix_token_ids=[100, 55],
+ template_prefix_token_ids=[9, 2],
+ template_token_ids=[9, 2, 77, 88],
+ model_stop_token_ids={2, 3},
+ )
+
+ assert result == [100, 55, 2, 77, 88]
+
+
def test_replace_prefix_tokens_qwen3_think_shift_picks_assistant_eos_not_user_eos():
"""Non-strict-prefix: Qwen3 strips from history when the last message is a
user turn, so the template's prefix region is shorter and a later user-turn EOS
diff --git a/tests/unit/models/generation/test_vllm_http_server_parity.py b/tests/unit/models/generation/test_vllm_http_server_parity.py
new file mode 100644
index 00000000000..2163965d97d
--- /dev/null
+++ b/tests/unit/models/generation/test_vllm_http_server_parity.py
@@ -0,0 +1,465 @@
+# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved.
+#
+# 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.
+"""vLLM chat-completions parity and golden generation.
+
+Regenerate the shared golden from vLLM on one GPU with::
+
+ NEMO_RL_GENERATE_PARITY_GOLDEN=1 uv run --extra vllm pytest \
+ tests/unit/models/generation/test_vllm_http_server_parity.py \
+ -k test_parity -p no:randomly --vllm-only
+"""
+
+import json
+import os
+import time
+from collections.abc import Iterator
+from copy import deepcopy
+from pathlib import Path
+from typing import Any
+
+import pytest
+import requests
+
+from nemo_rl.distributed.virtual_cluster import RayVirtualCluster
+from nemo_rl.models.generation import configure_generation_config
+from nemo_rl.models.generation.vllm import VllmConfig, VllmGeneration
+from tests.unit.models.generation.chat_template_parity_common import (
+ FOLLOWUP_USER_MSG,
+ MODEL,
+ MODEL_REVISION,
+ PARSER_SCENARIOS,
+ REASONING_PARSER_CONTRACT_CASES,
+ TOOL_CALL_ID,
+ TOOL_DEF,
+ TOOL_PARSER_CONTRACT_CASES,
+ TOOL_RESULT,
+ USER_MSG,
+ prompt_suffix_after_turn,
+)
+
+pytestmark = pytest.mark.vllm
+
+GOLDEN_PATH = (
+ Path(__file__).parent / "trtllm" / "fixtures" / "chat_template_parity_golden.json"
+)
+
+_BASE_VLLM_CFG: VllmConfig = {
+ "backend": "vllm",
+ "model_name": MODEL,
+ "tokenizer": {"name": MODEL},
+ "dtype": "bfloat16",
+ "max_new_tokens": 512,
+ "temperature": 0.0,
+ "top_p": 1.0,
+ "top_k": None,
+ "val_temperature": 0.0,
+ "val_top_p": 1.0,
+ "val_top_k": None,
+ "stop_token_ids": None,
+ "stop_strings": None,
+ "vllm_cfg": {
+ "precision": "bfloat16",
+ "tensor_parallel_size": 1,
+ "pipeline_parallel_size": 1,
+ "expert_parallel_size": 1,
+ "gpu_memory_utilization": 0.7,
+ "max_model_len": 4096,
+ "async_engine": True,
+ "expose_http_server": True,
+ "skip_tokenizer_init": False,
+ "load_format": "auto",
+ "enforce_eager": False,
+ "kv_cache_dtype": "auto",
+ "http_server_serving_chat_kwargs": {"tool_parser": "hermes"},
+ },
+ "colocated": {
+ "enabled": True,
+ "resources": {"gpus_per_node": None, "num_nodes": None},
+ },
+ "vllm_kwargs": {
+ "revision": MODEL_REVISION,
+ "tokenizer_revision": MODEL_REVISION,
+ },
+}
+
+
+def _wait_for_server(base_url: str, timeout: int = 180) -> None:
+ health_url = base_url.rstrip("/").removesuffix("/v1") + "/health"
+ deadline = time.time() + timeout
+ while time.time() < deadline:
+ try:
+ if requests.get(health_url, timeout=3).status_code == 200:
+ return
+ except Exception:
+ pass
+ time.sleep(2)
+ raise TimeoutError(f"vLLM server at {base_url} did not start within {timeout}s")
+
+
+def _extract_fields(response_json: dict[str, Any]) -> dict[str, Any]:
+ choice = response_json["choices"][0]
+ msg = choice["message"]
+ tool_calls = [
+ {
+ "function": {
+ "name": tc["function"]["name"],
+ "arguments": tc["function"]["arguments"],
+ }
+ }
+ for tc in (msg.get("tool_calls") or [])
+ ]
+ return {
+ "prompt_token_ids": msg["prompt_token_ids"],
+ "generation_token_ids": msg["generation_token_ids"],
+ "content": msg.get("content"),
+ # Normalize vLLM and NeMo-Gym reasoning field names.
+ "reasoning_content": msg.get("reasoning") or msg.get("reasoning_content") or "",
+ "tool_calls": tool_calls,
+ "finish_reason": choice["finish_reason"],
+ }
+
+
+def _run_scenario(base_url: str, enable_thinking: bool) -> list[dict[str, Any]]:
+ template_kwargs = {"enable_thinking": enable_thinking}
+ base = base_url.rstrip("/")
+
+ body1 = {
+ "model": MODEL,
+ "messages": [{"role": "user", "content": USER_MSG}],
+ "tools": [TOOL_DEF],
+ "temperature": 0.0,
+ "top_p": 1.0,
+ "top_k": -1,
+ "max_tokens": 512,
+ "logprobs": True,
+ "top_logprobs": 0,
+ "chat_template_kwargs": template_kwargs,
+ }
+ r1 = requests.post(f"{base}/chat/completions", json=body1, timeout=180)
+ r1.raise_for_status()
+ resp1 = r1.json()
+ turn1 = _extract_fields(resp1)
+ assert turn1["finish_reason"] == "tool_calls", (
+ f"Turn 1 expected finish_reason='tool_calls', got {turn1['finish_reason']!r}.\n"
+ f"content: {resp1['choices'][0]['message'].get('content')}"
+ )
+
+ raw_msg1 = resp1["choices"][0]["message"]
+ normalized_tool_calls = deepcopy(raw_msg1["tool_calls"])
+ assert len(normalized_tool_calls) == 1
+ normalized_tool_calls[0]["id"] = TOOL_CALL_ID
+ asst_msg = {
+ "role": "assistant",
+ "content": raw_msg1.get("content"),
+ "tool_calls": normalized_tool_calls,
+ "prompt_token_ids": turn1["prompt_token_ids"],
+ "generation_token_ids": turn1["generation_token_ids"],
+ "generation_log_probs": raw_msg1["generation_log_probs"],
+ }
+ tool_result_msg = {
+ "role": "tool",
+ "tool_call_id": TOOL_CALL_ID,
+ "content": TOOL_RESULT,
+ }
+
+ body2 = {
+ "model": MODEL,
+ "messages": [{"role": "user", "content": USER_MSG}, asst_msg, tool_result_msg],
+ "tools": [TOOL_DEF],
+ "temperature": 0.0,
+ "top_p": 1.0,
+ "top_k": -1,
+ "max_tokens": 512,
+ "logprobs": True,
+ "top_logprobs": 0,
+ "chat_template_kwargs": template_kwargs,
+ }
+ r2 = requests.post(f"{base}/chat/completions", json=body2, timeout=180)
+ r2.raise_for_status()
+ resp2 = r2.json()
+ turn2 = _extract_fields(resp2)
+ required_prefix = turn1["prompt_token_ids"] + turn1["generation_token_ids"]
+ assert turn2["prompt_token_ids"][: len(required_prefix)] == required_prefix, (
+ "vLLM turn-two engine prompt does not preserve the exact turn-one model prefix"
+ )
+ assert turn2["content"] is not None and not turn2["tool_calls"], (
+ "Turn 2 must be a normal assistant answer before the user follow-up"
+ )
+
+ raw_msg2 = resp2["choices"][0]["message"]
+ asst_msg2 = {
+ "role": "assistant",
+ "content": raw_msg2.get("content"),
+ "prompt_token_ids": turn2["prompt_token_ids"],
+ "generation_token_ids": turn2["generation_token_ids"],
+ "generation_log_probs": raw_msg2["generation_log_probs"],
+ }
+ body3 = {
+ "model": MODEL,
+ "messages": [
+ {"role": "user", "content": USER_MSG},
+ asst_msg,
+ tool_result_msg,
+ asst_msg2,
+ {"role": "user", "content": FOLLOWUP_USER_MSG},
+ ],
+ "tools": [TOOL_DEF],
+ "temperature": 0.0,
+ "top_p": 1.0,
+ "top_k": -1,
+ "max_tokens": 512,
+ "logprobs": True,
+ "top_logprobs": 0,
+ "chat_template_kwargs": template_kwargs,
+ }
+ r3 = requests.post(f"{base}/chat/completions", json=body3, timeout=180)
+ r3.raise_for_status()
+ turn3 = _extract_fields(r3.json())
+ required_prefix = turn2["prompt_token_ids"] + turn2["generation_token_ids"]
+ assert turn3["prompt_token_ids"][: len(required_prefix)] == required_prefix, (
+ "vLLM turn-three engine prompt does not preserve the latest assistant prefix"
+ )
+ return [turn1, turn2, turn3]
+
+
+def _should_generate_golden() -> bool:
+ return os.environ.get("NEMO_RL_GENERATE_PARITY_GOLDEN", "").lower() in (
+ "1",
+ "true",
+ "yes",
+ )
+
+
+def _load_golden(reasoning_parser: str) -> dict[str, Any]:
+ assert GOLDEN_PATH.exists(), (
+ "Golden missing; regenerate it with NEMO_RL_GENERATE_PARITY_GOLDEN=1 "
+ "and the vLLM parity test"
+ )
+ golden = json.loads(GOLDEN_PATH.read_text())
+ assert golden.get("source_backend") == "vllm", (
+ "Golden was not generated by vLLM; regenerate it"
+ )
+ assert golden.get("model") == MODEL, (
+ f"Golden is for {golden.get('model')!r}, not {MODEL!r}; regenerate it"
+ )
+ assert golden.get("model_revision") == MODEL_REVISION, (
+ "Golden model revision does not match; regenerate it"
+ )
+ for scenario_name, _ in PARSER_SCENARIOS[reasoning_parser]:
+ assert scenario_name in golden.get("scenarios", {}), (
+ f"Scenario {scenario_name!r} missing from golden; regenerate it"
+ )
+ return golden
+
+
+@pytest.fixture(scope="module")
+def tokenizer():
+ from transformers import AutoTokenizer
+
+ tokenizer = AutoTokenizer.from_pretrained(
+ MODEL, revision=MODEL_REVISION, trust_remote_code=True
+ )
+ if tokenizer.pad_token is None:
+ tokenizer.pad_token = tokenizer.eos_token
+ return tokenizer
+
+
+def _parse_with_vllm(
+ tokenizer,
+ *,
+ raw_output: str,
+ reasoning_parser: str | None,
+ tool_parser: str | None,
+ tools: list[dict[str, Any]] | None,
+ enable_thinking: bool,
+ reasoning_at_start: bool = False,
+) -> dict[str, Any]:
+ # Keep optional vLLM imports out of module collection. The base unit-test
+ # phase imports this file before its vllm marker can be deselected.
+ from vllm.entrypoints.openai.chat_completion.protocol import (
+ ChatCompletionRequest,
+ )
+ from vllm.reasoning.abs_reasoning_parsers import ReasoningParserManager
+ from vllm.tool_parsers.abstract_tool_parser import ToolParserManager
+
+ request = ChatCompletionRequest(
+ model=MODEL,
+ messages=[{"role": "user", "content": "parser contract"}],
+ tools=tools,
+ chat_template_kwargs={"enable_thinking": enable_thinking},
+ )
+ reasoning_content = ""
+ content: str | None = raw_output
+
+ if reasoning_parser is not None:
+ # vLLM has no reasoning_at_start flag: its parser infers the shape from
+ # the text, so output without an opening is passed through
+ # untouched. TRT-LLM is told explicitly via the flag instead.
+ parser_input = raw_output
+ parser_type = ReasoningParserManager.get_reasoning_parser(reasoning_parser)
+ parser = parser_type(tokenizer)
+ reasoning_content, content = parser.extract_reasoning(parser_input, request)
+ reasoning_content = reasoning_content or ""
+
+ normalized_calls: list[dict[str, Any]] = []
+ if tool_parser is not None and tools and content:
+ parser_type = ToolParserManager.get_tool_parser(tool_parser)
+ parser = parser_type(tokenizer, request.tools)
+ parsed = parser.extract_tool_calls(content, request)
+ if parsed.tools_called:
+ content = parsed.content
+ for call in parsed.tool_calls:
+ arguments = call.function.arguments
+ normalized_calls.append(
+ {
+ "name": call.function.name,
+ "arguments": (
+ json.loads(arguments)
+ if isinstance(arguments, str)
+ else arguments
+ ),
+ }
+ )
+
+ return {
+ "reasoning_content": reasoning_content,
+ "content": content,
+ "tool_calls": normalized_calls,
+ }
+
+
+@pytest.mark.parametrize("reasoning_parser", tuple(PARSER_SCENARIOS))
+def test_reasoning_parser_contracts(tokenizer, reasoning_parser: str) -> None:
+ for case in REASONING_PARSER_CONTRACT_CASES:
+ actual = _parse_with_vllm(
+ tokenizer,
+ raw_output=case["raw_output"],
+ reasoning_parser=reasoning_parser,
+ tool_parser="hermes",
+ tools=[TOOL_DEF],
+ enable_thinking=case["enable_thinking"],
+ reasoning_at_start=case["reasoning_at_start"],
+ )
+ assert actual == case["expected"], "vLLM %s reasoning contract %r failed" % (
+ reasoning_parser,
+ case["name"],
+ )
+
+
+def test_tool_parser_contracts(tokenizer) -> None:
+ for case in TOOL_PARSER_CONTRACT_CASES:
+ actual = _parse_with_vllm(
+ tokenizer,
+ raw_output=case["raw_output"],
+ reasoning_parser=None,
+ tool_parser="hermes",
+ tools=[TOOL_DEF],
+ enable_thinking=False,
+ )
+ actual_norm = {**actual, "content": (actual["content"] or "").strip() or None}
+ assert actual_norm == case["expected"], (
+ "vLLM Hermes tool contract %r failed" % case["name"]
+ )
+
+
+@pytest.fixture(scope="module", params=tuple(PARSER_SCENARIOS))
+def vllm_server(
+ request: pytest.FixtureRequest,
+ tokenizer,
+) -> Iterator[tuple[str, str]]:
+ reasoning_parser = request.param
+ if not _should_generate_golden():
+ _load_golden(reasoning_parser)
+
+ cluster = RayVirtualCluster(
+ bundle_ct_per_node_list=[1],
+ use_gpus=True,
+ max_colocated_worker_groups=1,
+ num_gpus_per_node=1,
+ name=f"vllm-parity-{reasoning_parser}-cluster",
+ )
+ raw_cfg = deepcopy(_BASE_VLLM_CFG)
+ raw_cfg["vllm_cfg"]["http_server_serving_chat_kwargs"]["reasoning_parser"] = (
+ reasoning_parser
+ )
+ cfg = configure_generation_config(raw_cfg, tokenizer, is_eval=True)
+ gen = VllmGeneration(cluster, cfg)
+
+ base_urls = gen.dp_openai_server_base_urls
+ assert len(base_urls) == 1
+ _wait_for_server(base_urls[0])
+ yield reasoning_parser, base_urls[0]
+ gen.shutdown()
+ cluster.shutdown()
+
+
+def test_parity(vllm_server: tuple[str, str]) -> None:
+ reasoning_parser, base_url = vllm_server
+ generate_golden = _should_generate_golden()
+ golden = {} if generate_golden else _load_golden(reasoning_parser)
+
+ suffix_mismatches = []
+ for scenario_name, enable_thinking in PARSER_SCENARIOS[reasoning_parser]:
+ actual_turns = _run_scenario(base_url, enable_thinking)
+
+ if generate_golden:
+ golden = json.loads(GOLDEN_PATH.read_text()) if GOLDEN_PATH.exists() else {}
+ if (
+ golden.get("model") != MODEL
+ or golden.get("model_revision") != MODEL_REVISION
+ or golden.get("source_backend") != "vllm"
+ ):
+ golden = {
+ "model": MODEL,
+ "model_revision": MODEL_REVISION,
+ "source_backend": "vllm",
+ "scenarios": {},
+ }
+ golden.setdefault("scenarios", {})
+ golden["scenarios"][scenario_name] = {"turns": actual_turns}
+ GOLDEN_PATH.parent.mkdir(parents=True, exist_ok=True)
+ GOLDEN_PATH.write_text(json.dumps(golden, indent=2) + "\n")
+ continue
+
+ expected_turns = golden["scenarios"][scenario_name]["turns"]
+ assert len(actual_turns) == len(expected_turns)
+
+ actual_turn_one, expected_turn_one = actual_turns[0], expected_turns[0]
+ assert (
+ actual_turn_one["prompt_token_ids"] == expected_turn_one["prompt_token_ids"]
+ ), f"scenario={scenario_name!r}: turn-one engine prompt mismatch"
+
+ for turn_index in range(len(actual_turns) - 1):
+ actual_suffix = prompt_suffix_after_turn(actual_turns, turn_index)
+ expected_suffix = prompt_suffix_after_turn(expected_turns, turn_index)
+ if actual_suffix != expected_suffix:
+ suffix_mismatches.append(
+ f"scenario={scenario_name!r}: transition {turn_index + 1}->"
+ f"{turn_index + 2} appended prompt suffix mismatch "
+ f"(vLLM={actual_suffix!r}, TRT-LLM={expected_suffix!r})"
+ )
+
+ if enable_thinking:
+ assert actual_turn_one["reasoning_content"], (
+ f"scenario={scenario_name!r}: reasoning parser was not exercised"
+ )
+ assert (
+ not actual_turn_one["reasoning_content"].lstrip().startswith("")
+ ), f"scenario={scenario_name!r}: reasoning marker leaked into response"
+ else:
+ assert not actual_turn_one["reasoning_content"], (
+ f"scenario={scenario_name!r}: reasoning leaked while thinking was disabled"
+ )
+
+ assert not suffix_mismatches, "\n".join(suffix_mismatches)
diff --git a/tests/unit/models/generation/trtllm/conftest.py b/tests/unit/models/generation/trtllm/conftest.py
new file mode 100644
index 00000000000..dfb09680f41
--- /dev/null
+++ b/tests/unit/models/generation/trtllm/conftest.py
@@ -0,0 +1,31 @@
+# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved.
+#
+# 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.
+"""No-op Ray fixture overrides for the TRT-LLM direct-server tests.
+
+Tests here use tensorrt_llm.LLM directly, not via Ray actors, so they must not
+auto-connect to a running cluster via the session-scoped autouse fixtures in
+tests/unit/conftest.py.
+"""
+
+import pytest
+
+
+@pytest.fixture(scope="session", autouse=True)
+def init_ray_cluster():
+ yield
+
+
+@pytest.fixture(scope="session", autouse=True)
+def ray_gpu_monitor(init_ray_cluster):
+ yield
diff --git a/tests/unit/models/generation/trtllm/fixtures/chat_template_parity_golden.json b/tests/unit/models/generation/trtllm/fixtures/chat_template_parity_golden.json
new file mode 100644
index 00000000000..ba66f2f9287
--- /dev/null
+++ b/tests/unit/models/generation/trtllm/fixtures/chat_template_parity_golden.json
@@ -0,0 +1,4289 @@
+{
+ "model": "Qwen/Qwen3-0.6B",
+ "model_revision": "c1899de289a04d12100db370d81485cdf75e47ca",
+ "source_backend": "vllm",
+ "scenarios": {
+ "qwen3_with_thinking": {
+ "turns": [
+ {
+ "prompt_token_ids": [
+ 151644,
+ 8948,
+ 198,
+ 2,
+ 13852,
+ 271,
+ 2610,
+ 1231,
+ 1618,
+ 825,
+ 476,
+ 803,
+ 5746,
+ 311,
+ 7789,
+ 448,
+ 279,
+ 1196,
+ 3239,
+ 382,
+ 2610,
+ 525,
+ 3897,
+ 448,
+ 729,
+ 32628,
+ 2878,
+ 366,
+ 15918,
+ 1472,
+ 15918,
+ 29,
+ 11874,
+ 9492,
+ 510,
+ 27,
+ 15918,
+ 397,
+ 4913,
+ 1313,
+ 788,
+ 330,
+ 1688,
+ 497,
+ 330,
+ 1688,
+ 788,
+ 5212,
+ 606,
+ 788,
+ 330,
+ 6108,
+ 880,
+ 988,
+ 497,
+ 330,
+ 4684,
+ 788,
+ 330,
+ 6727,
+ 264,
+ 27023,
+ 3210,
+ 323,
+ 470,
+ 1181,
+ 20075,
+ 10465,
+ 330,
+ 13786,
+ 788,
+ 5212,
+ 1313,
+ 788,
+ 330,
+ 1700,
+ 497,
+ 330,
+ 13193,
+ 788,
+ 5212,
+ 5631,
+ 788,
+ 5212,
+ 1313,
+ 788,
+ 330,
+ 917,
+ 497,
+ 330,
+ 4684,
+ 788,
+ 330,
+ 785,
+ 27023,
+ 3210,
+ 311,
+ 1598,
+ 1189,
+ 38154,
+ 330,
+ 6279,
+ 788,
+ 4383,
+ 5631,
+ 1341,
+ 3417,
+ 532,
+ 522,
+ 15918,
+ 1339,
+ 2461,
+ 1817,
+ 729,
+ 1618,
+ 11,
+ 470,
+ 264,
+ 2951,
+ 1633,
+ 448,
+ 729,
+ 829,
+ 323,
+ 5977,
+ 2878,
+ 220,
+ 151657,
+ 151658,
+ 11874,
+ 9492,
+ 510,
+ 151657,
+ 198,
+ 4913,
+ 606,
+ 788,
+ 366,
+ 1688,
+ 11494,
+ 8066,
+ 330,
+ 16370,
+ 788,
+ 366,
+ 2116,
+ 56080,
+ 40432,
+ 31296,
+ 151658,
+ 151645,
+ 198,
+ 151644,
+ 872,
+ 198,
+ 852,
+ 279,
+ 3542,
+ 304,
+ 608,
+ 5173,
+ 1667,
+ 1598,
+ 880,
+ 988,
+ 13,
+ 151645,
+ 198,
+ 151644,
+ 77091,
+ 198
+ ],
+ "generation_token_ids": [
+ 151667,
+ 198,
+ 32313,
+ 11,
+ 279,
+ 1196,
+ 6801,
+ 311,
+ 1140,
+ 279,
+ 3542,
+ 304,
+ 608,
+ 5173,
+ 1667,
+ 279,
+ 1598,
+ 880,
+ 988,
+ 729,
+ 13,
+ 6771,
+ 752,
+ 1779,
+ 279,
+ 2500,
+ 7375,
+ 13,
+ 576,
+ 3897,
+ 729,
+ 1598,
+ 880,
+ 988,
+ 4990,
+ 264,
+ 3210,
+ 323,
+ 4675,
+ 20075,
+ 13,
+ 2055,
+ 11,
+ 358,
+ 1184,
+ 311,
+ 9026,
+ 264,
+ 27023,
+ 3210,
+ 429,
+ 11469,
+ 3542,
+ 304,
+ 608,
+ 5173,
+ 382,
+ 785,
+ 5297,
+ 1616,
+ 311,
+ 1140,
+ 3542,
+ 304,
+ 264,
+ 6220,
+ 374,
+ 1667,
+ 279,
+ 19597,
+ 3210,
+ 13,
+ 1988,
+ 3783,
+ 11,
+ 279,
+ 1196,
+ 2578,
+ 537,
+ 614,
+ 279,
+ 19597,
+ 3210,
+ 10275,
+ 13,
+ 4354,
+ 11,
+ 279,
+ 729,
+ 594,
+ 4008,
+ 2727,
+ 432,
+ 8473,
+ 264,
+ 27023,
+ 3210,
+ 11,
+ 773,
+ 7196,
+ 432,
+ 646,
+ 3705,
+ 429,
+ 13,
+ 358,
+ 1265,
+ 990,
+ 279,
+ 19597,
+ 3210,
+ 448,
+ 481,
+ 64,
+ 311,
+ 1473,
+ 678,
+ 3542,
+ 11,
+ 2670,
+ 8177,
+ 6174,
+ 13,
+ 576,
+ 3210,
+ 1035,
+ 387,
+ 330,
+ 4730,
+ 481,
+ 64,
+ 608,
+ 5173,
+ 3263,
+ 4710,
+ 14190,
+ 11,
+ 714,
+ 7025,
+ 608,
+ 5173,
+ 2578,
+ 614,
+ 264,
+ 2696,
+ 315,
+ 3542,
+ 11,
+ 323,
+ 279,
+ 1196,
+ 2578,
+ 1366,
+ 311,
+ 1779,
+ 421,
+ 1052,
+ 525,
+ 894,
+ 4714,
+ 13,
+ 1988,
+ 279,
+ 1887,
+ 3383,
+ 374,
+ 311,
+ 1140,
+ 1105,
+ 13,
+ 2055,
+ 11,
+ 279,
+ 4396,
+ 3210,
+ 374,
+ 19597,
+ 481,
+ 64,
+ 608,
+ 5173,
+ 13,
+ 6771,
+ 752,
+ 1281,
+ 2704,
+ 1052,
+ 525,
+ 902,
+ 13580,
+ 966,
+ 13,
+ 7414,
+ 11,
+ 429,
+ 1265,
+ 975,
+ 13,
+ 2055,
+ 11,
+ 358,
+ 3278,
+ 1618,
+ 279,
+ 1598,
+ 880,
+ 988,
+ 729,
+ 448,
+ 429,
+ 3210,
+ 624,
+ 151668,
+ 271,
+ 151657,
+ 198,
+ 4913,
+ 606,
+ 788,
+ 330,
+ 6108,
+ 880,
+ 988,
+ 497,
+ 330,
+ 16370,
+ 788,
+ 5212,
+ 5631,
+ 788,
+ 330,
+ 4730,
+ 481,
+ 64,
+ 608,
+ 5173,
+ 95642,
+ 151658,
+ 151645
+ ],
+ "content": null,
+ "reasoning_content": "\nOkay, the user wants to list the files in /tmp using the run_bash function. Let me check the available tools. The provided function run_bash takes a command and returns stdout. So, I need to execute a bash command that lists files in /tmp.\n\nThe standard way to list files in a directory is using the ls command. But wait, the user might not have the ls command installed. However, the function's description says it runs a bash command, so maybe it can handle that. I should use the ls command with -a to show all files, including hidden ones. The command would be \"ls -a /tmp\". \n\nWait, but sometimes /tmp might have a lot of files, and the user might want to check if there are any issues. But the main task is to list them. So, the correct command is ls -a /tmp. Let me make sure there are no typos. Yes, that should work. So, I'll call the run_bash function with that command.\n",
+ "tool_calls": [
+ {
+ "function": {
+ "name": "run_bash",
+ "arguments": "{\"command\": \"ls -a /tmp\"}"
+ }
+ }
+ ],
+ "finish_reason": "tool_calls"
+ },
+ {
+ "prompt_token_ids": [
+ 151644,
+ 8948,
+ 198,
+ 2,
+ 13852,
+ 271,
+ 2610,
+ 1231,
+ 1618,
+ 825,
+ 476,
+ 803,
+ 5746,
+ 311,
+ 7789,
+ 448,
+ 279,
+ 1196,
+ 3239,
+ 382,
+ 2610,
+ 525,
+ 3897,
+ 448,
+ 729,
+ 32628,
+ 2878,
+ 366,
+ 15918,
+ 1472,
+ 15918,
+ 29,
+ 11874,
+ 9492,
+ 510,
+ 27,
+ 15918,
+ 397,
+ 4913,
+ 1313,
+ 788,
+ 330,
+ 1688,
+ 497,
+ 330,
+ 1688,
+ 788,
+ 5212,
+ 606,
+ 788,
+ 330,
+ 6108,
+ 880,
+ 988,
+ 497,
+ 330,
+ 4684,
+ 788,
+ 330,
+ 6727,
+ 264,
+ 27023,
+ 3210,
+ 323,
+ 470,
+ 1181,
+ 20075,
+ 10465,
+ 330,
+ 13786,
+ 788,
+ 5212,
+ 1313,
+ 788,
+ 330,
+ 1700,
+ 497,
+ 330,
+ 13193,
+ 788,
+ 5212,
+ 5631,
+ 788,
+ 5212,
+ 1313,
+ 788,
+ 330,
+ 917,
+ 497,
+ 330,
+ 4684,
+ 788,
+ 330,
+ 785,
+ 27023,
+ 3210,
+ 311,
+ 1598,
+ 1189,
+ 38154,
+ 330,
+ 6279,
+ 788,
+ 4383,
+ 5631,
+ 1341,
+ 3417,
+ 532,
+ 522,
+ 15918,
+ 1339,
+ 2461,
+ 1817,
+ 729,
+ 1618,
+ 11,
+ 470,
+ 264,
+ 2951,
+ 1633,
+ 448,
+ 729,
+ 829,
+ 323,
+ 5977,
+ 2878,
+ 220,
+ 151657,
+ 151658,
+ 11874,
+ 9492,
+ 510,
+ 151657,
+ 198,
+ 4913,
+ 606,
+ 788,
+ 366,
+ 1688,
+ 11494,
+ 8066,
+ 330,
+ 16370,
+ 788,
+ 366,
+ 2116,
+ 56080,
+ 40432,
+ 31296,
+ 151658,
+ 151645,
+ 198,
+ 151644,
+ 872,
+ 198,
+ 852,
+ 279,
+ 3542,
+ 304,
+ 608,
+ 5173,
+ 1667,
+ 1598,
+ 880,
+ 988,
+ 13,
+ 151645,
+ 198,
+ 151644,
+ 77091,
+ 198,
+ 151667,
+ 198,
+ 32313,
+ 11,
+ 279,
+ 1196,
+ 6801,
+ 311,
+ 1140,
+ 279,
+ 3542,
+ 304,
+ 608,
+ 5173,
+ 1667,
+ 279,
+ 1598,
+ 880,
+ 988,
+ 729,
+ 13,
+ 6771,
+ 752,
+ 1779,
+ 279,
+ 2500,
+ 7375,
+ 13,
+ 576,
+ 3897,
+ 729,
+ 1598,
+ 880,
+ 988,
+ 4990,
+ 264,
+ 3210,
+ 323,
+ 4675,
+ 20075,
+ 13,
+ 2055,
+ 11,
+ 358,
+ 1184,
+ 311,
+ 9026,
+ 264,
+ 27023,
+ 3210,
+ 429,
+ 11469,
+ 3542,
+ 304,
+ 608,
+ 5173,
+ 382,
+ 785,
+ 5297,
+ 1616,
+ 311,
+ 1140,
+ 3542,
+ 304,
+ 264,
+ 6220,
+ 374,
+ 1667,
+ 279,
+ 19597,
+ 3210,
+ 13,
+ 1988,
+ 3783,
+ 11,
+ 279,
+ 1196,
+ 2578,
+ 537,
+ 614,
+ 279,
+ 19597,
+ 3210,
+ 10275,
+ 13,
+ 4354,
+ 11,
+ 279,
+ 729,
+ 594,
+ 4008,
+ 2727,
+ 432,
+ 8473,
+ 264,
+ 27023,
+ 3210,
+ 11,
+ 773,
+ 7196,
+ 432,
+ 646,
+ 3705,
+ 429,
+ 13,
+ 358,
+ 1265,
+ 990,
+ 279,
+ 19597,
+ 3210,
+ 448,
+ 481,
+ 64,
+ 311,
+ 1473,
+ 678,
+ 3542,
+ 11,
+ 2670,
+ 8177,
+ 6174,
+ 13,
+ 576,
+ 3210,
+ 1035,
+ 387,
+ 330,
+ 4730,
+ 481,
+ 64,
+ 608,
+ 5173,
+ 3263,
+ 4710,
+ 14190,
+ 11,
+ 714,
+ 7025,
+ 608,
+ 5173,
+ 2578,
+ 614,
+ 264,
+ 2696,
+ 315,
+ 3542,
+ 11,
+ 323,
+ 279,
+ 1196,
+ 2578,
+ 1366,
+ 311,
+ 1779,
+ 421,
+ 1052,
+ 525,
+ 894,
+ 4714,
+ 13,
+ 1988,
+ 279,
+ 1887,
+ 3383,
+ 374,
+ 311,
+ 1140,
+ 1105,
+ 13,
+ 2055,
+ 11,
+ 279,
+ 4396,
+ 3210,
+ 374,
+ 19597,
+ 481,
+ 64,
+ 608,
+ 5173,
+ 13,
+ 6771,
+ 752,
+ 1281,
+ 2704,
+ 1052,
+ 525,
+ 902,
+ 13580,
+ 966,
+ 13,
+ 7414,
+ 11,
+ 429,
+ 1265,
+ 975,
+ 13,
+ 2055,
+ 11,
+ 358,
+ 3278,
+ 1618,
+ 279,
+ 1598,
+ 880,
+ 988,
+ 729,
+ 448,
+ 429,
+ 3210,
+ 624,
+ 151668,
+ 271,
+ 151657,
+ 198,
+ 4913,
+ 606,
+ 788,
+ 330,
+ 6108,
+ 880,
+ 988,
+ 497,
+ 330,
+ 16370,
+ 788,
+ 5212,
+ 5631,
+ 788,
+ 330,
+ 4730,
+ 481,
+ 64,
+ 608,
+ 5173,
+ 95642,
+ 151658,
+ 151645,
+ 198,
+ 151644,
+ 872,
+ 198,
+ 151665,
+ 198,
+ 1192,
+ 16,
+ 3909,
+ 198,
+ 1192,
+ 17,
+ 3909,
+ 198,
+ 54675,
+ 21324,
+ 198,
+ 151666,
+ 151645,
+ 198,
+ 151644,
+ 77091,
+ 198
+ ],
+ "generation_token_ids": [
+ 151667,
+ 198,
+ 32313,
+ 11,
+ 279,
+ 1196,
+ 4588,
+ 311,
+ 1140,
+ 3542,
+ 304,
+ 608,
+ 5173,
+ 1667,
+ 279,
+ 1598,
+ 880,
+ 988,
+ 729,
+ 13,
+ 358,
+ 2598,
+ 279,
+ 729,
+ 448,
+ 279,
+ 3210,
+ 330,
+ 4730,
+ 481,
+ 64,
+ 608,
+ 5173,
+ 497,
+ 892,
+ 1265,
+ 975,
+ 13,
+ 576,
+ 2033,
+ 504,
+ 279,
+ 5392,
+ 572,
+ 2326,
+ 3542,
+ 25,
+ 1034,
+ 16,
+ 3909,
+ 11,
+ 1034,
+ 17,
+ 3909,
+ 11,
+ 323,
+ 61945,
+ 21324,
+ 13,
+ 4695,
+ 358,
+ 1184,
+ 311,
+ 3042,
+ 419,
+ 1995,
+ 9355,
+ 311,
+ 279,
+ 1196,
+ 382,
+ 40,
+ 1265,
+ 7683,
+ 429,
+ 279,
+ 3542,
+ 1033,
+ 7790,
+ 10007,
+ 323,
+ 1140,
+ 1105,
+ 304,
+ 264,
+ 33798,
+ 3561,
+ 13,
+ 10696,
+ 6286,
+ 429,
+ 279,
+ 2550,
+ 4933,
+ 279,
+ 3542,
+ 3042,
+ 304,
+ 608,
+ 5173,
+ 13,
+ 7281,
+ 11,
+ 1779,
+ 421,
+ 1052,
+ 594,
+ 894,
+ 1184,
+ 311,
+ 10339,
+ 4623,
+ 11,
+ 714,
+ 2474,
+ 279,
+ 1196,
+ 1101,
+ 4588,
+ 311,
+ 1140,
+ 1105,
+ 11,
+ 264,
+ 30339,
+ 4226,
+ 1265,
+ 76156,
+ 13,
+ 7405,
+ 2704,
+ 279,
+ 2033,
+ 374,
+ 11657,
+ 323,
+ 10950,
+ 624,
+ 151668,
+ 271,
+ 8420,
+ 525,
+ 279,
+ 3542,
+ 304,
+ 37301,
+ 5173,
+ 63,
+ 1447,
+ 12,
+ 1034,
+ 16,
+ 3909,
+ 198,
+ 12,
+ 1034,
+ 17,
+ 3909,
+ 198,
+ 12,
+ 61945,
+ 21324,
+ 271,
+ 10061,
+ 752,
+ 1414,
+ 421,
+ 498,
+ 1184,
+ 4623,
+ 12994,
+ 0,
+ 151645
+ ],
+ "content": "\n\nHere are the files in `/tmp`:\n\n- file1.txt\n- file2.txt\n- README.md\n\nLet me know if you need further assistance!",
+ "reasoning_content": "\nOkay, the user asked to list files in /tmp using the run_bash function. I called the function with the command \"ls -a /tmp\", which should work. The response from the tool was three files: file1.txt, file2.txt, and README.md. Now I need to present this information clearly to the user.\n\nI should confirm that the files were successfully listed and list them in a readable format. Maybe mention that the output shows the files present in /tmp. Also, check if there's any need to explain further, but since the user just asked to list them, a straightforward answer should suffice. Make sure the response is friendly and helpful.\n",
+ "tool_calls": [],
+ "finish_reason": "stop"
+ },
+ {
+ "prompt_token_ids": [
+ 151644,
+ 8948,
+ 198,
+ 2,
+ 13852,
+ 271,
+ 2610,
+ 1231,
+ 1618,
+ 825,
+ 476,
+ 803,
+ 5746,
+ 311,
+ 7789,
+ 448,
+ 279,
+ 1196,
+ 3239,
+ 382,
+ 2610,
+ 525,
+ 3897,
+ 448,
+ 729,
+ 32628,
+ 2878,
+ 366,
+ 15918,
+ 1472,
+ 15918,
+ 29,
+ 11874,
+ 9492,
+ 510,
+ 27,
+ 15918,
+ 397,
+ 4913,
+ 1313,
+ 788,
+ 330,
+ 1688,
+ 497,
+ 330,
+ 1688,
+ 788,
+ 5212,
+ 606,
+ 788,
+ 330,
+ 6108,
+ 880,
+ 988,
+ 497,
+ 330,
+ 4684,
+ 788,
+ 330,
+ 6727,
+ 264,
+ 27023,
+ 3210,
+ 323,
+ 470,
+ 1181,
+ 20075,
+ 10465,
+ 330,
+ 13786,
+ 788,
+ 5212,
+ 1313,
+ 788,
+ 330,
+ 1700,
+ 497,
+ 330,
+ 13193,
+ 788,
+ 5212,
+ 5631,
+ 788,
+ 5212,
+ 1313,
+ 788,
+ 330,
+ 917,
+ 497,
+ 330,
+ 4684,
+ 788,
+ 330,
+ 785,
+ 27023,
+ 3210,
+ 311,
+ 1598,
+ 1189,
+ 38154,
+ 330,
+ 6279,
+ 788,
+ 4383,
+ 5631,
+ 1341,
+ 3417,
+ 532,
+ 522,
+ 15918,
+ 1339,
+ 2461,
+ 1817,
+ 729,
+ 1618,
+ 11,
+ 470,
+ 264,
+ 2951,
+ 1633,
+ 448,
+ 729,
+ 829,
+ 323,
+ 5977,
+ 2878,
+ 220,
+ 151657,
+ 151658,
+ 11874,
+ 9492,
+ 510,
+ 151657,
+ 198,
+ 4913,
+ 606,
+ 788,
+ 366,
+ 1688,
+ 11494,
+ 8066,
+ 330,
+ 16370,
+ 788,
+ 366,
+ 2116,
+ 56080,
+ 40432,
+ 31296,
+ 151658,
+ 151645,
+ 198,
+ 151644,
+ 872,
+ 198,
+ 852,
+ 279,
+ 3542,
+ 304,
+ 608,
+ 5173,
+ 1667,
+ 1598,
+ 880,
+ 988,
+ 13,
+ 151645,
+ 198,
+ 151644,
+ 77091,
+ 198,
+ 151667,
+ 198,
+ 32313,
+ 11,
+ 279,
+ 1196,
+ 6801,
+ 311,
+ 1140,
+ 279,
+ 3542,
+ 304,
+ 608,
+ 5173,
+ 1667,
+ 279,
+ 1598,
+ 880,
+ 988,
+ 729,
+ 13,
+ 6771,
+ 752,
+ 1779,
+ 279,
+ 2500,
+ 7375,
+ 13,
+ 576,
+ 3897,
+ 729,
+ 1598,
+ 880,
+ 988,
+ 4990,
+ 264,
+ 3210,
+ 323,
+ 4675,
+ 20075,
+ 13,
+ 2055,
+ 11,
+ 358,
+ 1184,
+ 311,
+ 9026,
+ 264,
+ 27023,
+ 3210,
+ 429,
+ 11469,
+ 3542,
+ 304,
+ 608,
+ 5173,
+ 382,
+ 785,
+ 5297,
+ 1616,
+ 311,
+ 1140,
+ 3542,
+ 304,
+ 264,
+ 6220,
+ 374,
+ 1667,
+ 279,
+ 19597,
+ 3210,
+ 13,
+ 1988,
+ 3783,
+ 11,
+ 279,
+ 1196,
+ 2578,
+ 537,
+ 614,
+ 279,
+ 19597,
+ 3210,
+ 10275,
+ 13,
+ 4354,
+ 11,
+ 279,
+ 729,
+ 594,
+ 4008,
+ 2727,
+ 432,
+ 8473,
+ 264,
+ 27023,
+ 3210,
+ 11,
+ 773,
+ 7196,
+ 432,
+ 646,
+ 3705,
+ 429,
+ 13,
+ 358,
+ 1265,
+ 990,
+ 279,
+ 19597,
+ 3210,
+ 448,
+ 481,
+ 64,
+ 311,
+ 1473,
+ 678,
+ 3542,
+ 11,
+ 2670,
+ 8177,
+ 6174,
+ 13,
+ 576,
+ 3210,
+ 1035,
+ 387,
+ 330,
+ 4730,
+ 481,
+ 64,
+ 608,
+ 5173,
+ 3263,
+ 4710,
+ 14190,
+ 11,
+ 714,
+ 7025,
+ 608,
+ 5173,
+ 2578,
+ 614,
+ 264,
+ 2696,
+ 315,
+ 3542,
+ 11,
+ 323,
+ 279,
+ 1196,
+ 2578,
+ 1366,
+ 311,
+ 1779,
+ 421,
+ 1052,
+ 525,
+ 894,
+ 4714,
+ 13,
+ 1988,
+ 279,
+ 1887,
+ 3383,
+ 374,
+ 311,
+ 1140,
+ 1105,
+ 13,
+ 2055,
+ 11,
+ 279,
+ 4396,
+ 3210,
+ 374,
+ 19597,
+ 481,
+ 64,
+ 608,
+ 5173,
+ 13,
+ 6771,
+ 752,
+ 1281,
+ 2704,
+ 1052,
+ 525,
+ 902,
+ 13580,
+ 966,
+ 13,
+ 7414,
+ 11,
+ 429,
+ 1265,
+ 975,
+ 13,
+ 2055,
+ 11,
+ 358,
+ 3278,
+ 1618,
+ 279,
+ 1598,
+ 880,
+ 988,
+ 729,
+ 448,
+ 429,
+ 3210,
+ 624,
+ 151668,
+ 271,
+ 151657,
+ 198,
+ 4913,
+ 606,
+ 788,
+ 330,
+ 6108,
+ 880,
+ 988,
+ 497,
+ 330,
+ 16370,
+ 788,
+ 5212,
+ 5631,
+ 788,
+ 330,
+ 4730,
+ 481,
+ 64,
+ 608,
+ 5173,
+ 95642,
+ 151658,
+ 151645,
+ 198,
+ 151644,
+ 872,
+ 198,
+ 151665,
+ 198,
+ 1192,
+ 16,
+ 3909,
+ 198,
+ 1192,
+ 17,
+ 3909,
+ 198,
+ 54675,
+ 21324,
+ 198,
+ 151666,
+ 151645,
+ 198,
+ 151644,
+ 77091,
+ 198,
+ 151667,
+ 198,
+ 32313,
+ 11,
+ 279,
+ 1196,
+ 4588,
+ 311,
+ 1140,
+ 3542,
+ 304,
+ 608,
+ 5173,
+ 1667,
+ 279,
+ 1598,
+ 880,
+ 988,
+ 729,
+ 13,
+ 358,
+ 2598,
+ 279,
+ 729,
+ 448,
+ 279,
+ 3210,
+ 330,
+ 4730,
+ 481,
+ 64,
+ 608,
+ 5173,
+ 497,
+ 892,
+ 1265,
+ 975,
+ 13,
+ 576,
+ 2033,
+ 504,
+ 279,
+ 5392,
+ 572,
+ 2326,
+ 3542,
+ 25,
+ 1034,
+ 16,
+ 3909,
+ 11,
+ 1034,
+ 17,
+ 3909,
+ 11,
+ 323,
+ 61945,
+ 21324,
+ 13,
+ 4695,
+ 358,
+ 1184,
+ 311,
+ 3042,
+ 419,
+ 1995,
+ 9355,
+ 311,
+ 279,
+ 1196,
+ 382,
+ 40,
+ 1265,
+ 7683,
+ 429,
+ 279,
+ 3542,
+ 1033,
+ 7790,
+ 10007,
+ 323,
+ 1140,
+ 1105,
+ 304,
+ 264,
+ 33798,
+ 3561,
+ 13,
+ 10696,
+ 6286,
+ 429,
+ 279,
+ 2550,
+ 4933,
+ 279,
+ 3542,
+ 3042,
+ 304,
+ 608,
+ 5173,
+ 13,
+ 7281,
+ 11,
+ 1779,
+ 421,
+ 1052,
+ 594,
+ 894,
+ 1184,
+ 311,
+ 10339,
+ 4623,
+ 11,
+ 714,
+ 2474,
+ 279,
+ 1196,
+ 1101,
+ 4588,
+ 311,
+ 1140,
+ 1105,
+ 11,
+ 264,
+ 30339,
+ 4226,
+ 1265,
+ 76156,
+ 13,
+ 7405,
+ 2704,
+ 279,
+ 2033,
+ 374,
+ 11657,
+ 323,
+ 10950,
+ 624,
+ 151668,
+ 271,
+ 8420,
+ 525,
+ 279,
+ 3542,
+ 304,
+ 37301,
+ 5173,
+ 63,
+ 1447,
+ 12,
+ 1034,
+ 16,
+ 3909,
+ 198,
+ 12,
+ 1034,
+ 17,
+ 3909,
+ 198,
+ 12,
+ 61945,
+ 21324,
+ 271,
+ 10061,
+ 752,
+ 1414,
+ 421,
+ 498,
+ 1184,
+ 4623,
+ 12994,
+ 0,
+ 151645,
+ 198,
+ 151644,
+ 872,
+ 198,
+ 26040,
+ 8098,
+ 264,
+ 5392,
+ 11,
+ 9851,
+ 448,
+ 6896,
+ 25,
+ 27357,
+ 13,
+ 151645,
+ 198,
+ 151644,
+ 77091,
+ 198
+ ],
+ "generation_token_ids": [
+ 151667,
+ 198,
+ 32313,
+ 11,
+ 279,
+ 1196,
+ 1101,
+ 4588,
+ 311,
+ 1140,
+ 3542,
+ 304,
+ 608,
+ 5173,
+ 323,
+ 358,
+ 3897,
+ 279,
+ 2550,
+ 13,
+ 4695,
+ 807,
+ 1366,
+ 752,
+ 311,
+ 9851,
+ 448,
+ 330,
+ 17453,
+ 1189,
+ 8704,
+ 807,
+ 3207,
+ 944,
+ 2548,
+ 369,
+ 4113,
+ 770,
+ 11,
+ 358,
+ 1265,
+ 7683,
+ 429,
+ 279,
+ 3383,
+ 374,
+ 4583,
+ 13,
+ 358,
+ 1184,
+ 311,
+ 1281,
+ 2704,
+ 279,
+ 2033,
+ 374,
+ 63594,
+ 323,
+ 9071,
+ 862,
+ 1681,
+ 13,
+ 2308,
+ 5392,
+ 6738,
+ 525,
+ 4362,
+ 1588,
+ 11,
+ 1101,
+ 264,
+ 4285,
+ 19539,
+ 13,
+ 151645
+ ],
+ "content": null,
+ "reasoning_content": "\nOkay, the user just asked to list files in /tmp and I provided the output. Now they want me to reply with \"Done.\" Since they didn't ask for anything else, I should confirm that the task is complete. I need to make sure the response is concise and matches their request. No tool calls are needed here, just a simple confirmation.",
+ "tool_calls": [],
+ "finish_reason": "stop"
+ }
+ ]
+ },
+ "qwen3_without_thinking": {
+ "turns": [
+ {
+ "prompt_token_ids": [
+ 151644,
+ 8948,
+ 198,
+ 2,
+ 13852,
+ 271,
+ 2610,
+ 1231,
+ 1618,
+ 825,
+ 476,
+ 803,
+ 5746,
+ 311,
+ 7789,
+ 448,
+ 279,
+ 1196,
+ 3239,
+ 382,
+ 2610,
+ 525,
+ 3897,
+ 448,
+ 729,
+ 32628,
+ 2878,
+ 366,
+ 15918,
+ 1472,
+ 15918,
+ 29,
+ 11874,
+ 9492,
+ 510,
+ 27,
+ 15918,
+ 397,
+ 4913,
+ 1313,
+ 788,
+ 330,
+ 1688,
+ 497,
+ 330,
+ 1688,
+ 788,
+ 5212,
+ 606,
+ 788,
+ 330,
+ 6108,
+ 880,
+ 988,
+ 497,
+ 330,
+ 4684,
+ 788,
+ 330,
+ 6727,
+ 264,
+ 27023,
+ 3210,
+ 323,
+ 470,
+ 1181,
+ 20075,
+ 10465,
+ 330,
+ 13786,
+ 788,
+ 5212,
+ 1313,
+ 788,
+ 330,
+ 1700,
+ 497,
+ 330,
+ 13193,
+ 788,
+ 5212,
+ 5631,
+ 788,
+ 5212,
+ 1313,
+ 788,
+ 330,
+ 917,
+ 497,
+ 330,
+ 4684,
+ 788,
+ 330,
+ 785,
+ 27023,
+ 3210,
+ 311,
+ 1598,
+ 1189,
+ 38154,
+ 330,
+ 6279,
+ 788,
+ 4383,
+ 5631,
+ 1341,
+ 3417,
+ 532,
+ 522,
+ 15918,
+ 1339,
+ 2461,
+ 1817,
+ 729,
+ 1618,
+ 11,
+ 470,
+ 264,
+ 2951,
+ 1633,
+ 448,
+ 729,
+ 829,
+ 323,
+ 5977,
+ 2878,
+ 220,
+ 151657,
+ 151658,
+ 11874,
+ 9492,
+ 510,
+ 151657,
+ 198,
+ 4913,
+ 606,
+ 788,
+ 366,
+ 1688,
+ 11494,
+ 8066,
+ 330,
+ 16370,
+ 788,
+ 366,
+ 2116,
+ 56080,
+ 40432,
+ 31296,
+ 151658,
+ 151645,
+ 198,
+ 151644,
+ 872,
+ 198,
+ 852,
+ 279,
+ 3542,
+ 304,
+ 608,
+ 5173,
+ 1667,
+ 1598,
+ 880,
+ 988,
+ 13,
+ 151645,
+ 198,
+ 151644,
+ 77091,
+ 198,
+ 151667,
+ 271,
+ 151668,
+ 271
+ ],
+ "generation_token_ids": [
+ 151657,
+ 198,
+ 4913,
+ 606,
+ 788,
+ 330,
+ 6108,
+ 880,
+ 988,
+ 497,
+ 330,
+ 16370,
+ 788,
+ 5212,
+ 5631,
+ 788,
+ 330,
+ 4730,
+ 608,
+ 5173,
+ 95642,
+ 151658,
+ 151645
+ ],
+ "content": null,
+ "reasoning_content": "",
+ "tool_calls": [
+ {
+ "function": {
+ "name": "run_bash",
+ "arguments": "{\"command\": \"ls /tmp\"}"
+ }
+ }
+ ],
+ "finish_reason": "tool_calls"
+ },
+ {
+ "prompt_token_ids": [
+ 151644,
+ 8948,
+ 198,
+ 2,
+ 13852,
+ 271,
+ 2610,
+ 1231,
+ 1618,
+ 825,
+ 476,
+ 803,
+ 5746,
+ 311,
+ 7789,
+ 448,
+ 279,
+ 1196,
+ 3239,
+ 382,
+ 2610,
+ 525,
+ 3897,
+ 448,
+ 729,
+ 32628,
+ 2878,
+ 366,
+ 15918,
+ 1472,
+ 15918,
+ 29,
+ 11874,
+ 9492,
+ 510,
+ 27,
+ 15918,
+ 397,
+ 4913,
+ 1313,
+ 788,
+ 330,
+ 1688,
+ 497,
+ 330,
+ 1688,
+ 788,
+ 5212,
+ 606,
+ 788,
+ 330,
+ 6108,
+ 880,
+ 988,
+ 497,
+ 330,
+ 4684,
+ 788,
+ 330,
+ 6727,
+ 264,
+ 27023,
+ 3210,
+ 323,
+ 470,
+ 1181,
+ 20075,
+ 10465,
+ 330,
+ 13786,
+ 788,
+ 5212,
+ 1313,
+ 788,
+ 330,
+ 1700,
+ 497,
+ 330,
+ 13193,
+ 788,
+ 5212,
+ 5631,
+ 788,
+ 5212,
+ 1313,
+ 788,
+ 330,
+ 917,
+ 497,
+ 330,
+ 4684,
+ 788,
+ 330,
+ 785,
+ 27023,
+ 3210,
+ 311,
+ 1598,
+ 1189,
+ 38154,
+ 330,
+ 6279,
+ 788,
+ 4383,
+ 5631,
+ 1341,
+ 3417,
+ 532,
+ 522,
+ 15918,
+ 1339,
+ 2461,
+ 1817,
+ 729,
+ 1618,
+ 11,
+ 470,
+ 264,
+ 2951,
+ 1633,
+ 448,
+ 729,
+ 829,
+ 323,
+ 5977,
+ 2878,
+ 220,
+ 151657,
+ 151658,
+ 11874,
+ 9492,
+ 510,
+ 151657,
+ 198,
+ 4913,
+ 606,
+ 788,
+ 366,
+ 1688,
+ 11494,
+ 8066,
+ 330,
+ 16370,
+ 788,
+ 366,
+ 2116,
+ 56080,
+ 40432,
+ 31296,
+ 151658,
+ 151645,
+ 198,
+ 151644,
+ 872,
+ 198,
+ 852,
+ 279,
+ 3542,
+ 304,
+ 608,
+ 5173,
+ 1667,
+ 1598,
+ 880,
+ 988,
+ 13,
+ 151645,
+ 198,
+ 151644,
+ 77091,
+ 198,
+ 151667,
+ 271,
+ 151668,
+ 271,
+ 151657,
+ 198,
+ 4913,
+ 606,
+ 788,
+ 330,
+ 6108,
+ 880,
+ 988,
+ 497,
+ 330,
+ 16370,
+ 788,
+ 5212,
+ 5631,
+ 788,
+ 330,
+ 4730,
+ 608,
+ 5173,
+ 95642,
+ 151658,
+ 151645,
+ 198,
+ 151644,
+ 872,
+ 198,
+ 151665,
+ 198,
+ 1192,
+ 16,
+ 3909,
+ 198,
+ 1192,
+ 17,
+ 3909,
+ 198,
+ 54675,
+ 21324,
+ 198,
+ 151666,
+ 151645,
+ 198,
+ 151644,
+ 77091,
+ 198,
+ 151667,
+ 271,
+ 151668,
+ 271
+ ],
+ "generation_token_ids": [
+ 785,
+ 3542,
+ 304,
+ 37301,
+ 5173,
+ 63,
+ 525,
+ 1447,
+ 16,
+ 13,
+ 1034,
+ 16,
+ 3909,
+ 198,
+ 17,
+ 13,
+ 1034,
+ 17,
+ 3909,
+ 198,
+ 18,
+ 13,
+ 61945,
+ 21324,
+ 151645
+ ],
+ "content": "The files in `/tmp` are:\n\n1. file1.txt\n2. file2.txt\n3. README.md",
+ "reasoning_content": "",
+ "tool_calls": [],
+ "finish_reason": "stop"
+ },
+ {
+ "prompt_token_ids": [
+ 151644,
+ 8948,
+ 198,
+ 2,
+ 13852,
+ 271,
+ 2610,
+ 1231,
+ 1618,
+ 825,
+ 476,
+ 803,
+ 5746,
+ 311,
+ 7789,
+ 448,
+ 279,
+ 1196,
+ 3239,
+ 382,
+ 2610,
+ 525,
+ 3897,
+ 448,
+ 729,
+ 32628,
+ 2878,
+ 366,
+ 15918,
+ 1472,
+ 15918,
+ 29,
+ 11874,
+ 9492,
+ 510,
+ 27,
+ 15918,
+ 397,
+ 4913,
+ 1313,
+ 788,
+ 330,
+ 1688,
+ 497,
+ 330,
+ 1688,
+ 788,
+ 5212,
+ 606,
+ 788,
+ 330,
+ 6108,
+ 880,
+ 988,
+ 497,
+ 330,
+ 4684,
+ 788,
+ 330,
+ 6727,
+ 264,
+ 27023,
+ 3210,
+ 323,
+ 470,
+ 1181,
+ 20075,
+ 10465,
+ 330,
+ 13786,
+ 788,
+ 5212,
+ 1313,
+ 788,
+ 330,
+ 1700,
+ 497,
+ 330,
+ 13193,
+ 788,
+ 5212,
+ 5631,
+ 788,
+ 5212,
+ 1313,
+ 788,
+ 330,
+ 917,
+ 497,
+ 330,
+ 4684,
+ 788,
+ 330,
+ 785,
+ 27023,
+ 3210,
+ 311,
+ 1598,
+ 1189,
+ 38154,
+ 330,
+ 6279,
+ 788,
+ 4383,
+ 5631,
+ 1341,
+ 3417,
+ 532,
+ 522,
+ 15918,
+ 1339,
+ 2461,
+ 1817,
+ 729,
+ 1618,
+ 11,
+ 470,
+ 264,
+ 2951,
+ 1633,
+ 448,
+ 729,
+ 829,
+ 323,
+ 5977,
+ 2878,
+ 220,
+ 151657,
+ 151658,
+ 11874,
+ 9492,
+ 510,
+ 151657,
+ 198,
+ 4913,
+ 606,
+ 788,
+ 366,
+ 1688,
+ 11494,
+ 8066,
+ 330,
+ 16370,
+ 788,
+ 366,
+ 2116,
+ 56080,
+ 40432,
+ 31296,
+ 151658,
+ 151645,
+ 198,
+ 151644,
+ 872,
+ 198,
+ 852,
+ 279,
+ 3542,
+ 304,
+ 608,
+ 5173,
+ 1667,
+ 1598,
+ 880,
+ 988,
+ 13,
+ 151645,
+ 198,
+ 151644,
+ 77091,
+ 198,
+ 151667,
+ 271,
+ 151668,
+ 271,
+ 151657,
+ 198,
+ 4913,
+ 606,
+ 788,
+ 330,
+ 6108,
+ 880,
+ 988,
+ 497,
+ 330,
+ 16370,
+ 788,
+ 5212,
+ 5631,
+ 788,
+ 330,
+ 4730,
+ 608,
+ 5173,
+ 95642,
+ 151658,
+ 151645,
+ 198,
+ 151644,
+ 872,
+ 198,
+ 151665,
+ 198,
+ 1192,
+ 16,
+ 3909,
+ 198,
+ 1192,
+ 17,
+ 3909,
+ 198,
+ 54675,
+ 21324,
+ 198,
+ 151666,
+ 151645,
+ 198,
+ 151644,
+ 77091,
+ 198,
+ 151667,
+ 271,
+ 151668,
+ 271,
+ 785,
+ 3542,
+ 304,
+ 37301,
+ 5173,
+ 63,
+ 525,
+ 1447,
+ 16,
+ 13,
+ 1034,
+ 16,
+ 3909,
+ 198,
+ 17,
+ 13,
+ 1034,
+ 17,
+ 3909,
+ 198,
+ 18,
+ 13,
+ 61945,
+ 21324,
+ 151645,
+ 198,
+ 151644,
+ 872,
+ 198,
+ 26040,
+ 8098,
+ 264,
+ 5392,
+ 11,
+ 9851,
+ 448,
+ 6896,
+ 25,
+ 27357,
+ 13,
+ 151645,
+ 198,
+ 151644,
+ 77091,
+ 198,
+ 151667,
+ 271,
+ 151668,
+ 271
+ ],
+ "generation_token_ids": [
+ 17453,
+ 13,
+ 151645
+ ],
+ "content": "Done.",
+ "reasoning_content": "",
+ "tool_calls": [],
+ "finish_reason": "stop"
+ }
+ ]
+ },
+ "deepseek_r1_with_thinking": {
+ "turns": [
+ {
+ "prompt_token_ids": [
+ 151644,
+ 8948,
+ 198,
+ 2,
+ 13852,
+ 271,
+ 2610,
+ 1231,
+ 1618,
+ 825,
+ 476,
+ 803,
+ 5746,
+ 311,
+ 7789,
+ 448,
+ 279,
+ 1196,
+ 3239,
+ 382,
+ 2610,
+ 525,
+ 3897,
+ 448,
+ 729,
+ 32628,
+ 2878,
+ 366,
+ 15918,
+ 1472,
+ 15918,
+ 29,
+ 11874,
+ 9492,
+ 510,
+ 27,
+ 15918,
+ 397,
+ 4913,
+ 1313,
+ 788,
+ 330,
+ 1688,
+ 497,
+ 330,
+ 1688,
+ 788,
+ 5212,
+ 606,
+ 788,
+ 330,
+ 6108,
+ 880,
+ 988,
+ 497,
+ 330,
+ 4684,
+ 788,
+ 330,
+ 6727,
+ 264,
+ 27023,
+ 3210,
+ 323,
+ 470,
+ 1181,
+ 20075,
+ 10465,
+ 330,
+ 13786,
+ 788,
+ 5212,
+ 1313,
+ 788,
+ 330,
+ 1700,
+ 497,
+ 330,
+ 13193,
+ 788,
+ 5212,
+ 5631,
+ 788,
+ 5212,
+ 1313,
+ 788,
+ 330,
+ 917,
+ 497,
+ 330,
+ 4684,
+ 788,
+ 330,
+ 785,
+ 27023,
+ 3210,
+ 311,
+ 1598,
+ 1189,
+ 38154,
+ 330,
+ 6279,
+ 788,
+ 4383,
+ 5631,
+ 1341,
+ 3417,
+ 532,
+ 522,
+ 15918,
+ 1339,
+ 2461,
+ 1817,
+ 729,
+ 1618,
+ 11,
+ 470,
+ 264,
+ 2951,
+ 1633,
+ 448,
+ 729,
+ 829,
+ 323,
+ 5977,
+ 2878,
+ 220,
+ 151657,
+ 151658,
+ 11874,
+ 9492,
+ 510,
+ 151657,
+ 198,
+ 4913,
+ 606,
+ 788,
+ 366,
+ 1688,
+ 11494,
+ 8066,
+ 330,
+ 16370,
+ 788,
+ 366,
+ 2116,
+ 56080,
+ 40432,
+ 31296,
+ 151658,
+ 151645,
+ 198,
+ 151644,
+ 872,
+ 198,
+ 852,
+ 279,
+ 3542,
+ 304,
+ 608,
+ 5173,
+ 1667,
+ 1598,
+ 880,
+ 988,
+ 13,
+ 151645,
+ 198,
+ 151644,
+ 77091,
+ 198
+ ],
+ "generation_token_ids": [
+ 151667,
+ 198,
+ 32313,
+ 11,
+ 279,
+ 1196,
+ 6801,
+ 311,
+ 1140,
+ 279,
+ 3542,
+ 304,
+ 608,
+ 5173,
+ 1667,
+ 279,
+ 1598,
+ 880,
+ 988,
+ 729,
+ 13,
+ 6771,
+ 752,
+ 1779,
+ 279,
+ 2500,
+ 7375,
+ 13,
+ 576,
+ 3897,
+ 729,
+ 1598,
+ 880,
+ 988,
+ 4990,
+ 264,
+ 3210,
+ 323,
+ 4675,
+ 20075,
+ 13,
+ 2055,
+ 11,
+ 358,
+ 1184,
+ 311,
+ 9026,
+ 264,
+ 27023,
+ 3210,
+ 429,
+ 11469,
+ 3542,
+ 304,
+ 608,
+ 5173,
+ 382,
+ 785,
+ 5297,
+ 1616,
+ 311,
+ 1140,
+ 3542,
+ 304,
+ 264,
+ 6220,
+ 374,
+ 1667,
+ 279,
+ 19597,
+ 3210,
+ 13,
+ 1988,
+ 3783,
+ 11,
+ 279,
+ 1196,
+ 2578,
+ 537,
+ 614,
+ 279,
+ 19597,
+ 3210,
+ 10275,
+ 13,
+ 4354,
+ 11,
+ 279,
+ 729,
+ 594,
+ 4008,
+ 2727,
+ 432,
+ 8473,
+ 264,
+ 27023,
+ 3210,
+ 11,
+ 773,
+ 7196,
+ 432,
+ 646,
+ 3705,
+ 429,
+ 13,
+ 358,
+ 1265,
+ 990,
+ 279,
+ 19597,
+ 3210,
+ 448,
+ 481,
+ 64,
+ 311,
+ 1473,
+ 678,
+ 3542,
+ 11,
+ 2670,
+ 8177,
+ 6174,
+ 13,
+ 576,
+ 3210,
+ 1035,
+ 387,
+ 330,
+ 4730,
+ 481,
+ 64,
+ 608,
+ 5173,
+ 3263,
+ 4710,
+ 14190,
+ 11,
+ 714,
+ 7025,
+ 608,
+ 5173,
+ 2578,
+ 614,
+ 264,
+ 2696,
+ 315,
+ 3542,
+ 11,
+ 323,
+ 279,
+ 1196,
+ 2578,
+ 1366,
+ 311,
+ 1779,
+ 421,
+ 1052,
+ 525,
+ 894,
+ 4714,
+ 13,
+ 1988,
+ 279,
+ 1887,
+ 3383,
+ 374,
+ 311,
+ 1140,
+ 1105,
+ 13,
+ 2055,
+ 11,
+ 279,
+ 4396,
+ 3210,
+ 374,
+ 19597,
+ 481,
+ 64,
+ 608,
+ 5173,
+ 13,
+ 6771,
+ 752,
+ 1281,
+ 2704,
+ 1052,
+ 525,
+ 902,
+ 13580,
+ 966,
+ 13,
+ 7414,
+ 11,
+ 429,
+ 1265,
+ 975,
+ 13,
+ 2055,
+ 11,
+ 358,
+ 3278,
+ 1618,
+ 279,
+ 1598,
+ 880,
+ 988,
+ 729,
+ 448,
+ 429,
+ 3210,
+ 624,
+ 151668,
+ 271,
+ 151657,
+ 198,
+ 4913,
+ 606,
+ 788,
+ 330,
+ 6108,
+ 880,
+ 988,
+ 497,
+ 330,
+ 16370,
+ 788,
+ 5212,
+ 5631,
+ 788,
+ 330,
+ 4730,
+ 481,
+ 64,
+ 608,
+ 5173,
+ 95642,
+ 151658,
+ 151645
+ ],
+ "content": null,
+ "reasoning_content": "\nOkay, the user wants to list the files in /tmp using the run_bash function. Let me check the available tools. The provided function run_bash takes a command and returns stdout. So, I need to execute a bash command that lists files in /tmp.\n\nThe standard way to list files in a directory is using the ls command. But wait, the user might not have the ls command installed. However, the function's description says it runs a bash command, so maybe it can handle that. I should use the ls command with -a to show all files, including hidden ones. The command would be \"ls -a /tmp\". \n\nWait, but sometimes /tmp might have a lot of files, and the user might want to check if there are any issues. But the main task is to list them. So, the correct command is ls -a /tmp. Let me make sure there are no typos. Yes, that should work. So, I'll call the run_bash function with that command.\n",
+ "tool_calls": [
+ {
+ "function": {
+ "name": "run_bash",
+ "arguments": "{\"command\": \"ls -a /tmp\"}"
+ }
+ }
+ ],
+ "finish_reason": "tool_calls"
+ },
+ {
+ "prompt_token_ids": [
+ 151644,
+ 8948,
+ 198,
+ 2,
+ 13852,
+ 271,
+ 2610,
+ 1231,
+ 1618,
+ 825,
+ 476,
+ 803,
+ 5746,
+ 311,
+ 7789,
+ 448,
+ 279,
+ 1196,
+ 3239,
+ 382,
+ 2610,
+ 525,
+ 3897,
+ 448,
+ 729,
+ 32628,
+ 2878,
+ 366,
+ 15918,
+ 1472,
+ 15918,
+ 29,
+ 11874,
+ 9492,
+ 510,
+ 27,
+ 15918,
+ 397,
+ 4913,
+ 1313,
+ 788,
+ 330,
+ 1688,
+ 497,
+ 330,
+ 1688,
+ 788,
+ 5212,
+ 606,
+ 788,
+ 330,
+ 6108,
+ 880,
+ 988,
+ 497,
+ 330,
+ 4684,
+ 788,
+ 330,
+ 6727,
+ 264,
+ 27023,
+ 3210,
+ 323,
+ 470,
+ 1181,
+ 20075,
+ 10465,
+ 330,
+ 13786,
+ 788,
+ 5212,
+ 1313,
+ 788,
+ 330,
+ 1700,
+ 497,
+ 330,
+ 13193,
+ 788,
+ 5212,
+ 5631,
+ 788,
+ 5212,
+ 1313,
+ 788,
+ 330,
+ 917,
+ 497,
+ 330,
+ 4684,
+ 788,
+ 330,
+ 785,
+ 27023,
+ 3210,
+ 311,
+ 1598,
+ 1189,
+ 38154,
+ 330,
+ 6279,
+ 788,
+ 4383,
+ 5631,
+ 1341,
+ 3417,
+ 532,
+ 522,
+ 15918,
+ 1339,
+ 2461,
+ 1817,
+ 729,
+ 1618,
+ 11,
+ 470,
+ 264,
+ 2951,
+ 1633,
+ 448,
+ 729,
+ 829,
+ 323,
+ 5977,
+ 2878,
+ 220,
+ 151657,
+ 151658,
+ 11874,
+ 9492,
+ 510,
+ 151657,
+ 198,
+ 4913,
+ 606,
+ 788,
+ 366,
+ 1688,
+ 11494,
+ 8066,
+ 330,
+ 16370,
+ 788,
+ 366,
+ 2116,
+ 56080,
+ 40432,
+ 31296,
+ 151658,
+ 151645,
+ 198,
+ 151644,
+ 872,
+ 198,
+ 852,
+ 279,
+ 3542,
+ 304,
+ 608,
+ 5173,
+ 1667,
+ 1598,
+ 880,
+ 988,
+ 13,
+ 151645,
+ 198,
+ 151644,
+ 77091,
+ 198,
+ 151667,
+ 198,
+ 32313,
+ 11,
+ 279,
+ 1196,
+ 6801,
+ 311,
+ 1140,
+ 279,
+ 3542,
+ 304,
+ 608,
+ 5173,
+ 1667,
+ 279,
+ 1598,
+ 880,
+ 988,
+ 729,
+ 13,
+ 6771,
+ 752,
+ 1779,
+ 279,
+ 2500,
+ 7375,
+ 13,
+ 576,
+ 3897,
+ 729,
+ 1598,
+ 880,
+ 988,
+ 4990,
+ 264,
+ 3210,
+ 323,
+ 4675,
+ 20075,
+ 13,
+ 2055,
+ 11,
+ 358,
+ 1184,
+ 311,
+ 9026,
+ 264,
+ 27023,
+ 3210,
+ 429,
+ 11469,
+ 3542,
+ 304,
+ 608,
+ 5173,
+ 382,
+ 785,
+ 5297,
+ 1616,
+ 311,
+ 1140,
+ 3542,
+ 304,
+ 264,
+ 6220,
+ 374,
+ 1667,
+ 279,
+ 19597,
+ 3210,
+ 13,
+ 1988,
+ 3783,
+ 11,
+ 279,
+ 1196,
+ 2578,
+ 537,
+ 614,
+ 279,
+ 19597,
+ 3210,
+ 10275,
+ 13,
+ 4354,
+ 11,
+ 279,
+ 729,
+ 594,
+ 4008,
+ 2727,
+ 432,
+ 8473,
+ 264,
+ 27023,
+ 3210,
+ 11,
+ 773,
+ 7196,
+ 432,
+ 646,
+ 3705,
+ 429,
+ 13,
+ 358,
+ 1265,
+ 990,
+ 279,
+ 19597,
+ 3210,
+ 448,
+ 481,
+ 64,
+ 311,
+ 1473,
+ 678,
+ 3542,
+ 11,
+ 2670,
+ 8177,
+ 6174,
+ 13,
+ 576,
+ 3210,
+ 1035,
+ 387,
+ 330,
+ 4730,
+ 481,
+ 64,
+ 608,
+ 5173,
+ 3263,
+ 4710,
+ 14190,
+ 11,
+ 714,
+ 7025,
+ 608,
+ 5173,
+ 2578,
+ 614,
+ 264,
+ 2696,
+ 315,
+ 3542,
+ 11,
+ 323,
+ 279,
+ 1196,
+ 2578,
+ 1366,
+ 311,
+ 1779,
+ 421,
+ 1052,
+ 525,
+ 894,
+ 4714,
+ 13,
+ 1988,
+ 279,
+ 1887,
+ 3383,
+ 374,
+ 311,
+ 1140,
+ 1105,
+ 13,
+ 2055,
+ 11,
+ 279,
+ 4396,
+ 3210,
+ 374,
+ 19597,
+ 481,
+ 64,
+ 608,
+ 5173,
+ 13,
+ 6771,
+ 752,
+ 1281,
+ 2704,
+ 1052,
+ 525,
+ 902,
+ 13580,
+ 966,
+ 13,
+ 7414,
+ 11,
+ 429,
+ 1265,
+ 975,
+ 13,
+ 2055,
+ 11,
+ 358,
+ 3278,
+ 1618,
+ 279,
+ 1598,
+ 880,
+ 988,
+ 729,
+ 448,
+ 429,
+ 3210,
+ 624,
+ 151668,
+ 271,
+ 151657,
+ 198,
+ 4913,
+ 606,
+ 788,
+ 330,
+ 6108,
+ 880,
+ 988,
+ 497,
+ 330,
+ 16370,
+ 788,
+ 5212,
+ 5631,
+ 788,
+ 330,
+ 4730,
+ 481,
+ 64,
+ 608,
+ 5173,
+ 95642,
+ 151658,
+ 151645,
+ 198,
+ 151644,
+ 872,
+ 198,
+ 151665,
+ 198,
+ 1192,
+ 16,
+ 3909,
+ 198,
+ 1192,
+ 17,
+ 3909,
+ 198,
+ 54675,
+ 21324,
+ 198,
+ 151666,
+ 151645,
+ 198,
+ 151644,
+ 77091,
+ 198
+ ],
+ "generation_token_ids": [
+ 151667,
+ 198,
+ 32313,
+ 11,
+ 279,
+ 1196,
+ 4588,
+ 311,
+ 1140,
+ 3542,
+ 304,
+ 608,
+ 5173,
+ 1667,
+ 279,
+ 1598,
+ 880,
+ 988,
+ 729,
+ 13,
+ 358,
+ 2598,
+ 279,
+ 729,
+ 448,
+ 279,
+ 3210,
+ 330,
+ 4730,
+ 481,
+ 64,
+ 608,
+ 5173,
+ 497,
+ 892,
+ 1265,
+ 975,
+ 13,
+ 576,
+ 2033,
+ 504,
+ 279,
+ 5392,
+ 572,
+ 2326,
+ 3542,
+ 25,
+ 1034,
+ 16,
+ 3909,
+ 11,
+ 1034,
+ 17,
+ 3909,
+ 11,
+ 323,
+ 61945,
+ 21324,
+ 13,
+ 4695,
+ 358,
+ 1184,
+ 311,
+ 3042,
+ 419,
+ 1995,
+ 9355,
+ 311,
+ 279,
+ 1196,
+ 382,
+ 40,
+ 1265,
+ 7683,
+ 429,
+ 279,
+ 3542,
+ 1033,
+ 7790,
+ 10007,
+ 323,
+ 1140,
+ 1105,
+ 304,
+ 264,
+ 33798,
+ 3561,
+ 13,
+ 10696,
+ 6286,
+ 429,
+ 279,
+ 2550,
+ 4933,
+ 279,
+ 3542,
+ 3042,
+ 304,
+ 608,
+ 5173,
+ 13,
+ 7281,
+ 11,
+ 1779,
+ 421,
+ 1052,
+ 594,
+ 894,
+ 1184,
+ 311,
+ 10339,
+ 4623,
+ 11,
+ 714,
+ 2474,
+ 279,
+ 1196,
+ 1101,
+ 4588,
+ 311,
+ 1140,
+ 1105,
+ 11,
+ 264,
+ 30339,
+ 4226,
+ 1265,
+ 76156,
+ 13,
+ 7405,
+ 2704,
+ 279,
+ 2033,
+ 374,
+ 11657,
+ 323,
+ 10950,
+ 624,
+ 151668,
+ 271,
+ 8420,
+ 525,
+ 279,
+ 3542,
+ 304,
+ 37301,
+ 5173,
+ 63,
+ 1447,
+ 12,
+ 1034,
+ 16,
+ 3909,
+ 198,
+ 12,
+ 1034,
+ 17,
+ 3909,
+ 198,
+ 12,
+ 61945,
+ 21324,
+ 271,
+ 10061,
+ 752,
+ 1414,
+ 421,
+ 498,
+ 1184,
+ 4623,
+ 12994,
+ 0,
+ 151645
+ ],
+ "content": "\n\nHere are the files in `/tmp`:\n\n- file1.txt\n- file2.txt\n- README.md\n\nLet me know if you need further assistance!",
+ "reasoning_content": "\nOkay, the user asked to list files in /tmp using the run_bash function. I called the function with the command \"ls -a /tmp\", which should work. The response from the tool was three files: file1.txt, file2.txt, and README.md. Now I need to present this information clearly to the user.\n\nI should confirm that the files were successfully listed and list them in a readable format. Maybe mention that the output shows the files present in /tmp. Also, check if there's any need to explain further, but since the user just asked to list them, a straightforward answer should suffice. Make sure the response is friendly and helpful.\n",
+ "tool_calls": [],
+ "finish_reason": "stop"
+ },
+ {
+ "prompt_token_ids": [
+ 151644,
+ 8948,
+ 198,
+ 2,
+ 13852,
+ 271,
+ 2610,
+ 1231,
+ 1618,
+ 825,
+ 476,
+ 803,
+ 5746,
+ 311,
+ 7789,
+ 448,
+ 279,
+ 1196,
+ 3239,
+ 382,
+ 2610,
+ 525,
+ 3897,
+ 448,
+ 729,
+ 32628,
+ 2878,
+ 366,
+ 15918,
+ 1472,
+ 15918,
+ 29,
+ 11874,
+ 9492,
+ 510,
+ 27,
+ 15918,
+ 397,
+ 4913,
+ 1313,
+ 788,
+ 330,
+ 1688,
+ 497,
+ 330,
+ 1688,
+ 788,
+ 5212,
+ 606,
+ 788,
+ 330,
+ 6108,
+ 880,
+ 988,
+ 497,
+ 330,
+ 4684,
+ 788,
+ 330,
+ 6727,
+ 264,
+ 27023,
+ 3210,
+ 323,
+ 470,
+ 1181,
+ 20075,
+ 10465,
+ 330,
+ 13786,
+ 788,
+ 5212,
+ 1313,
+ 788,
+ 330,
+ 1700,
+ 497,
+ 330,
+ 13193,
+ 788,
+ 5212,
+ 5631,
+ 788,
+ 5212,
+ 1313,
+ 788,
+ 330,
+ 917,
+ 497,
+ 330,
+ 4684,
+ 788,
+ 330,
+ 785,
+ 27023,
+ 3210,
+ 311,
+ 1598,
+ 1189,
+ 38154,
+ 330,
+ 6279,
+ 788,
+ 4383,
+ 5631,
+ 1341,
+ 3417,
+ 532,
+ 522,
+ 15918,
+ 1339,
+ 2461,
+ 1817,
+ 729,
+ 1618,
+ 11,
+ 470,
+ 264,
+ 2951,
+ 1633,
+ 448,
+ 729,
+ 829,
+ 323,
+ 5977,
+ 2878,
+ 220,
+ 151657,
+ 151658,
+ 11874,
+ 9492,
+ 510,
+ 151657,
+ 198,
+ 4913,
+ 606,
+ 788,
+ 366,
+ 1688,
+ 11494,
+ 8066,
+ 330,
+ 16370,
+ 788,
+ 366,
+ 2116,
+ 56080,
+ 40432,
+ 31296,
+ 151658,
+ 151645,
+ 198,
+ 151644,
+ 872,
+ 198,
+ 852,
+ 279,
+ 3542,
+ 304,
+ 608,
+ 5173,
+ 1667,
+ 1598,
+ 880,
+ 988,
+ 13,
+ 151645,
+ 198,
+ 151644,
+ 77091,
+ 198,
+ 151667,
+ 198,
+ 32313,
+ 11,
+ 279,
+ 1196,
+ 6801,
+ 311,
+ 1140,
+ 279,
+ 3542,
+ 304,
+ 608,
+ 5173,
+ 1667,
+ 279,
+ 1598,
+ 880,
+ 988,
+ 729,
+ 13,
+ 6771,
+ 752,
+ 1779,
+ 279,
+ 2500,
+ 7375,
+ 13,
+ 576,
+ 3897,
+ 729,
+ 1598,
+ 880,
+ 988,
+ 4990,
+ 264,
+ 3210,
+ 323,
+ 4675,
+ 20075,
+ 13,
+ 2055,
+ 11,
+ 358,
+ 1184,
+ 311,
+ 9026,
+ 264,
+ 27023,
+ 3210,
+ 429,
+ 11469,
+ 3542,
+ 304,
+ 608,
+ 5173,
+ 382,
+ 785,
+ 5297,
+ 1616,
+ 311,
+ 1140,
+ 3542,
+ 304,
+ 264,
+ 6220,
+ 374,
+ 1667,
+ 279,
+ 19597,
+ 3210,
+ 13,
+ 1988,
+ 3783,
+ 11,
+ 279,
+ 1196,
+ 2578,
+ 537,
+ 614,
+ 279,
+ 19597,
+ 3210,
+ 10275,
+ 13,
+ 4354,
+ 11,
+ 279,
+ 729,
+ 594,
+ 4008,
+ 2727,
+ 432,
+ 8473,
+ 264,
+ 27023,
+ 3210,
+ 11,
+ 773,
+ 7196,
+ 432,
+ 646,
+ 3705,
+ 429,
+ 13,
+ 358,
+ 1265,
+ 990,
+ 279,
+ 19597,
+ 3210,
+ 448,
+ 481,
+ 64,
+ 311,
+ 1473,
+ 678,
+ 3542,
+ 11,
+ 2670,
+ 8177,
+ 6174,
+ 13,
+ 576,
+ 3210,
+ 1035,
+ 387,
+ 330,
+ 4730,
+ 481,
+ 64,
+ 608,
+ 5173,
+ 3263,
+ 4710,
+ 14190,
+ 11,
+ 714,
+ 7025,
+ 608,
+ 5173,
+ 2578,
+ 614,
+ 264,
+ 2696,
+ 315,
+ 3542,
+ 11,
+ 323,
+ 279,
+ 1196,
+ 2578,
+ 1366,
+ 311,
+ 1779,
+ 421,
+ 1052,
+ 525,
+ 894,
+ 4714,
+ 13,
+ 1988,
+ 279,
+ 1887,
+ 3383,
+ 374,
+ 311,
+ 1140,
+ 1105,
+ 13,
+ 2055,
+ 11,
+ 279,
+ 4396,
+ 3210,
+ 374,
+ 19597,
+ 481,
+ 64,
+ 608,
+ 5173,
+ 13,
+ 6771,
+ 752,
+ 1281,
+ 2704,
+ 1052,
+ 525,
+ 902,
+ 13580,
+ 966,
+ 13,
+ 7414,
+ 11,
+ 429,
+ 1265,
+ 975,
+ 13,
+ 2055,
+ 11,
+ 358,
+ 3278,
+ 1618,
+ 279,
+ 1598,
+ 880,
+ 988,
+ 729,
+ 448,
+ 429,
+ 3210,
+ 624,
+ 151668,
+ 271,
+ 151657,
+ 198,
+ 4913,
+ 606,
+ 788,
+ 330,
+ 6108,
+ 880,
+ 988,
+ 497,
+ 330,
+ 16370,
+ 788,
+ 5212,
+ 5631,
+ 788,
+ 330,
+ 4730,
+ 481,
+ 64,
+ 608,
+ 5173,
+ 95642,
+ 151658,
+ 151645,
+ 198,
+ 151644,
+ 872,
+ 198,
+ 151665,
+ 198,
+ 1192,
+ 16,
+ 3909,
+ 198,
+ 1192,
+ 17,
+ 3909,
+ 198,
+ 54675,
+ 21324,
+ 198,
+ 151666,
+ 151645,
+ 198,
+ 151644,
+ 77091,
+ 198,
+ 151667,
+ 198,
+ 32313,
+ 11,
+ 279,
+ 1196,
+ 4588,
+ 311,
+ 1140,
+ 3542,
+ 304,
+ 608,
+ 5173,
+ 1667,
+ 279,
+ 1598,
+ 880,
+ 988,
+ 729,
+ 13,
+ 358,
+ 2598,
+ 279,
+ 729,
+ 448,
+ 279,
+ 3210,
+ 330,
+ 4730,
+ 481,
+ 64,
+ 608,
+ 5173,
+ 497,
+ 892,
+ 1265,
+ 975,
+ 13,
+ 576,
+ 2033,
+ 504,
+ 279,
+ 5392,
+ 572,
+ 2326,
+ 3542,
+ 25,
+ 1034,
+ 16,
+ 3909,
+ 11,
+ 1034,
+ 17,
+ 3909,
+ 11,
+ 323,
+ 61945,
+ 21324,
+ 13,
+ 4695,
+ 358,
+ 1184,
+ 311,
+ 3042,
+ 419,
+ 1995,
+ 9355,
+ 311,
+ 279,
+ 1196,
+ 382,
+ 40,
+ 1265,
+ 7683,
+ 429,
+ 279,
+ 3542,
+ 1033,
+ 7790,
+ 10007,
+ 323,
+ 1140,
+ 1105,
+ 304,
+ 264,
+ 33798,
+ 3561,
+ 13,
+ 10696,
+ 6286,
+ 429,
+ 279,
+ 2550,
+ 4933,
+ 279,
+ 3542,
+ 3042,
+ 304,
+ 608,
+ 5173,
+ 13,
+ 7281,
+ 11,
+ 1779,
+ 421,
+ 1052,
+ 594,
+ 894,
+ 1184,
+ 311,
+ 10339,
+ 4623,
+ 11,
+ 714,
+ 2474,
+ 279,
+ 1196,
+ 1101,
+ 4588,
+ 311,
+ 1140,
+ 1105,
+ 11,
+ 264,
+ 30339,
+ 4226,
+ 1265,
+ 76156,
+ 13,
+ 7405,
+ 2704,
+ 279,
+ 2033,
+ 374,
+ 11657,
+ 323,
+ 10950,
+ 624,
+ 151668,
+ 271,
+ 8420,
+ 525,
+ 279,
+ 3542,
+ 304,
+ 37301,
+ 5173,
+ 63,
+ 1447,
+ 12,
+ 1034,
+ 16,
+ 3909,
+ 198,
+ 12,
+ 1034,
+ 17,
+ 3909,
+ 198,
+ 12,
+ 61945,
+ 21324,
+ 271,
+ 10061,
+ 752,
+ 1414,
+ 421,
+ 498,
+ 1184,
+ 4623,
+ 12994,
+ 0,
+ 151645,
+ 198,
+ 151644,
+ 872,
+ 198,
+ 26040,
+ 8098,
+ 264,
+ 5392,
+ 11,
+ 9851,
+ 448,
+ 6896,
+ 25,
+ 27357,
+ 13,
+ 151645,
+ 198,
+ 151644,
+ 77091,
+ 198
+ ],
+ "generation_token_ids": [
+ 151667,
+ 198,
+ 32313,
+ 11,
+ 279,
+ 1196,
+ 1101,
+ 4588,
+ 311,
+ 1140,
+ 3542,
+ 304,
+ 608,
+ 5173,
+ 323,
+ 358,
+ 3897,
+ 279,
+ 2550,
+ 13,
+ 4695,
+ 807,
+ 1366,
+ 752,
+ 311,
+ 9851,
+ 448,
+ 330,
+ 17453,
+ 1189,
+ 8704,
+ 807,
+ 3207,
+ 944,
+ 2548,
+ 369,
+ 4113,
+ 770,
+ 11,
+ 358,
+ 1265,
+ 7683,
+ 429,
+ 279,
+ 3383,
+ 374,
+ 4583,
+ 13,
+ 358,
+ 1184,
+ 311,
+ 1281,
+ 2704,
+ 279,
+ 2033,
+ 374,
+ 63594,
+ 323,
+ 9071,
+ 862,
+ 1681,
+ 13,
+ 2308,
+ 5392,
+ 6738,
+ 525,
+ 4362,
+ 1588,
+ 11,
+ 1101,
+ 264,
+ 4285,
+ 19539,
+ 13,
+ 151645
+ ],
+ "content": null,
+ "reasoning_content": "\nOkay, the user just asked to list files in /tmp and I provided the output. Now they want me to reply with \"Done.\" Since they didn't ask for anything else, I should confirm that the task is complete. I need to make sure the response is concise and matches their request. No tool calls are needed here, just a simple confirmation.",
+ "tool_calls": [],
+ "finish_reason": "stop"
+ }
+ ]
+ }
+ }
+}
diff --git a/tests/unit/models/generation/trtllm/test_trtllm_http_server.py b/tests/unit/models/generation/trtllm/test_trtllm_http_server.py
index fa5824d66ee..6053b300dc1 100644
--- a/tests/unit/models/generation/trtllm/test_trtllm_http_server.py
+++ b/tests/unit/models/generation/trtllm/test_trtllm_http_server.py
@@ -24,6 +24,7 @@
_compute_splice_inputs,
_make_parse_tool_calls,
_resolve_tool_parser_name,
+ _tokens_for_response_text,
)
@@ -97,6 +98,23 @@ def test_http_sampling_params_map_null_top_k_and_empty_stop_tokens():
)
+def test_response_text_excludes_stops_without_mutating_training_tokens():
+ generation_token_ids = [10, 20, 2, 3]
+
+ text_token_ids = _tokens_for_response_text(generation_token_ids, {2, 3})
+
+ assert text_token_ids == [10, 20]
+ assert generation_token_ids == [10, 20, 2, 3]
+
+
+def test_response_text_reuses_unterminated_generation_tokens():
+ generation_token_ids = [10, 20]
+
+ assert (
+ _tokens_for_response_text(generation_token_ids, {2, 3}) is generation_token_ids
+ )
+
+
def test_explicit_tool_parser_overrides_model_auto_resolution():
assert _resolve_tool_parser_name("qwen3_coder", "/missing/model") == "qwen3_coder"
diff --git a/tests/unit/models/generation/trtllm/test_trtllm_http_server_parity.py b/tests/unit/models/generation/trtllm/test_trtllm_http_server_parity.py
new file mode 100644
index 00000000000..4f37a993f6e
--- /dev/null
+++ b/tests/unit/models/generation/trtllm/test_trtllm_http_server_parity.py
@@ -0,0 +1,514 @@
+# Copyright (c) 2026, NVIDIA CORPORATION. All rights reserved.
+#
+# 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.
+"""TRT-LLM chat-completions parity against the vLLM-generated golden."""
+
+import json
+import multiprocessing
+import os
+import queue
+import sys
+import time
+import traceback
+from collections.abc import Iterator
+from importlib import metadata
+from pathlib import Path
+from typing import Any
+
+import pytest
+import requests
+
+from nemo_rl.models.generation.trtllm.trtllm_http_server import (
+ _build_reasoning_parser,
+ _build_tool_parser,
+ _make_parse_tool_calls,
+)
+from tests.unit.models.generation.chat_template_parity_common import (
+ FOLLOWUP_USER_MSG,
+ GENERATION_TOKEN_IDS_FIELD,
+ MODEL,
+ MODEL_REVISION,
+ PARSER_SCENARIOS,
+ REASONING_PARSER_CONTRACT_CASES,
+ TOOL_CALL_ID,
+ TOOL_DEF,
+ TOOL_PARSER_CONTRACT_CASES,
+ TOOL_RESULT,
+ USER_MSG,
+ inclusive_token_span,
+ prompt_suffix_after_turn,
+ token_edit_similarity,
+)
+
+pytestmark = pytest.mark.trtllm
+
+GOLDEN_PATH = Path(__file__).parent / "fixtures" / "chat_template_parity_golden.json"
+
+TRTLLM_REASONING_PARSERS = {
+ "qwen3": "qwen3",
+ "deepseek_r1": "deepseek-r1",
+}
+
+SERVER_PROCESS_START_TIMEOUT = 600
+SERVER_PROCESS_STOP_TIMEOUT = 60
+
+
+def _wait_for_server(base_url: str, timeout: int = 180) -> None:
+ health_url = base_url.rstrip("/").removesuffix("/v1") + "/health"
+ deadline = time.time() + timeout
+ while time.time() < deadline:
+ try:
+ if requests.get(health_url, timeout=3).status_code == 200:
+ return
+ except Exception:
+ pass
+ time.sleep(2)
+ raise TimeoutError(f"TRT-LLM server at {base_url} did not start within {timeout}s")
+
+
+def _extract_fields(response_json: dict[str, Any]) -> dict[str, Any]:
+ choice = response_json["choices"][0]
+ msg = choice["message"]
+ tool_calls = [
+ {
+ "function": {
+ "name": tc["function"]["name"],
+ "arguments": tc["function"]["arguments"],
+ }
+ }
+ for tc in (msg.get("tool_calls") or [])
+ ]
+ generation_token_ids = msg["generation_token_ids"]
+ generation_log_probs = msg["generation_log_probs"]
+ return {
+ "prompt_token_ids": msg["prompt_token_ids"],
+ "generation_token_ids": generation_token_ids,
+ "content": msg.get("content"),
+ "reasoning_content": msg.get("reasoning_content") or "",
+ "tool_calls": tool_calls,
+ "finish_reason": choice["finish_reason"],
+ }
+
+
+def _run_scenario(base_url: str, enable_thinking: bool) -> list[dict[str, Any]]:
+ template_kwargs = {"enable_thinking": enable_thinking}
+ base = base_url.rstrip("/")
+
+ body1 = {
+ "model": MODEL,
+ "messages": [{"role": "user", "content": USER_MSG}],
+ "tools": [TOOL_DEF],
+ "temperature": 0.0,
+ "top_p": 1.0,
+ "max_tokens": 512,
+ "chat_template_kwargs": template_kwargs,
+ }
+ r1 = requests.post(f"{base}/chat/completions", json=body1, timeout=180)
+ r1.raise_for_status()
+ resp1 = r1.json()
+ turn1 = _extract_fields(resp1)
+ assert turn1["finish_reason"] == "tool_calls", (
+ f"Turn 1 expected finish_reason='tool_calls', got {turn1['finish_reason']!r}.\n"
+ f"content: {resp1['choices'][0]['message'].get('content')}"
+ )
+
+ raw_msg1 = resp1["choices"][0]["message"]
+ normalized_tool_calls = json.loads(json.dumps(raw_msg1["tool_calls"]))
+ assert len(normalized_tool_calls) == 1
+ normalized_tool_calls[0]["id"] = TOOL_CALL_ID
+ asst_msg = {
+ "role": "assistant",
+ "content": raw_msg1.get("content"),
+ "tool_calls": normalized_tool_calls,
+ "prompt_token_ids": raw_msg1["prompt_token_ids"],
+ "generation_token_ids": raw_msg1["generation_token_ids"],
+ "generation_log_probs": raw_msg1["generation_log_probs"],
+ }
+ tool_result_msg = {
+ "role": "tool",
+ "tool_call_id": TOOL_CALL_ID,
+ "content": TOOL_RESULT,
+ }
+
+ body2 = {
+ "model": MODEL,
+ "messages": [{"role": "user", "content": USER_MSG}, asst_msg, tool_result_msg],
+ "tools": [TOOL_DEF],
+ "temperature": 0.0,
+ "top_p": 1.0,
+ "max_tokens": 512,
+ "chat_template_kwargs": template_kwargs,
+ }
+ r2 = requests.post(f"{base}/chat/completions", json=body2, timeout=180)
+ r2.raise_for_status()
+ resp2 = r2.json()
+ turn2 = _extract_fields(resp2)
+ required_prefix = turn1["prompt_token_ids"] + turn1["generation_token_ids"]
+ assert turn2["prompt_token_ids"][: len(required_prefix)] == required_prefix, (
+ "TRT-LLM turn-two prompt does not preserve the exact turn-one model prefix"
+ )
+ assert turn2["content"] is not None and not turn2["tool_calls"], (
+ "Turn 2 must be a normal assistant answer before the user follow-up"
+ )
+
+ raw_msg2 = resp2["choices"][0]["message"]
+ asst_msg2 = {
+ "role": "assistant",
+ "content": raw_msg2.get("content"),
+ "prompt_token_ids": turn2["prompt_token_ids"],
+ "generation_token_ids": turn2["generation_token_ids"],
+ "generation_log_probs": raw_msg2["generation_log_probs"],
+ }
+ body3 = {
+ "model": MODEL,
+ "messages": [
+ {"role": "user", "content": USER_MSG},
+ asst_msg,
+ tool_result_msg,
+ asst_msg2,
+ {"role": "user", "content": FOLLOWUP_USER_MSG},
+ ],
+ "tools": [TOOL_DEF],
+ "temperature": 0.0,
+ "top_p": 1.0,
+ "max_tokens": 512,
+ "chat_template_kwargs": template_kwargs,
+ }
+ r3 = requests.post(f"{base}/chat/completions", json=body3, timeout=180)
+ assert r3.ok, f"TRT-LLM turn-three request failed: {r3.text}"
+ turn3 = _extract_fields(r3.json())
+ required_prefix = turn2["prompt_token_ids"] + turn2["generation_token_ids"]
+ assert turn3["prompt_token_ids"][: len(required_prefix)] == required_prefix, (
+ "TRT-LLM turn-three prompt does not preserve the latest assistant prefix"
+ )
+ return [turn1, turn2, turn3]
+
+
+def _load_golden(reasoning_parser: str) -> dict[str, Any]:
+ assert GOLDEN_PATH.exists(), (
+ "Golden missing; regenerate it with the vLLM parity test"
+ )
+ golden = json.loads(GOLDEN_PATH.read_text())
+ assert golden.get("source_backend") == "vllm", (
+ "Golden was not generated by vLLM; regenerate it with the vLLM parity test"
+ )
+ assert golden.get("model") == MODEL, (
+ f"Golden is for {golden.get('model')!r}, not {MODEL!r}; regenerate it"
+ )
+ assert golden.get("model_revision") == MODEL_REVISION, (
+ "Golden model revision does not match; regenerate it"
+ )
+ for scenario_name, _ in PARSER_SCENARIOS[reasoning_parser]:
+ assert scenario_name in golden.get("scenarios", {}), (
+ f"Scenario {scenario_name!r} missing from golden; regenerate it"
+ )
+ return golden
+
+
+def _sanitize_trtllm_child_environment() -> None:
+ """Remove scheduler launch state only inside the TRT-LLM server process."""
+ for key in tuple(os.environ):
+ if key.startswith(("PMIX_", "PMI_", "MPI_", "OMPI_", "SLURM_")):
+ os.environ.pop(key, None)
+
+
+def _import_trtllm_llm() -> Any:
+ """Import TRT-LLM, recovering an editable install inside the child only."""
+ try:
+ from tensorrt_llm import LLM
+
+ return LLM
+ except (ImportError, ModuleNotFoundError):
+ dist = metadata.distribution("tensorrt-llm")
+ direct_url_text = dist.read_text("direct_url.json")
+ source_path = (
+ (json.loads(direct_url_text) if direct_url_text else {})
+ .get("url", "")
+ .removeprefix("file://")
+ )
+ if not source_path:
+ raise
+ sys.path.insert(0, source_path)
+ for module_name in tuple(sys.modules):
+ if module_name == "tensorrt_llm" or module_name.startswith("tensorrt_llm."):
+ del sys.modules[module_name]
+
+ from tensorrt_llm import LLM
+
+ return LLM
+
+
+def _run_trtllm_server_process(
+ reasoning_parser: str, startup_queue: Any, stop_event: Any
+) -> None:
+ """Own the TRT-LLM engine and HTTP server in an isolated child process."""
+ llm = None
+ server = None
+ server_thread = None
+ startup_reported = False
+ try:
+ _sanitize_trtllm_child_environment()
+ llm_cls = _import_trtllm_llm()
+
+ from tensorrt_llm.llmapi import KvCacheConfig
+ from transformers import AutoTokenizer
+
+ from nemo_rl.models.generation.trtllm.trtllm_http_server import start_server
+
+ tokenizer = AutoTokenizer.from_pretrained(MODEL, revision=MODEL_REVISION)
+ llm = llm_cls(
+ model=MODEL,
+ revision=MODEL_REVISION,
+ tokenizer_revision=MODEL_REVISION,
+ tensor_parallel_size=1,
+ max_num_tokens=2048,
+ kv_cache_config=KvCacheConfig(),
+ )
+ server_thread, base_url, server = start_server(
+ llm=llm,
+ tokenizer=tokenizer,
+ model_name=MODEL,
+ max_seq_len=4096,
+ sampling_config={"temperature": 0.0, "top_p": 1.0, "top_k": None},
+ tool_parser="qwen3",
+ reasoning_parser=TRTLLM_REASONING_PARSERS[reasoning_parser],
+ )
+ startup_queue.put(("ready", base_url))
+ startup_reported = True
+
+ while not stop_event.wait(0.2):
+ if not server_thread.is_alive():
+ raise RuntimeError("TRT-LLM HTTP server thread exited unexpectedly")
+ except BaseException:
+ if not startup_reported:
+ startup_queue.put(("error", traceback.format_exc()))
+ raise
+ finally:
+ if server is not None:
+ server.should_exit = True
+ server_stopped = True
+ if server_thread is not None:
+ server_thread.join(timeout=30)
+ server_stopped = not server_thread.is_alive()
+ if llm is not None:
+ llm.shutdown()
+ if not server_stopped:
+ raise RuntimeError("TRT-LLM HTTP server thread did not stop cleanly")
+
+
+def _stop_server_process(process: Any, stop_event: Any) -> None:
+ """Stop the owned server process, escalating only if graceful exit stalls."""
+ stop_event.set()
+ process.join(timeout=SERVER_PROCESS_STOP_TIMEOUT)
+ if process.is_alive():
+ process.terminate()
+ process.join(timeout=10)
+ if process.is_alive():
+ process.kill()
+ process.join(timeout=10)
+
+
+@pytest.fixture(scope="module")
+def tokenizer():
+ from transformers import AutoTokenizer
+
+ return AutoTokenizer.from_pretrained(MODEL, revision=MODEL_REVISION)
+
+
+@pytest.fixture(scope="module", params=tuple(PARSER_SCENARIOS))
+def trtllm_server(
+ request: pytest.FixtureRequest, tokenizer
+) -> Iterator[tuple[str, str, list[int], list[int]]]:
+ reasoning_parser = request.param
+ _load_golden(reasoning_parser)
+
+ process_context = multiprocessing.get_context("spawn")
+ startup_queue = process_context.Queue()
+ stop_event = process_context.Event()
+ process = process_context.Process(
+ target=_run_trtllm_server_process,
+ args=(reasoning_parser, startup_queue, stop_event),
+ name=f"trtllm-parity-{reasoning_parser}",
+ )
+
+ process_started = False
+ try:
+ process.start()
+ process_started = True
+ try:
+ status, payload = startup_queue.get(timeout=SERVER_PROCESS_START_TIMEOUT)
+ except queue.Empty:
+ pytest.fail(
+ "TRT-LLM server process did not report startup within "
+ f"{SERVER_PROCESS_START_TIMEOUT}s",
+ pytrace=False,
+ )
+ if status == "error":
+ pytest.fail(f"TRT-LLM server process failed:\n{payload}", pytrace=False)
+
+ base_url = payload
+ _wait_for_server(base_url)
+ tool_call_start = tokenizer.encode("", add_special_tokens=False)
+ tool_call_end = tokenizer.encode("", add_special_tokens=False)
+ yield reasoning_parser, base_url, tool_call_start, tool_call_end
+ finally:
+ if process_started:
+ _stop_server_process(process, stop_event)
+ startup_queue.close()
+ startup_queue.join_thread()
+
+ assert process_started and process.exitcode == 0, (
+ f"TRT-LLM server process exited with code {process.exitcode}"
+ )
+
+
+def _parse_with_trtllm(
+ *,
+ raw_output: str,
+ reasoning_parser: str | None,
+ enable_thinking: bool,
+ reasoning_at_start: bool,
+) -> dict[str, Any]:
+ reasoning_content = ""
+ content = raw_output
+ if reasoning_parser is not None:
+ parser = _build_reasoning_parser(
+ TRTLLM_REASONING_PARSERS[reasoning_parser],
+ {"enable_thinking": enable_thinking},
+ reasoning_at_start=reasoning_at_start,
+ )
+ parsed = parser.parse(raw_output)
+ reasoning_content = parsed.reasoning_content or ""
+ content = parsed.content
+
+ parse_tool_calls = _make_parse_tool_calls(_build_tool_parser("qwen3"))
+ content, tool_calls = parse_tool_calls(content, [TOOL_DEF])
+ normalized_calls = []
+ for call in tool_calls:
+ arguments = call["function"]["arguments"]
+ normalized_calls.append(
+ {
+ "name": call["function"]["name"],
+ "arguments": (
+ json.loads(arguments) if isinstance(arguments, str) else arguments
+ ),
+ }
+ )
+
+ return {
+ "reasoning_content": reasoning_content,
+ "content": content or None if normalized_calls else content,
+ "tool_calls": normalized_calls,
+ }
+
+
+@pytest.mark.parametrize("reasoning_parser", tuple(TRTLLM_REASONING_PARSERS))
+def test_reasoning_parser_contracts(reasoning_parser: str) -> None:
+ for case in REASONING_PARSER_CONTRACT_CASES:
+ actual = _parse_with_trtllm(
+ raw_output=case["raw_output"],
+ reasoning_parser=reasoning_parser,
+ enable_thinking=case["enable_thinking"],
+ reasoning_at_start=case["reasoning_at_start"],
+ )
+ assert actual == case["expected"], "TRT-LLM %s reasoning contract %r failed" % (
+ reasoning_parser,
+ case["name"],
+ )
+
+
+def test_tool_parser_contracts() -> None:
+ for case in TOOL_PARSER_CONTRACT_CASES:
+ actual = _parse_with_trtllm(
+ raw_output=case["raw_output"],
+ reasoning_parser=None,
+ enable_thinking=False,
+ reasoning_at_start=False,
+ )
+ actual_norm = {**actual, "content": (actual["content"] or "").strip() or None}
+ assert actual_norm == case["expected"], (
+ "TRT-LLM qwen3 tool contract %r failed" % case["name"]
+ )
+
+
+def test_parity(
+ trtllm_server: tuple[str, str, list[int], list[int]],
+) -> None:
+ reasoning_parser, base_url, tool_call_start, tool_call_end = trtllm_server
+ golden = _load_golden(reasoning_parser)
+
+ for scenario_name, enable_thinking in PARSER_SCENARIOS[reasoning_parser]:
+ actual_turns = _run_scenario(base_url, enable_thinking)
+
+ expected_turns = golden["scenarios"][scenario_name]["turns"]
+ assert len(actual_turns) == len(expected_turns)
+
+ assert (
+ actual_turns[0]["prompt_token_ids"] == expected_turns[0]["prompt_token_ids"]
+ ), f"scenario={scenario_name!r}: turn-one engine prompt mismatch"
+
+ actual_tool_call_ids = inclusive_token_span(
+ actual_turns[0][GENERATION_TOKEN_IDS_FIELD],
+ tool_call_start,
+ tool_call_end,
+ )
+ expected_tool_call_ids = inclusive_token_span(
+ expected_turns[0][GENERATION_TOKEN_IDS_FIELD],
+ tool_call_start,
+ tool_call_end,
+ )
+ similarity = token_edit_similarity(actual_tool_call_ids, expected_tool_call_ids)
+ assert similarity >= 0.9, (
+ f"scenario={scenario_name!r}: turn-one tool-call token similarity "
+ f"{similarity:.3f} is below the 0.900 parity threshold"
+ )
+ print(
+ f"[PARITY] scenario={scenario_name!r}: turn-one tool-call token "
+ f"similarity={similarity:.3f} "
+ f"(TRT-LLM tokens={len(actual_tool_call_ids)}, "
+ f"vLLM tokens={len(expected_tool_call_ids)})"
+ )
+ print(
+ "[PARITY_OUTPUT] "
+ + json.dumps(
+ {
+ "scenario": scenario_name,
+ "tool_call_token_similarity": similarity,
+ "trtllm_tool_call_token_ids": actual_tool_call_ids,
+ "vllm_tool_call_token_ids": expected_tool_call_ids,
+ "trtllm": actual_turns[0],
+ "vllm": expected_turns[0],
+ },
+ indent=2,
+ sort_keys=True,
+ )
+ )
+ for turn_index in range(len(actual_turns) - 1):
+ assert prompt_suffix_after_turn(
+ actual_turns, turn_index
+ ) == prompt_suffix_after_turn(expected_turns, turn_index), (
+ f"scenario={scenario_name!r}: transition {turn_index + 1}->"
+ f"{turn_index + 2} appended prompt suffix mismatch"
+ )
+
+ if enable_thinking:
+ assert actual_turns[0]["reasoning_content"], (
+ f"scenario={scenario_name!r}: reasoning parser was not exercised"
+ )
+ assert (
+ not actual_turns[0]["reasoning_content"].lstrip().startswith("")
+ ), f"scenario={scenario_name!r}: reasoning marker leaked into response"
+ else:
+ assert not actual_turns[0]["reasoning_content"], (
+ f"scenario={scenario_name!r}: reasoning leaked while thinking was disabled"
+ )