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" + )