Compare commits

...
Author SHA1 Message Date
Andreas KaratzasandGitHub 78d2334aab Merge branch 'main' into codex/rocm-artifact-tensorizer 2026-06-18 14:28:51 -05:00
Wentao YeandGitHub b53b1c7ffe [Model Runner V2] Migration to support quantized model by default [5/N] (#44446)
Signed-off-by: yewentao256 <zhyanwentao@126.com>
2026-06-18 12:20:44 -07:00
Ting SUNGitHubmergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
79ca54d221 [Bugfix][Quantization] Don't reject fp8_e5m2 KV cache for non-fp8 quantized checkpoints (#45040)
Signed-off-by: Ting Sun <suntcrick@gmail.com>
Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
2026-06-18 14:18:25 -04:00
Ben BrowningandGitHub 09f3cd5c10 [Bugfix] [Parser] Fix Qwen3 latent bug in partial params dropping values containing < (#46047)
Signed-off-by: Ben Browning <bbrownin@redhat.com>
2026-06-18 18:04:06 +00:00
ea6078fe6a [KV Connector][Offloading] Disable parallel-agnostic fs-tier cache on V2 model runner (#46044)
Signed-off-by: Itay Etelis <etelis2019@gmail.com>
Co-authored-by: Itay Etelis <etelis2019@gmail.com>
2026-06-18 20:43:35 +03:00
Palaiologos1453andGitHub a0df04e477 [Tests] Add Qwen3 streaming parser delta boundary cases (#45708)
Signed-off-by: test test <2260891073@qq.com>
2026-06-18 17:37:39 +00:00
stefankoncarevicandGitHub e2352c2974 [ROCm][Spec Decode] Fix probabilistic draft probs test attention backend (#45706)
Signed-off-by: Stefan Koncarevic <stefan.koncarevic@amd.com>
2026-06-18 11:59:37 -05:00
qli88andGitHub 25faa1f4cc [CI]Enable mxfp4 lora test for ROCm platform (#43802)
Signed-off-by: Qiang Li <qiang.li2@amd.com>
2026-06-18 16:59:09 +00:00
HumphreyandGitHub 4583630b56 [Bugfix][Kernel] Check output alignment in vectorize_with_alignment (fixes misaligned-address crash for non-multiple-of-8 head sizes) (#45466)
Signed-off-by: HumphreySun98 <humphreysun98@gmail.com>
2026-06-18 16:58:22 +00:00
Divakar VermaandGitHub 21da47dabe [ROCm][CI] move lora%N test to mi300 and gate (#45970)
Signed-off-by: Divakar Verma <divakar.verma@amd.com>
2026-06-19 00:50:32 +08:00
ChaunceyGitHubmergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
6c379b9e54 [Frontend] Add Streaming Parser Engine and new GLM4.7/GLM5.1/GLM5.2 Parser (#45915)
Signed-off-by: chaunceyjiang <chaunceyjiang@gmail.com>
Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
2026-06-19 00:42:10 +08:00
Rohan PotdarandGitHub 5099474633 [Bugfix][ROCm] Fix rocm_aiter_per_tensor_quant custom op aliasing (#45747)
Signed-off-by: Rohan138 <rohanpotdar138@gmail.com>
2026-06-18 11:30:21 -05:00
Yuwen ZhouandGitHub 058cc0a8b6 [Bugfix] Restore is_sym guard for zp in GPTQ/CT MoE to fix symmetric quant regression (#45656)
Signed-off-by: yuwenzho <yuwen.zhou@intel.com>
2026-06-18 16:20:29 +00:00
837db7605e [Bugfix][Tool Parser] Handle non-finite numbers in coerce_to_schema_type (#43984)
Signed-off-by: ashishpatel26 <shriganesh.patel@gmail.com>
Co-authored-by: Ben Browning <bbrownin@redhat.com>
2026-06-18 16:00:20 +00:00
Andreas Karatzas 7b375c8502 Fix ROCm artifact test dependencies
Signed-off-by: Andreas Karatzas <akaratza@amd.com>
2026-06-04 21:30:21 -05:00
39 changed files with 1069 additions and 2023 deletions
+14 -16
View File
@@ -415,22 +415,6 @@ steps:
commands:
- pytest -v -s kernels/mamba
#----------------------------------------------------------- mi250 · lora ------------------------------------------------------------#
- label: LoRA %N # TBD
timeout_in_minutes: 180
mirror_hardwares: [amdexperimental, amdproduction, amdgfx90anightly, amdmi250]
agent_pool: mi250_1
parallelism: 4
optional: true
working_dir: "/vllm-workspace/tests"
source_file_dependencies:
- vllm/lora
- tests/lora
- vllm/platforms/rocm.py
commands:
- pytest -v -s lora --shard-id=$$BUILDKITE_PARALLEL_JOB --num-shards=$$BUILDKITE_PARALLEL_JOB_COUNT --ignore=lora/test_chatglm3_tp.py --ignore=lora/test_llama_tp.py --ignore=lora/test_qwen3_with_multi_loras.py --ignore=lora/test_olmoe_tp.py --ignore=lora/test_deepseekv2_tp.py --ignore=lora/test_gptoss_tp.py --ignore=lora/test_qwen3moe_tp.py --ignore=lora/test_qwen35_densemodel_lora.py
#------------------------------------------------------ mi250 · models / basic -------------------------------------------------------#
- label: Basic Models Test (Other CPU) # TBD
@@ -1699,6 +1683,20 @@ steps:
#----------------------------------------------------------- mi300 · lora ------------------------------------------------------------#
- label: LoRA %N # TBD
timeout_in_minutes: 180
mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300]
agent_pool: mi300_1
parallelism: 4
optional: true
working_dir: "/vllm-workspace/tests"
source_file_dependencies:
- vllm/lora
- tests/lora
- vllm/platforms/rocm.py
commands:
- pytest -v -s lora --shard-id=$$BUILDKITE_PARALLEL_JOB --num-shards=$$BUILDKITE_PARALLEL_JOB_COUNT --ignore=lora/test_chatglm3_tp.py --ignore=lora/test_llama_tp.py --ignore=lora/test_qwen3_with_multi_loras.py --ignore=lora/test_olmoe_tp.py --ignore=lora/test_deepseekv2_tp.py --ignore=lora/test_gptoss_tp.py --ignore=lora/test_qwen3moe_tp.py --ignore=lora/test_qwen35_densemodel_lora.py
- label: LoRA TP (Distributed) # TBD
timeout_in_minutes: 180
mirror_hardwares: [amdexperimental, amdproduction, amdgfx942nightly, amdmi300]
+11
View File
@@ -12,6 +12,17 @@ steps:
commands:
- pytest -v -s lora --shard-id=$$BUILDKITE_PARALLEL_JOB --num-shards=$$BUILDKITE_PARALLEL_JOB_COUNT --ignore=lora/test_chatglm3_tp.py --ignore=lora/test_llama_tp.py --ignore=lora/test_qwen3_with_multi_loras.py --ignore=lora/test_olmoe_tp.py --ignore=lora/test_deepseekv2_tp.py --ignore=lora/test_gptoss_tp.py --ignore=lora/test_qwen3moe_tp.py --ignore=lora/test_qwen35_densemodel_lora.py
parallelism: 4
mirror:
amd:
device: mi325_1
working_dir: "/vllm-workspace/tests"
timeout_in_minutes: 60
source_file_dependencies:
- vllm/lora
- tests/lora
- vllm/platforms/rocm.py
depends_on:
- image-build-amd
- label: LoRA TP (Distributed)
+6
View File
@@ -21,6 +21,12 @@ steps:
- export VLLM_WORKER_MULTIPROC_METHOD=spawn
# TODO: create another `optional` test group for slow tests
- pytest -v -s -m 'not slow_test' v1/spec_decode
mirror:
amd:
device: mi300_1
timeout_in_minutes: 65
depends_on:
- image-build-amd
- label: V1 Sample + Logits
key: v1-sample-logits
@@ -24,13 +24,21 @@ __device__ inline void vectorize_with_alignment(
ScaOp&& scalar_op) { // InT -> OutT
static_assert(VEC_SIZE > 0 && (VEC_SIZE & (VEC_SIZE - 1)) == 0,
"VEC_SIZE must be a positive power-of-two");
constexpr int WIDTH = VEC_SIZE * sizeof(InT); // eg: 64 B
constexpr int WIDTH = VEC_SIZE * sizeof(InT); // eg: 16 B
constexpr int OUT_WIDTH = VEC_SIZE * sizeof(OutT); // eg: 16 B
uintptr_t addr = reinterpret_cast<uintptr_t>(in);
uintptr_t out_addr = reinterpret_cast<uintptr_t>(out);
// fast path when the whole region is already aligned
// Note: currently the output is guaranteed to be same as the input, so we
// don't check it here, comments here just for future reference.
bool can_vec = ((addr & (WIDTH - 1)) == 0) && ((len & (VEC_SIZE - 1)) == 0);
// fast path when input and output are both fully aligned. The vector
// load/store below go through vec_n_t<T, VEC_SIZE>, declared
// __align__(VEC_SIZE * sizeof(T)), so each side must be aligned to its
// own vector width. out is NOT generally co-aligned with in: e.g.
// reshape_and_cache_flash writes KV-cache rows whose byte offset is a
// multiple of head_size, which for head sizes that are not a multiple
// of VEC_SIZE puts some rows off the vector-width boundary.
bool can_vec = ((addr & (WIDTH - 1)) == 0) &&
((out_addr & (OUT_WIDTH - 1)) == 0) &&
((len & (VEC_SIZE - 1)) == 0);
if (can_vec) {
int num_vec = len / VEC_SIZE;
@@ -55,6 +63,16 @@ __device__ inline void vectorize_with_alignment(
prefix_elems /= sizeof(InT);
prefix_elems = min(prefix_elems, len); // 0 ≤ prefix < 16
// the prefix below aligns in; if that does not also align out (their
// addresses differ modulo the vector width), vectorizing is impossible
// and the whole copy must stay scalar.
if (((out_addr + prefix_elems * sizeof(OutT)) & (OUT_WIDTH - 1)) != 0) {
for (int i = tid; i < len; i += stride) {
scalar_op(out[i], in[i]);
}
return;
}
// 1. prefill the when it is unsafe to vectorize
for (int i = tid; i < prefix_elems; i += stride) {
scalar_op(out[i], in[i]);
+1 -1
View File
@@ -11,7 +11,7 @@ numba == 0.65.0 # Required for N-gram speculative decoding
datasets
peft
pytest-asyncio
tensorizer==2.10.1
tensorizer==2.12.1
packaging>=24.2
setuptools>=77.0.3,<80.0.0
setuptools-scm>=8
+1 -1
View File
@@ -1,6 +1,6 @@
# testing
pytest
tensorizer==2.10.1
tensorizer==2.12.1
pytest-forked
pytest-asyncio
pytest-rerunfailures
+1 -1
View File
@@ -966,7 +966,7 @@ tenacity==9.1.2
# gpt-oss
# lm-eval
# plotly
tensorizer==2.10.1
tensorizer==2.12.1
# via -r requirements/test/cuda.in
termcolor==3.1.0
# via gpt-oss
+1 -1
View File
@@ -1,6 +1,6 @@
# testing
pytest
tensorizer==2.10.1
tensorizer==2.12.1
pytest-forked
pytest-asyncio
pytest-rerunfailures
+1 -1
View File
@@ -2,7 +2,7 @@
# testing
pytest
tensorizer==2.10.1
tensorizer==2.12.1
pytest-forked
pytest-asyncio
pytest-rerunfailures
+1 -1
View File
@@ -1182,7 +1182,7 @@ tenacity==9.1.4
# via
# gpt-oss
# lm-eval
tensorizer==2.10.1
tensorizer==2.12.1
# via
# -c requirements/rocm.txt
# -r requirements/test/rocm.in
+37
View File
@@ -428,6 +428,43 @@ def test_reshape_and_cache_flash(
torch.testing.assert_close(value_cache_compact, cloned_value_cache)
@pytest.mark.parametrize("dtype", DTYPES)
@pytest.mark.parametrize("kv_cache_dtype", KV_CACHE_DTYPE)
@pytest.mark.parametrize("kv_cache_layout", CACHE_LAYOUTS)
@pytest.mark.parametrize("implementation", RESHAPE_FLASH_IMPLEMENTATIONS)
@torch.inference_mode()
def test_reshape_and_cache_flash_unaligned_rows(
kv_cache_factory_flashinfer,
dtype: torch.dtype,
kv_cache_dtype: str,
kv_cache_layout: str,
implementation: str,
) -> None:
"""Regression test for https://github.com/vllm-project/vllm/issues/41257.
head_size=46 with num_heads=13 places KV-cache rows at byte offsets
that are not a multiple of the vector width (NHD row pitch
13*46*itemsize, HND head pitch 46*itemsize), unlike HEAD_SIZES above
which are all 16-byte multiples. The CUDA kernel used to issue
vectorized stores to those rows -> CUDA misaligned address.
"""
test_reshape_and_cache_flash(
kv_cache_factory_flashinfer,
num_tokens=42,
num_heads=13,
head_size=46,
block_size=16,
num_blocks=128,
dtype=dtype,
seed=0,
device=CUDA_DEVICES[0],
kv_cache_dtype=kv_cache_dtype,
kv_cache_layout=kv_cache_layout,
kv_scale_type="tensor",
implementation=implementation,
)
@pytest.mark.parametrize("direction", COPYING_DIRECTION)
@pytest.mark.parametrize("num_mappings", NUM_MAPPINGS)
@pytest.mark.parametrize("num_heads", NUM_HEADS)
+25 -11
View File
@@ -70,17 +70,20 @@ def generate_and_test(llm: vllm.LLM, lora_path: str, lora_id: int) -> None:
assert generated_texts[i].startswith(EXPECTED_LORA_OUTPUT[i])
@pytest.mark.skipif(
not current_platform.is_cuda(),
reason=(
"Mxfp4 LoRA on ROCm is blocked by a spawn compatibility issue. "
"The fused_moe_lora Triton kernel crashes in spawned subprocesses, "
"and vLLM forces spawn mode when HIP is initialized before "
"multiprocessing. Fixing this requires either making the LoRA "
"Triton kernel spawn-safe or pre-warming the kernel cache."
),
# TODO: make the Mxfp4MoeBackend.TRITON spawn-safe.
# For now just use TRITON_UNFUSED kernel
@pytest.mark.parametrize(
"mxfp4_use_marlin",
[
False,
pytest.param(
True,
marks=pytest.mark.skipif(
current_platform.is_rocm(), reason="marlin not supported"
),
),
],
)
@pytest.mark.parametrize("mxfp4_use_marlin", [True, False])
@pytest.mark.parametrize("specialize_active_lora", [True, False])
def test_gpt_oss_lora(
gptoss20b_lora_files,
@@ -109,7 +112,18 @@ def test_gpt_oss_lora(
@multi_gpu_test(num_gpus=2)
@pytest.mark.parametrize("fully_sharded_loras", [False, True])
@pytest.mark.parametrize("mxfp4_use_marlin", [True, False])
@pytest.mark.parametrize(
"mxfp4_use_marlin",
[
False,
pytest.param(
True,
marks=pytest.mark.skipif(
current_platform.is_rocm(), reason="marlin not supported"
),
),
],
)
def test_gpt_oss_lora_tp2(
gptoss20b_lora_files,
fully_sharded_loras,
+18
View File
@@ -615,6 +615,24 @@ class TestArgConverter:
assert result["command"] == "ls -la"
assert result["desc"] == "\npartial value"
def test_partial_value_with_angle_bracket(self):
from vllm.parser.qwen3 import (
_qwen3_arg_converter,
)
raw = "<parameter=expr>x<5"
result = json.loads(_qwen3_arg_converter(raw, partial=True))
assert result == {"expr": "x<5"}
def test_partial_value_with_angle_bracket_and_complete_param(self):
from vllm.parser.qwen3 import (
_qwen3_arg_converter,
)
raw = "<parameter=city>Tokyo</parameter>\n<parameter=expr>x<5"
result = json.loads(_qwen3_arg_converter(raw, partial=True))
assert result == {"city": "Tokyo", "expr": "x<5"}
class TestSchemaAwareTypeCoercion:
"""Verify that _fix_arg_types corrects miscoerced values using the
+76
View File
@@ -30,6 +30,7 @@ from vllm.entrypoints.openai.chat_completion.protocol import (
)
from vllm.parser.engine.registered_adapters import (
Gemma4Parser,
Glm47MoeParser,
MinimaxM2Parser,
NemotronV3Parser,
Qwen3Parser,
@@ -571,6 +572,80 @@ def _build_nemotron_v3(scenario: Scenario, validate: bool = True) -> Sample:
)
# ── GLM-4.7 MoE (XML tool format, starts in REASONING) ──────────────
_GLM47_MOE_VOCAB: dict[str, int] = {
"<think>": 50,
"</think>": 51,
"<tool_call>": 60,
"</tool_call>": 61,
"<arg_key>": 62,
"</arg_key>": 63,
"<arg_value>": 64,
"</arg_value>": 65,
}
def _glm47_moe_arg_value(value: Any) -> str:
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, (int, float)):
return str(value)
if isinstance(value, str):
return value
return json.dumps(value, ensure_ascii=False)
def _glm47_moe_tool_segments(tc: ToolCallSpec) -> list[tuple[str, bool]]:
segs: list[tuple[str, bool]] = [
("<tool_call>", True),
(tc.name, False),
]
for key, value in tc.arguments.items():
segs.extend(
[
("<arg_key>", True),
(key, False),
("</arg_key>", True),
("<arg_value>", True),
(_glm47_moe_arg_value(value), False),
("</arg_value>", True),
]
)
segs.append(("</tool_call>", True))
return segs
def _glm47_moe_segments(scenario: Scenario) -> list[tuple[str, bool]]:
segs: list[tuple[str, bool]] = []
if scenario.reasoning is not None:
segs.append((scenario.reasoning, False))
if scenario.content is not None or scenario.tool_calls:
segs.append(("</think>", True))
if scenario.content is not None:
segs.append((scenario.content, False))
if scenario.tool_calls:
for tc in scenario.tool_calls:
segs.extend(_glm47_moe_tool_segments(tc))
return segs
def _build_glm47_moe(scenario: Scenario, validate: bool = True) -> Sample:
sample = _make_sample(
sample_id=f"glm47_moe-{scenario.id}",
description=scenario.description,
vocab=_GLM47_MOE_VOCAB,
segments=_glm47_moe_segments(scenario),
expected_reasoning=scenario.reasoning if scenario.reasoning is not None else "",
expected_content=_qwen3_expected_content(scenario),
expected_tool_calls=_expected_tc(scenario),
tools=_expected_tools(scenario),
)
if validate:
_validate_sample(sample, Glm47MoeParser)
return sample
# ── Registry and public API ──────────────────────────────────────────
_BUILDERS: dict[str, Any] = {
@@ -578,6 +653,7 @@ _BUILDERS: dict[str, Any] = {
"gemma4": _build_gemma4,
"minimax_m2": _build_minimax_m2,
"nemotron_v3": _build_nemotron_v3,
"glm47_moe": _build_glm47_moe,
}
@@ -11,7 +11,7 @@ parser_name = "glm45"
start_token = "<think>"
end_token = "</think>"
REASONING_MODEL_NAME = "zai-org/GLM-4.5"
REASONING_MODEL_NAME = "zai-org/GLM-4.7"
@pytest.fixture(scope="module")
@@ -35,18 +35,32 @@ WITH_THINK_STREAM = {
WITHOUT_THINK = {
"output": "This is the rest",
"reasoning": None,
"content": "This is the rest",
"reasoning": "This is the rest",
"content": None,
"is_reasoning_end": False,
}
WITHOUT_THINK_STREAM = {
"output": "This is the rest",
"reasoning": None,
"content": "This is the rest",
"reasoning": "This is the rest",
"content": None,
"is_reasoning_end": False,
}
WITHOUT_OPEN_THINK = {
"output": "This is a reasoning section</think>This is the rest",
"reasoning": "This is a reasoning section",
"content": "This is the rest",
"is_reasoning_end": True,
}
WITHOUT_OPEN_THINK_STREAM = {
"output": "This is a reasoning section</think>This is the rest",
"reasoning": "This is a reasoning section",
"content": "This is the rest",
"is_reasoning_end": True,
}
COMPLETE_REASONING = {
"output": "<think>This is a reasoning section</think>",
"reasoning": "This is a reasoning section",
@@ -61,8 +75,8 @@ MULTILINE_REASONING = {
}
ONLY_OPEN_TAG = {
"output": "<think>This is a reasoning section",
"reasoning": None,
"content": "<think>This is a reasoning section",
"reasoning": "This is a reasoning section",
"content": None,
"is_reasoning_end": False,
}
@@ -94,6 +108,16 @@ TEST_CASES = [
WITHOUT_THINK_STREAM,
id="without_think_stream",
),
pytest.param(
False,
WITHOUT_OPEN_THINK,
id="without_open_think",
),
pytest.param(
True,
WITHOUT_OPEN_THINK_STREAM,
id="without_open_think_stream",
),
pytest.param(
False,
COMPLETE_REASONING,
+145
View File
@@ -0,0 +1,145 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# Schema/aliasing tests for the AITER FP8 quantization custom ops.
#
# These use torch.library.opcheck, whose test_schema check catches custom ops
# whose implementation aliases an input that the registered schema declares as
# non-aliasing -- the failure mode behind the rocm_aiter_per_tensor_quant
# regression (a returned scale that aliased the input scale).
#
# Skipped if AITER is not installed or the platform is not ROCm.
import importlib.util
import pytest
import torch
# this import statement is needed to ensure the ops are registered
from vllm._aiter_ops import rocm_aiter_ops
from vllm.platforms import current_platform
aiter_available = importlib.util.find_spec("aiter") is not None
pytestmark = pytest.mark.skipif(
not (current_platform.is_rocm() and aiter_available),
reason="AITER ops are only available on ROCm with aiter package installed",
)
FP8_DTYPE = current_platform.fp8_dtype()
def _x(M=128, N=4096):
return torch.randn((M, N), dtype=torch.float16, device="cuda")
# The in-place per-tensor op takes the fp8 output buffer as an input, which
# opcheck's test_schema cannot exercise ("mul_cuda" is unimplemented for fp8),
# so restrict to the utils that run on fp8 inputs. The aliasing contract for
# this op is instead covered by test_per_tensor_quant_torch_compile below.
_INPLACE_OPCHECK_UTILS = (
"test_faketensor",
"test_aot_dispatch_dynamic",
"test_autograd_registration",
)
def test_per_tensor_quant_static_schema():
"""Static per-tensor: caller provides scale (the aliasing regression)."""
x = _x()
out = torch.empty_like(x, dtype=FP8_DTYPE)
scale = torch.ones(1, dtype=torch.float32, device="cuda")
torch.library.opcheck(
torch.ops.vllm.rocm_aiter_per_tensor_quant,
(out, x, scale, False),
test_utils=_INPLACE_OPCHECK_UTILS,
)
def test_per_tensor_quant_dynamic_schema():
"""Dynamic per-tensor: op computes scale into the caller's buffer."""
x = _x()
out = torch.empty_like(x, dtype=FP8_DTYPE)
scale = torch.empty(1, dtype=torch.float32, device="cuda")
torch.library.opcheck(
torch.ops.vllm.rocm_aiter_per_tensor_quant,
(out, x, scale, True),
test_utils=_INPLACE_OPCHECK_UTILS,
)
def test_per_token_quant_dynamic_schema():
"""Dynamic per-token: op computes scale into a freshly allocated buffer."""
x = _x()
torch.library.opcheck(
torch.ops.vllm.rocm_aiter_per_token_quant,
(x, FP8_DTYPE, None),
)
def test_group_fp8_quant_schema():
"""Dynamic per-token-group quant."""
x = _x()
torch.library.opcheck(
torch.ops.vllm.rocm_aiter_group_fp8_quant,
(x, 128),
)
@pytest.mark.parametrize("dynamic", [True, False])
def test_per_tensor_quant_matches_native(dynamic):
"""Wrapper output matches the native scaled_fp8_quant reference."""
from vllm import _custom_ops as ops
torch.manual_seed(0)
x = _x()
if dynamic:
scale_in = None
else:
scale_in = torch.tensor([0.5], dtype=torch.float32, device="cuda")
out, scale = rocm_aiter_ops.per_tensor_quant(x, FP8_DTYPE, scale_in)
ref_out, ref_scale = ops.scaled_fp8_quant(x, scale_in)
assert out.shape == x.shape
assert out.dtype == FP8_DTYPE
assert scale.shape == ref_scale.shape
if not dynamic:
# static scale is passed through unchanged
assert torch.equal(scale, scale_in)
# Compare dequantized values to be robust to 1-ULP fp8 boundary flips.
deq = out.to(torch.float32) * scale
ref_deq = ref_out.to(torch.float32) * ref_scale
torch.testing.assert_close(deq, ref_deq, rtol=2e-2, atol=2e-2)
@pytest.mark.parametrize("dynamic", [True, False])
def test_per_tensor_quant_torch_compile(monkeypatch, dynamic):
"""per_tensor_quant compiles under inductor without an aliasing error.
Forces the custom-op aliasing check to error (it is otherwise only a
warning outside CI), so a regression that returns an input-aliasing
scale fails here regardless of the CI env var.
"""
aliasing_cfg = pytest.importorskip("torch._functorch.config")
monkeypatch.setattr(
aliasing_cfg, "error_on_custom_op_aliasing", True, raising=False
)
x = _x()
scale = None if dynamic else torch.tensor([0.5], dtype=torch.float32, device="cuda")
def fn(x, s):
return rocm_aiter_ops.per_tensor_quant(x, FP8_DTYPE, s)
compiled = torch.compile(fn, fullgraph=True, backend="inductor", dynamic=False)
out_eager, scale_eager = fn(x, scale)
out_compiled, scale_compiled = compiled(x, scale)
assert out_compiled.shape == out_eager.shape
torch.testing.assert_close(
out_compiled.to(torch.float32) * scale_compiled,
out_eager.to(torch.float32) * scale_eager,
rtol=2e-2,
atol=2e-2,
)
+1 -1
View File
@@ -188,7 +188,7 @@ def test_v2_model_runner_env_tri_state(monkeypatch, env_value, expected):
is_moe=False,
is_quantized=True,
),
False,
True,
),
(
SimpleNamespace(
@@ -16,7 +16,7 @@ from vllm.entrypoints.openai.chat_completion.protocol import (
from vllm.tokenizers import get_tokenizer
from vllm.tool_parsers.glm47_moe_tool_parser import Glm47MoeModelToolParser
MODEL = "zai-org/GLM-4.5"
MODEL = "zai-org/GLM-4.7"
@pytest.fixture(scope="module")
@@ -136,9 +136,10 @@ class TestGlm47Streaming:
_reset(glm47_tool_parser)
chunks = ["<tool_call>", "get_current_date", "</tool_call>"]
current_text = ""
deltas = []
for chunk in chunks:
current_text += chunk
glm47_tool_parser.extract_tool_calls_streaming(
delta = glm47_tool_parser.extract_tool_calls_streaming(
previous_text="",
current_text=current_text,
delta_text=chunk,
@@ -147,7 +148,23 @@ class TestGlm47Streaming:
delta_token_ids=[],
request=mock_request,
)
assert len(glm47_tool_parser.prev_tool_call_arr) >= 1
if delta:
deltas.append(delta)
tool_calls = [
tool_call for delta in deltas for tool_call in (delta.tool_calls or [])
]
names = [
tool_call.function.name
for tool_call in tool_calls
if tool_call.function and tool_call.function.name
]
arguments = [
tool_call.function.arguments
for tool_call in tool_calls
if tool_call.function and tool_call.function.arguments
]
assert names == ["get_current_date"]
assert "".join(arguments) == "{}"
def test_with_args(self, glm47_tool_parser, mock_request):
_reset(glm47_tool_parser)
@@ -161,9 +178,10 @@ class TestGlm47Streaming:
"</tool_call>",
]
current_text = ""
deltas = []
for chunk in chunks:
current_text += chunk
glm47_tool_parser.extract_tool_calls_streaming(
delta = glm47_tool_parser.extract_tool_calls_streaming(
previous_text="",
current_text=current_text,
delta_text=chunk,
@@ -172,5 +190,13 @@ class TestGlm47Streaming:
delta_token_ids=[],
request=mock_request,
)
args = json.loads(glm47_tool_parser.prev_tool_call_arr[0]["arguments"])
if delta:
deltas.append(delta)
arguments = [
tool_call.function.arguments
for delta in deltas
for tool_call in (delta.tool_calls or [])
if tool_call.function and tool_call.function.arguments
]
args = json.loads("".join(arguments))
assert args["city"] == "Beijing"
File diff suppressed because it is too large Load Diff
@@ -1300,6 +1300,73 @@ def test_streaming_multi_param_single_chunk(qwen3_tool_parser, qwen3_tokenizer):
assert args["unit"] == "fahrenheit"
def test_streaming_complete_tool_call_single_delta(qwen3_tool_parser):
"""Regression: one delta may contain a complete tool call."""
request = ChatCompletionRequest(model=MODEL, messages=[])
from tests.tool_parsers.utils import (
run_tool_extraction_streaming,
)
reconstructor = run_tool_extraction_streaming(
qwen3_tool_parser,
[
(
"<tool_call>\n"
"<function=get_current_weather>\n"
"<parameter=city>\nDallas\n</parameter>\n"
"<parameter=state>\nTX\n</parameter>\n"
"</function>\n"
"</tool_call>"
)
],
request,
assert_one_tool_per_delta=False,
)
assert len(reconstructor.tool_calls) == 1
assert reconstructor.tool_calls[0].function.name == "get_current_weather"
args = json.loads(reconstructor.tool_calls[0].function.arguments)
assert args == {"city": "Dallas", "state": "TX"}
def test_streaming_next_tool_call_starts_in_close_delta(qwen3_tool_parser):
"""Regression: a close delta may also contain the next tool call."""
request = ChatCompletionRequest(model=MODEL, messages=[])
from tests.tool_parsers.utils import (
run_tool_extraction_streaming,
)
reconstructor = run_tool_extraction_streaming(
qwen3_tool_parser,
[
"<tool_call>\n",
"<function=get_current_weather>\n",
"<parameter=city>\nDallas\n</parameter>\n",
"<parameter=state>\nTX\n</parameter>\n",
"</function>",
(
"\n</tool_call>\n"
"<tool_call>\n"
"<function=get_current_weather>\n"
"<parameter=city>\nOrlando\n</parameter>\n"
"<parameter=state>\nFL\n</parameter>\n"
"</function>\n"
"</tool_call>"
),
],
request,
assert_one_tool_per_delta=False,
)
assert len(reconstructor.tool_calls) == 2
first_args = json.loads(reconstructor.tool_calls[0].function.arguments)
second_args = json.loads(reconstructor.tool_calls[1].function.arguments)
assert first_args == {"city": "Dallas", "state": "TX"}
assert second_args == {"city": "Orlando", "state": "FL"}
def test_no_double_serialization_string_args(qwen3_tool_parser):
"""Regression: string arguments must not be double-serialized (PR #35615)."""
tools = [
+67
View File
@@ -1,6 +1,8 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import json
import pytest
from vllm.tool_parsers.utils import (
@@ -91,6 +93,71 @@ class TestCoerceToSchemaType:
def test_invalid_number_fallback(self):
assert coerce_to_schema_type("abc", "number") == "abc"
class TestNonFiniteNumbers:
"""Non-finite numeric strings must not crash and must coerce to a
JSON-serializable value.
Regression: ``int(float("inf"))`` raised an uncaught ``OverflowError``
(only ``ValueError``/``TypeError`` were handled), and ``"1e999"``
round-tripped through ``json.loads`` to a float ``inf`` that
``json.dumps`` renders as invalid JSON ``Infinity``.
"""
@pytest.mark.parametrize(
"value", ["inf", "-inf", "Infinity", "1e999", "nan", "-nan"]
)
def test_non_finite_number_does_not_crash(self, value):
# Must not raise (previously OverflowError for inf/1e999/Infinity).
result = coerce_to_schema_type(value, "number")
# Result must serialize to valid, finite JSON and round-trip.
assert json.loads(json.dumps(result)) == result
@pytest.mark.parametrize("value", ["inf", "-inf", "1e999"])
def test_non_finite_number_preserved_as_string(self, value):
assert coerce_to_schema_type(value, "number") == value
@pytest.mark.parametrize("value", ["inf", "1e999", "Infinity"])
def test_non_finite_integer_not_float_inf(self, value):
result = coerce_to_schema_type(value, "integer")
assert isinstance(result, str)
assert result == value
class TestNonFiniteContainers:
"""Non-finite floats nested in object/array values must not produce
invalid JSON.
Regression: the ``object``/``array`` branch returned
``json.loads(value)`` directly, so ``"[1e999]"`` became ``[inf]`` and
``'{"x": Infinity}'`` became ``{"x": inf}`` -- values that
``json.dumps`` later renders as invalid JSON (``Infinity``/``NaN``).
"""
@pytest.mark.parametrize(
"value", ["[1e999]", "[1, 2, 1e999]", "[NaN]", "[-Infinity]"]
)
def test_array_with_non_finite_preserved_as_string(self, value):
result = coerce_to_schema_type(value, "array")
assert result == value
assert json.loads(json.dumps(result)) == result
@pytest.mark.parametrize(
"value", ['{"x": 1e999}', '{"x": Infinity}', '{"a": [1e999, 2]}']
)
def test_object_with_non_finite_preserved_as_string(self, value):
result = coerce_to_schema_type(value, "object")
assert result == value
assert json.loads(json.dumps(result)) == result
def test_finite_array_still_coerced(self):
assert coerce_to_schema_type("[1, 2, 3]", "array") == [1, 2, 3]
def test_finite_object_still_coerced(self):
assert coerce_to_schema_type('{"a": 1}', "object") == {"a": 1}
def test_unknown_type_non_finite_falls_back_to_string(self):
# Exercises the final json.loads fallback path.
assert coerce_to_schema_type("1e999", "unknown_type") == "1e999"
class TestBooleanType:
def test_true(self):
assert coerce_to_schema_type("true", "boolean") is True
+14
View File
@@ -64,6 +64,7 @@ def make_mapper_from_offloading_spec(**kwargs) -> FileMapper:
"dcp_size", 1
)
mock_vllm_config.parallel_config.rank = kwargs.get("rank", 0)
mock_vllm_config.use_v2_model_runner = kwargs.get("use_v2_model_runner", False)
mock_kv_cache_config = MagicMock()
mock_kv_cache_config.kv_cache_groups = kwargs.get("kv_cache_groups", [])
@@ -210,3 +211,16 @@ def test_parallel_agnostic_excludes_mla():
)
assert fm.fields["tp_size"] == 2
assert fm.rank == 1
def test_parallel_agnostic_disabled_on_v2_model_runner():
# V2's KV layout is not known to be parallelism-invariant: don't collapse.
fm = make_mapper_from_offloading_spec(
tp_size=2,
rank=1,
kv_cache_groups=[_full_attention_group()],
use_v2_model_runner=True,
parallel_agnostic=True,
)
assert fm.fields["tp_size"] == 2
assert fm.rank == 1
@@ -37,6 +37,7 @@ def _make_vllm_config():
decode_context_parallel_size=1,
rank=0,
),
use_v2_model_runner=False,
)
+6 -2
View File
@@ -1002,7 +1002,11 @@ def test_propose(method, attn_backend, num_speculative_tokens, monkeypatch):
assert torch.equal(result, expected_tokens)
def test_propose_stores_probabilistic_draft_probs(monkeypatch):
@pytest.mark.parametrize(
"attn_backend",
["ROCM_ATTN", "TRITON_ATTN"] if current_platform.is_rocm() else ["FLASH_ATTN"],
)
def test_propose_stores_probabilistic_draft_probs(attn_backend, monkeypatch):
device = torch.device(DEVICE_TYPE)
batch_size = 2
seq_lens = [5, 3]
@@ -1053,7 +1057,7 @@ def test_propose_stores_probabilistic_draft_probs(monkeypatch):
)
attn_metadata_builder_cls, _ = try_get_attention_backend(
AttentionBackendEnum.FLASH_ATTN
AttentionBackendEnum[attn_backend]
)
attn_metadata_builder = attn_metadata_builder_cls(
kv_cache_spec=create_standard_kv_cache_spec(proposer.vllm_config),
+21 -13
View File
@@ -1019,23 +1019,26 @@ def _rocm_aiter_fused_allreduce_rmsnorm_quant_per_group_with_bf16_norm_fake(
def _rocm_aiter_per_tensor_quant_impl(
out: torch.Tensor,
x: torch.Tensor,
quant_dtype: torch.dtype,
scale: torch.Tensor | None = None,
) -> tuple[torch.Tensor, torch.Tensor]:
from aiter.ops.quant import per_tensor_quant_hip
scale: torch.Tensor,
is_dynamic: bool,
) -> None:
from aiter.ops.quant import dynamic_per_tensor_quant, static_per_tensor_quant
return per_tensor_quant_hip(x, scale, quant_dtype)
if is_dynamic:
dynamic_per_tensor_quant(out, x, scale)
else:
static_per_tensor_quant(out, x, scale)
def _rocm_aiter_per_tensor_quant_fake(
out: torch.Tensor,
x: torch.Tensor,
quant_dtype: torch.dtype,
scale: torch.Tensor | None = None,
) -> tuple[torch.Tensor, torch.Tensor]:
return torch.empty_like(x, dtype=quant_dtype), torch.empty(
1, dtype=torch.float32, device=x.device
)
scale: torch.Tensor,
is_dynamic: bool,
) -> None:
pass
def _rocm_aiter_per_token_quant_impl(
@@ -1979,7 +1982,7 @@ class rocm_aiter_ops:
direct_register_custom_op(
op_name="rocm_aiter_per_tensor_quant",
op_func=_rocm_aiter_per_tensor_quant_impl,
mutates_args=[],
mutates_args=["out", "scale"],
fake_impl=_rocm_aiter_per_tensor_quant_fake,
dispatch_key=current_platform.dispatch_key,
)
@@ -2392,7 +2395,12 @@ class rocm_aiter_ops:
quant_dtype: torch.dtype,
scale: torch.Tensor | None = None,
) -> tuple[torch.Tensor, torch.Tensor]:
return torch.ops.vllm.rocm_aiter_per_tensor_quant(x, quant_dtype, scale)
out = torch.empty_like(x, dtype=quant_dtype)
is_dynamic = scale is None
if is_dynamic:
scale = torch.empty(1, dtype=torch.float32, device=x.device)
torch.ops.vllm.rocm_aiter_per_tensor_quant(out, x, scale, is_dynamic)
return out, scale
@staticmethod
def per_token_quant(
-3
View File
@@ -555,9 +555,6 @@ class VllmConfig:
if model_config.runner_type != "generate":
return False
if model_config.is_quantized:
return False
architectures = getattr(model_config, "architectures", [])
return any(
arch in DEFAULT_V2_MODEL_RUNNER_ARCHITECTURES for arch in architectures
@@ -1,7 +1,7 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, cast
import torch
import torch.nn as nn
@@ -166,7 +166,21 @@ def _init_kv_cache_quant(
# TODO (mgoin): kv cache dtype should be specified in the FP8
# checkpoint config and become the "auto" behavior
if layer.kv_cache_dtype == "fp8_e5m2":
raise ValueError("fp8_e5m2 kv-cache is not supported with fp8 checkpoints.")
# A compressed-tensors checkpoint stores fp8 KV scales only when it
# declares a kv_cache_scheme; weight-only ones declare none and must
# keep fp8_e5m2, the only fp8 KV dtype usable on Ampere.
from vllm.model_executor.layers.quantization.compressed_tensors.compressed_tensors import ( # noqa: E501
CompressedTensorsConfig,
CompressedTensorsKVCacheMethod,
)
if not isinstance(quant_method, CompressedTensorsKVCacheMethod) or (
cast(CompressedTensorsConfig, quant_method.quant_config).kv_cache_scheme
is not None
):
raise ValueError(
"fp8_e5m2 kv-cache is not supported with fp8 checkpoints."
)
# If quantization is enabled, we make "k_scale" and "v_scale"
# parameters so that it can be loaded from the model checkpoint.
# The k/v_scale will then be converted back to native float32
@@ -25,6 +25,7 @@ from vllm.model_executor.layers.fused_moe import (
UnquantizedFusedMoEMethod,
)
from vllm.model_executor.layers.fused_moe.oracle.int_wna16 import (
WNA16MoEBackend,
convert_to_wna16_moe_kernel_format,
make_wna16_moe_kernel,
select_wna16_moe_backend,
@@ -753,13 +754,18 @@ class AutoGPTQMoEMethod(FusedMoEMethodBase):
gptq_marlin_moe_quant_config,
)
# CPU fused_experts_cpu requires zero points even for symmetric quant
use_zp = (
not self.quant_config.is_sym
or self.wna16_moe_backend == WNA16MoEBackend.CPU
)
return gptq_marlin_moe_quant_config(
w1_scale=layer.w13_scales,
w2_scale=layer.w2_scales,
weight_bits=self.quant_config.weight_bits,
group_size=self.quant_config.group_size,
w1_zp=getattr(layer, "w13_qzeros", None),
w2_zp=getattr(layer, "w2_qzeros", None),
w1_zp=getattr(layer, "w13_qzeros", None) if use_zp else None,
w2_zp=getattr(layer, "w2_qzeros", None) if use_zp else None,
w1_bias=getattr(layer, "w13_bias", None),
w2_bias=getattr(layer, "w2_bias", None),
)
@@ -415,9 +415,9 @@ class CompressedTensorsWNA16MarlinMoEMethod(CompressedTensorsMoEMethod):
replace_parameter(layer, "w13_weight_scale", w13_scales)
replace_parameter(layer, "w2_weight_scale", w2_scales)
if w13_qzeros is not None:
# CPU fused_experts_cpu requires zero points even for symmetric quant
if not self.symmetric or self.wna16_backend == WNA16MoEBackend.CPU:
replace_parameter(layer, "w13_weight_zero_point", w13_qzeros)
if w2_qzeros is not None:
replace_parameter(layer, "w2_weight_zero_point", w2_qzeros)
# Marlin-specific parameters (not needed for Flashinfer)
@@ -9,6 +9,7 @@ names so that :class:`ReasoningParserManager` and
from vllm.parser.engine.adapters import make_adapters
from vllm.parser.gemma4 import Gemma4Parser
from vllm.parser.glm47_moe import Glm47MoeParser
from vllm.parser.minimax_m2 import MinimaxM2Parser
from vllm.parser.nemotron_v3 import NemotronV3Parser
from vllm.parser.qwen3 import Qwen3Parser
@@ -32,3 +33,8 @@ from vllm.parser.qwen3 import Qwen3Parser
Qwen3ParserReasoningAdapter,
Qwen3ParserToolAdapter,
) = make_adapters(Qwen3Parser)
(
Glm47MoeParserReasoningAdapter,
Glm47MoeParserToolAdapter,
) = make_adapters(Glm47MoeParser)
+226
View File
@@ -0,0 +1,226 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""GLM-4.7 parser for reasoning and tool calls.
GLM-4.7 uses XML-like tool calls::
<tool_call>func_name<arg_key>key</arg_key><arg_value>value</arg_value></tool_call>
The function name can be followed directly by the first ``<arg_key>`` tag,
and tool calls may have no arguments.
"""
from __future__ import annotations
import functools
import json
from typing import TYPE_CHECKING
import regex as re
from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
from vllm.parser.engine.events import EventType
from vllm.parser.engine.parser_engine import ParserEngine
from vllm.parser.engine.parser_engine_config import (
ParserEngineConfig,
ParserState,
Transition,
)
if TYPE_CHECKING:
from vllm.tokenizers import TokenizerLike
from vllm.tool_parsers.abstract_tool_parser import Tool
THINK_START = "<think>"
THINK_END = "</think>"
TOOL_CALL_START = "<tool_call>"
TOOL_CALL_END = "</tool_call>"
ARG_KEY_START = "<arg_key>"
ARG_KEY_END = "</arg_key>"
ARG_VALUE_START = "<arg_value>"
ARG_VALUE_END = "</arg_value>"
_ARG_RE = re.compile(
r"<arg_key>(?P<key>.*?)</arg_key>\s*"
r"<arg_value>(?P<value>.*?)</arg_value>",
re.DOTALL,
)
_PARTIAL_ARG_RE = re.compile(
r"<arg_key>(?P<key>.*?)</arg_key>\s*"
r"<arg_value>(?P<value>.*)$",
re.DOTALL,
)
def _glm47_arg_converter(raw_args: str, partial: bool) -> str:
params: dict[str, object] = {}
for match in _ARG_RE.finditer(raw_args):
params[match.group("key").strip()] = match.group("value")
if partial:
remaining = _ARG_RE.sub("", raw_args)
match = _PARTIAL_ARG_RE.search(remaining)
if match:
key = match.group("key").strip()
if key:
params[key] = match.group("value")
return json.dumps(params, ensure_ascii=False)
@functools.cache
def glm47_moe_config(thinking: bool = True) -> ParserEngineConfig:
arg_tag_transitions = {
(ParserState.TOOL_ARGS, terminal): Transition(
ParserState.TOOL_ARGS,
(EventType.ARG_VALUE_CHUNK,),
)
for terminal in (
"ARG_KEY_START",
"ARG_KEY_END",
"ARG_VALUE_START",
"ARG_VALUE_END",
)
}
reasoning_terminals = (
{
"THINK_START": THINK_START,
"THINK_END": THINK_END,
}
if thinking
else {}
)
reasoning_token_id_terminals = (
{
"THINK_START": THINK_START,
"THINK_END": THINK_END,
}
if thinking
else {}
)
reasoning_transitions = (
{
(ParserState.CONTENT, "THINK_START"): Transition(
ParserState.REASONING,
(EventType.REASONING_START,),
),
(ParserState.REASONING, "THINK_END"): Transition(
ParserState.CONTENT,
(EventType.REASONING_END,),
),
(ParserState.CONTENT, "THINK_END"): Transition(
ParserState.CONTENT,
(),
),
}
if thinking
else {}
)
return ParserEngineConfig(
name="glm47_moe",
initial_state=ParserState.REASONING if thinking else ParserState.CONTENT,
terminals={
**reasoning_terminals,
"TOOL_START": TOOL_CALL_START,
"TOOL_END": TOOL_CALL_END,
"ARG_KEY_START": ARG_KEY_START,
"ARG_KEY_END": ARG_KEY_END,
"ARG_VALUE_START": ARG_VALUE_START,
"ARG_VALUE_END": ARG_VALUE_END,
},
token_id_terminals={
**reasoning_token_id_terminals,
"TOOL_START": TOOL_CALL_START,
"TOOL_END": TOOL_CALL_END,
},
transitions={
**reasoning_transitions,
(ParserState.REASONING, "THINK_START"): Transition(
ParserState.REASONING,
(),
),
(ParserState.REASONING, "TOOL_START"): Transition(
ParserState.TOOL_NAME,
(EventType.REASONING_END, EventType.TOOL_CALL_START),
),
(ParserState.CONTENT, "TOOL_START"): Transition(
ParserState.TOOL_NAME,
(EventType.TOOL_CALL_START,),
),
(ParserState.TOOL_NAME, "ARG_KEY_START"): Transition(
ParserState.TOOL_ARGS,
(EventType.ARG_VALUE_CHUNK,),
),
(ParserState.TOOL_NAME, "TOOL_END"): Transition(
ParserState.CONTENT,
(EventType.TOOL_CALL_END,),
),
(ParserState.TOOL_ARGS, "TOOL_END"): Transition(
ParserState.CONTENT,
(EventType.TOOL_CALL_END,),
),
**arg_tag_transitions,
},
arg_converter=_glm47_arg_converter,
stream_arg_deltas=True,
tool_args_json=False,
validate_tool_names=True,
)
class Glm47MoeParser(ParserEngine):
"""GLM-4.7 parser backed by the declarative parser engine."""
def __init__(
self,
tokenizer: TokenizerLike,
tools: list[Tool] | None = None,
**kwargs,
) -> None:
chat_kwargs = kwargs.get("chat_template_kwargs", {}) or {}
thinking = chat_kwargs.get("thinking", None)
enable_thinking = chat_kwargs.get("enable_thinking", None)
self.thinking_enabled = (
True
if thinking is None and enable_thinking is None
else bool(thinking) or bool(enable_thinking)
)
kwargs.setdefault(
"parser_engine_config",
glm47_moe_config(thinking=self.thinking_enabled),
)
super().__init__(tokenizer, tools, **kwargs)
def _emit_name_delta(self, idx: int, deltas, name: str | None) -> None:
if name is not None:
name = name.strip()
super()._emit_name_delta(idx, deltas, name)
def _handle_tool_end(self, event, deltas) -> None:
idx = event.tool_index
if 0 <= idx < len(self._tool_slots):
self._tool_slots[idx].name = self._tool_slots[idx].name.strip()
super()._handle_tool_end(event, deltas)
def is_reasoning_end(self, input_ids: list[int]) -> bool:
if not self.thinking_enabled:
return True
return super().is_reasoning_end(input_ids)
def extract_content_ids(self, input_ids: list[int]) -> list[int]:
if not self.thinking_enabled:
return input_ids
return super().extract_content_ids(input_ids)
def extract_reasoning(
self,
model_output: str,
request: ChatCompletionRequest | ResponsesRequest,
) -> tuple[str | None, str | None]:
if not self.thinking_enabled:
return None, model_output
return super().extract_reasoning(model_output, request)
+1 -1
View File
@@ -49,7 +49,7 @@ _PARAM_RE = re.compile(
r"(?:<\s*/\s*parameter\s*>|(?=<\s*parameter\s*=))",
re.DOTALL,
)
_PARTIAL_PARAM_RE = re.compile(r"<\s*parameter\s*=\s*([^>]+)>([^<]*)$", re.DOTALL)
_PARTIAL_PARAM_RE = re.compile(r"<\s*parameter\s*=\s*([^>]+)>(.*)$", re.DOTALL)
def _qwen3_arg_converter(raw_args: str, partial: bool) -> str:
+6 -2
View File
@@ -53,8 +53,12 @@ _REASONING_PARSERS_TO_REGISTER = {
"Gemma4ParserReasoningAdapter",
),
"glm45": (
"deepseek_v3_reasoning_parser",
"DeepSeekV3ReasoningWithThinkingParser",
"glm47_moe_reasoning_parser",
"Glm47MoeParserReasoningAdapter",
),
"glm47": (
"glm47_moe_reasoning_parser",
"Glm47MoeParserReasoningAdapter",
),
"openai_gptoss": (
"gptoss_reasoning_parser",
@@ -0,0 +1,6 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from vllm.parser.engine.registered_adapters import Glm47MoeParserReasoningAdapter
__all__ = ["Glm47MoeParserReasoningAdapter"]
+2 -2
View File
@@ -51,8 +51,8 @@ _TOOL_PARSERS_TO_REGISTER = {
"Ernie45ToolParser",
),
"glm45": (
"glm4_moe_tool_parser",
"Glm4MoeModelToolParser",
"glm47_moe_tool_parser",
"Glm47MoeModelToolParser",
),
"glm47": (
"glm47_moe_tool_parser",
+3 -33
View File
@@ -1,41 +1,11 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""
GLM-4.7 Tool Call Parser.
GLM-4.7 uses a slightly different tool call format compared to GLM-4.5:
- The function name may appear on the same line as ``<tool_call>`` without
a newline separator before the first ``<arg_key>``.
- Tool calls may have zero arguments
(e.g. ``<tool_call>func</tool_call>``).
from __future__ import annotations
This parser overrides the parent regex patterns to handle both formats.
"""
import regex as re
from vllm.logger import init_logger
from vllm.tokenizers import TokenizerLike
from vllm.tool_parsers.abstract_tool_parser import Tool
from vllm.tool_parsers.glm4_moe_tool_parser import Glm4MoeModelToolParser
logger = init_logger(__name__)
from vllm.parser.engine.registered_adapters import Glm47MoeParserToolAdapter
class Glm47MoeModelToolParser(Glm4MoeModelToolParser):
class Glm47MoeModelToolParser(Glm47MoeParserToolAdapter): # type: ignore[valid-type, misc]
supports_required_and_named = False
structural_tag_model = "glm_4_7"
def __init__(self, tokenizer: TokenizerLike, tools: list[Tool] | None = None):
super().__init__(tokenizer, tools)
# GLM-4.7 format: <tool_call>func_name[<arg_key>...]*</tool_call>
# The function name can be followed by a newline, whitespace, or
# directly by <arg_key> tags (no separator). The arg section is
# optional so that zero-argument calls are supported.
self.func_detail_regex = re.compile(
r"<tool_call>\s*(\S+?)\s*(<arg_key>.*)?</tool_call>", re.DOTALL
)
self.func_arg_regex = re.compile(
r"<arg_key>(.*?)</arg_key>\s*<arg_value>(.*?)</arg_value>",
re.DOTALL,
)
-495
View File
@@ -1,495 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""
GLM-4 Tool Call Parser with incremental string streaming support.
This parser fixes the streaming issue reported in Issue #32829 where long string
parameters (e.g., file content with 4000+ characters of code) are buffered until
complete, causing multi-second delays before the user sees any content.
The fix streams string values incrementally as they arrive, providing a true
streaming experience for long content.
"""
import json
from collections.abc import Sequence
from typing import Any
import regex as re
from vllm.entrypoints.chat_utils import make_tool_call_id
from vllm.entrypoints.openai.chat_completion.protocol import (
ChatCompletionNamedToolChoiceParam,
ChatCompletionRequest,
)
from vllm.entrypoints.openai.engine.protocol import (
DeltaFunctionCall,
DeltaMessage,
DeltaToolCall,
ExtractedToolCallInformation,
FunctionCall,
ToolCall,
)
from vllm.entrypoints.openai.responses.protocol import ResponsesRequest
from vllm.logger import init_logger
from vllm.tokenizers import TokenizerLike
from vllm.tool_parsers.abstract_tool_parser import (
Tool,
ToolParser,
)
from vllm.tool_parsers.utils import (
extract_types_from_schema,
find_tool_properties,
partial_tag_overlap,
safe_literal_eval,
)
logger = init_logger(__name__)
class Glm4MoeModelToolParser(ToolParser):
"""Tool parser for GLM-4 models with incremental string streaming.
On every streaming call the parser re-parses ``current_text`` to find
``<tool_call>`` regions, builds the JSON arguments string for each tool
call, and diffs against what was previously sent to emit only new content.
"""
supports_required_and_named = False
def __init__(self, tokenizer: TokenizerLike, tools: list[Tool] | None = None):
super().__init__(tokenizer, tools)
# Stateful streaming fields
self.current_tool_name_sent: bool = False
self.prev_tool_call_arr: list[dict[str, Any]] = []
self.current_tool_id: int = -1
self.streamed_args_for_tool: list[str] = []
self.tool_call_start_token: str = "<tool_call>"
self.tool_call_end_token: str = "</tool_call>"
self.arg_key_start: str = "<arg_key>"
self.arg_key_end: str = "</arg_key>"
self.arg_val_start: str = "<arg_value>"
self.arg_val_end: str = "</arg_value>"
self.tool_calls_start_token = self.tool_call_start_token
self.func_call_regex = re.compile(r"<tool_call>.*?</tool_call>", re.DOTALL)
self.func_detail_regex = re.compile(
r"<tool_call>([^\n]*)\n(.*)</tool_call>", re.DOTALL
)
self.func_arg_regex = re.compile(
r"<arg_key>(.*?)</arg_key>\s*<arg_value>(.*?)</arg_value>", re.DOTALL
)
if not self.model_tokenizer:
raise ValueError(
"The model tokenizer must be passed to the ToolParser "
"constructor during construction."
)
self.tool_call_start_token_id = self.vocab.get(self.tool_call_start_token)
self.tool_call_end_token_id = self.vocab.get(self.tool_call_end_token)
# Pre-compiled pattern for finding the last <arg_key>...</arg_key>
# before a partial <arg_value> (used in _build_args_json_so_far).
self._arg_key_pattern = re.compile(
re.escape(self.arg_key_start) + r"(.*?)" + re.escape(self.arg_key_end),
re.DOTALL,
)
# Streaming state for re-parse-and-diff approach
self._sent_content_idx: int = 0
self._tool_call_ids: list[str] = []
@staticmethod
def _deserialize(value: str) -> Any:
try:
return json.loads(value)
except json.JSONDecodeError:
pass
try:
return safe_literal_eval(value)
except (ValueError, SyntaxError):
pass
return value
@staticmethod
def _json_escape_string_content(s: str) -> str:
"""JSON-escape string content for incremental streaming.
This escapes the content that goes INSIDE a JSON string (between quotes),
not including the surrounding quotes themselves.
"""
if not s:
return ""
return json.dumps(s, ensure_ascii=False)[1:-1]
def _is_string_type(self, tool_name: str, arg_name: str) -> bool:
tool_properties = find_tool_properties(self.tools, tool_name)
param_schema = tool_properties.get(arg_name)
if param_schema is None:
return False
param_types = extract_types_from_schema(param_schema)
return set(param_types) - {"null"} == {"string"}
@staticmethod
def _tools_enabled(request: ChatCompletionRequest) -> bool:
"""Return whether tool parsing should be applied for this request."""
try:
tools = getattr(request, "tools", None)
tool_choice = getattr(request, "tool_choice", None)
return bool(tools) and tool_choice != "none"
except Exception:
logger.exception("Failed to determine if tools are enabled.")
return False
def adjust_request(
self, request: ChatCompletionRequest | ResponsesRequest
) -> ChatCompletionRequest | ResponsesRequest:
"""Adjust request parameters for tool call token handling.
For required/named tool_choice, skip setting structured_outputs
because GLM models output tool calls in XML format (per chat
template). Guided decoding would force JSON output, conflicting
with the XML format and causing parsing failures.
"""
if request.tools:
tc = request.tool_choice
if tc == "required" or isinstance(tc, ChatCompletionNamedToolChoiceParam):
# Do NOT call super().adjust_request() for required/named,
# because it would set structured_outputs and force JSON
# output via guided decoding. GLM models use XML tool-call
# syntax (defined in the chat template), so guided decoding
# must be skipped to let the model output XML freely.
# The tool_parser handles extraction from XML output.
if request.tool_choice != "none":
request.skip_special_tokens = False
return request
request = super().adjust_request(request)
if request.tools and request.tool_choice != "none":
# Ensure tool call tokens (<tool_call>, </tool_call>) are not skipped
# during decoding. Even though they are not marked as special tokens,
# setting skip_special_tokens=False ensures proper handling in
# transformers 5.x where decoding behavior may have changed.
request.skip_special_tokens = False
return request
def extract_tool_calls(
self,
model_output: str,
request: ChatCompletionRequest,
) -> ExtractedToolCallInformation:
matched_tool_calls = self.func_call_regex.findall(model_output)
logger.debug("model_output: %s", model_output)
try:
tool_calls: list[ToolCall] = []
for match in matched_tool_calls:
tc_detail = self.func_detail_regex.search(match)
if not tc_detail:
logger.warning(
"Failed to parse tool call details from: %s",
match,
)
continue
tc_name = tc_detail.group(1).strip()
tc_args = tc_detail.group(2)
pairs = self.func_arg_regex.findall(tc_args) if tc_args else []
arg_dct: dict[str, Any] = {}
for key, value in pairs:
arg_key = key.strip()
if self._is_string_type(tc_name, arg_key):
arg_val = value
else:
arg_val = self._deserialize(value.strip())
logger.debug("arg_key = %s, arg_val = %s", arg_key, arg_val)
arg_dct[arg_key] = arg_val
tool_calls.append(
ToolCall(
type="function",
function=FunctionCall(
name=tc_name,
arguments=json.dumps(arg_dct, ensure_ascii=False),
),
)
)
except Exception:
logger.exception("Failed to extract tool call spec")
return ExtractedToolCallInformation(
tools_called=False, tool_calls=[], content=model_output
)
else:
if len(tool_calls) > 0:
content: str | None = model_output[
: model_output.find(self.tool_calls_start_token)
]
# Normalize empty/whitespace-only content to None
if not content or not content.strip():
content = None
return ExtractedToolCallInformation(
tools_called=True, tool_calls=tool_calls, content=content
)
return ExtractedToolCallInformation(
tools_called=False, tool_calls=[], content=model_output
)
def _extract_content(self, current_text: str) -> str | None:
"""Return unsent non-tool-call text, or None.
Collects all text outside ``<tool_call>...</tool_call>`` regions,
including text between consecutive tool calls. Holds back any
suffix that could be a partial ``<tool_call>`` tag.
"""
# Build the "sendable index" — the furthest point we can send
# content up to. We scan through the text collecting segments
# that are outside tool-call regions.
content_segments: list[str] = []
pos = self._sent_content_idx
while pos < len(current_text):
start = current_text.find(self.tool_call_start_token, pos)
if start == -1:
# No more tool calls — send up to (len - partial-tag overlap)
tail = current_text[pos:]
overlap = partial_tag_overlap(tail, self.tool_call_start_token)
sendable = tail[: len(tail) - overlap] if overlap else tail
if sendable:
content_segments.append(sendable)
pos = len(current_text) - overlap
break
# Text before this <tool_call>
if start > pos:
content_segments.append(current_text[pos:start])
# Skip past the </tool_call> (or to end if incomplete)
end = current_text.find(self.tool_call_end_token, start)
if end != -1:
pos = end + len(self.tool_call_end_token)
else:
# Incomplete tool call — nothing more to send
pos = start
break
if content_segments:
self._sent_content_idx = pos
return "".join(content_segments)
# Even if no content, advance past completed tool-call regions
if pos > self._sent_content_idx:
self._sent_content_idx = pos
return None
def _extract_tool_call_regions(self, text: str) -> list[tuple[str, bool]]:
"""Extract ``(inner_text, is_complete)`` for each ``<tool_call>`` region."""
results: list[tuple[str, bool]] = []
pos = 0
while True:
start = text.find(self.tool_call_start_token, pos)
if start == -1:
break
inner_start = start + len(self.tool_call_start_token)
end = text.find(self.tool_call_end_token, inner_start)
if end != -1:
results.append((text[inner_start:end], True))
pos = end + len(self.tool_call_end_token)
else:
# Incomplete tool call — strip partial </tool_call> suffix
raw = text[inner_start:]
overlap = partial_tag_overlap(raw, self.tool_call_end_token)
if overlap:
raw = raw[:-overlap]
results.append((raw, False))
break
return results
def _extract_tool_name_from_region(self, inner_text: str) -> str | None:
"""Extract the tool name from the beginning of a tool-call region.
The name is everything before the first ``\\n`` or ``<arg_key>``.
Returns ``None`` if the name hasn't fully arrived yet.
"""
nl = inner_text.find("\n")
ak = inner_text.find(self.arg_key_start)
candidates = [i for i in [nl, ak] if i != -1]
if not candidates:
return None
cut = min(candidates)
name = inner_text[:cut].strip()
return name if name else None
def _build_args_json_so_far(
self,
tool_name: str,
inner_text: str,
is_complete: bool,
) -> str:
"""Build the JSON arguments string from the XML pairs seen so far.
For complete ``<arg_key>/<arg_value>`` pairs the value is fully
formatted. For the last argument whose ``<arg_value>`` has been
opened but not closed, the partial string content is included
(JSON-escaped, with an opening ``"`` but no closing ``"``).
The closing ``}`` is only appended when ``is_complete`` is True
(i.e. the ``</tool_call>`` tag has arrived).
"""
# Find all complete arg pairs
pairs = self.func_arg_regex.findall(inner_text)
parts: list[str] = []
for key, value in pairs:
key = key.strip()
key_json = json.dumps(key, ensure_ascii=False)
if self._is_string_type(tool_name, key):
# Don't strip string values — whitespace is significant
# and must match the partial-value path for diffing.
val_json = json.dumps(value, ensure_ascii=False)
else:
val_json = json.dumps(
self._deserialize(value.strip()), ensure_ascii=False
)
parts.append(f"{key_json}: {val_json}")
# Check for a partial (incomplete) arg value
# Find the last <arg_value> that isn't closed
last_val_start = inner_text.rfind(self.arg_val_start)
last_val_end = inner_text.rfind(self.arg_val_end)
has_partial_value = last_val_start != -1 and (
last_val_end == -1 or last_val_end < last_val_start
)
if has_partial_value:
# Find the key for this partial value
# Look for the last <arg_key>...</arg_key> before this <arg_value>
last_key_match = None
for m in self._arg_key_pattern.finditer(inner_text[:last_val_start]):
last_key_match = m
if last_key_match:
partial_key = last_key_match.group(1).strip()
partial_content_start = last_val_start + len(self.arg_val_start)
partial_content = inner_text[partial_content_start:]
# Hold back any partial </arg_value> suffix
overlap = partial_tag_overlap(partial_content, self.arg_val_end)
if overlap:
partial_content = partial_content[:-overlap]
key_json = json.dumps(partial_key, ensure_ascii=False)
if is_complete:
# Tool call finished but </arg_value> is missing
# (malformed output). Treat partial as complete value
# so the diff naturally closes any open quotes.
if self._is_string_type(tool_name, partial_key):
val_json = json.dumps(partial_content, ensure_ascii=False)
else:
val_json = json.dumps(
self._deserialize(partial_content.strip()),
ensure_ascii=False,
)
parts.append(f"{key_json}: {val_json}")
elif self._is_string_type(tool_name, partial_key):
escaped = self._json_escape_string_content(partial_content)
# Open quote but no close — more content may arrive
parts.append(f'{key_json}: "{escaped}')
else:
# Non-string partial: include raw content, no wrapping
parts.append(f"{key_json}: {partial_content}")
if not parts:
return "{}" if is_complete else ""
joined = "{" + ", ".join(parts)
if is_complete:
joined += "}"
return joined
def _compute_args_diff(self, index: int, args_so_far: str) -> str | None:
"""Return new argument text not yet sent for tool *index*, or None."""
if not args_so_far or len(args_so_far) <= len(
self.streamed_args_for_tool[index]
):
return None
diff = args_so_far[len(self.streamed_args_for_tool[index]) :]
self.streamed_args_for_tool[index] = args_so_far
self.prev_tool_call_arr[index]["arguments"] = args_so_far
return diff
def _ensure_tool_state_for(self, index: int) -> None:
"""Grow state arrays so that *index* is valid."""
while len(self._tool_call_ids) <= index:
self._tool_call_ids.append(
make_tool_call_id(id_type="random", func_name=None, idx=None)
)
while len(self.streamed_args_for_tool) <= index:
self.streamed_args_for_tool.append("")
while len(self.prev_tool_call_arr) <= index:
self.prev_tool_call_arr.append({})
def extract_tool_calls_streaming(
self,
previous_text: str,
current_text: str,
delta_text: str,
previous_token_ids: Sequence[int],
current_token_ids: Sequence[int],
delta_token_ids: Sequence[int],
request: ChatCompletionRequest,
) -> DeltaMessage | None:
if not self._tools_enabled(request):
return DeltaMessage(content=delta_text) if delta_text else None
content = self._extract_content(current_text)
regions = self._extract_tool_call_regions(current_text)
tool_call_deltas: list[DeltaToolCall] = []
for i, (inner_text, is_complete) in enumerate(regions):
self._ensure_tool_state_for(i)
# Extract tool name
tool_name = self._extract_tool_name_from_region(inner_text)
if not tool_name:
break
# Emit tool name (once per tool call)
if "name" not in self.prev_tool_call_arr[i]:
self.prev_tool_call_arr[i]["name"] = tool_name
tool_call_deltas.append(
DeltaToolCall(
index=i,
id=self._tool_call_ids[i],
type="function",
function=DeltaFunctionCall(
name=tool_name,
arguments="",
).model_dump(exclude_none=True),
)
)
# Build args JSON so far, diff, emit
args_so_far = self._build_args_json_so_far(
tool_name, inner_text, is_complete
)
diff = self._compute_args_diff(i, args_so_far)
if diff:
tool_call_deltas.append(
DeltaToolCall(
index=i,
function=DeltaFunctionCall(arguments=diff).model_dump(
exclude_none=True
),
)
)
# Update current_tool_id for serving layer compatibility
if regions:
self.current_tool_id = len(regions) - 1
if content or tool_call_deltas:
return DeltaMessage(
content=content,
tool_calls=tool_call_deltas,
)
return None
+35 -3
View File
@@ -3,6 +3,7 @@
import ast
import json
import math
import warnings
from json import JSONDecodeError, JSONDecoder
from typing import Any, TypeAlias
@@ -145,6 +146,20 @@ def is_complete_json(input_str: str) -> bool:
return False
def _is_json_finite(obj: Any) -> bool:
"""Whether *obj* can be serialized to valid JSON.
``json.dumps(..., allow_nan=False)`` raises ``ValueError`` on any
non-finite float (``inf``/``-inf``/``nan``) anywhere in the value, so this
detects non-finite floats nested inside parsed lists/dicts too.
"""
try:
json.dumps(obj, allow_nan=False)
return True
except (ValueError, TypeError):
return False
def consume_space(i: int, s: str) -> int:
while i < len(s) and s[i].isspace():
i += 1
@@ -601,9 +616,15 @@ def coerce_to_schema_type(value: str, schema_type: str | list[str]) -> Any:
if candidate_type == "number":
try:
val = float(value)
return val if val != int(val) else int(val)
except (ValueError, TypeError):
continue
if not math.isfinite(val):
# inf/-inf/nan are not valid JSON numbers. Fall through so
# the value is preserved as a string instead of crashing
# (int(float("inf")) raises OverflowError) or emitting
# invalid JSON (json.dumps(inf) -> "Infinity").
continue
return val if val != int(val) else int(val)
if candidate_type == "boolean":
lower_val = value.lower().strip()
if lower_val in ("true", "1"):
@@ -613,14 +634,25 @@ def coerce_to_schema_type(value: str, schema_type: str | list[str]) -> Any:
continue
if candidate_type in ("object", "array"):
try:
return json.loads(value)
parsed = json.loads(value)
except (json.JSONDecodeError, ValueError, TypeError):
continue
if _is_json_finite(parsed):
return parsed
# Non-finite floats (e.g. "[1e999]" -> [inf]) cannot be
# serialized back to valid JSON; preserve the raw string.
continue
try:
return json.loads(value)
parsed = json.loads(value)
except (json.JSONDecodeError, ValueError):
return value
# Reject non-finite results (e.g. json.loads("1e999") -> inf, or nested
# inf/nan inside a parsed list/dict) which json.dumps would render as
# invalid JSON (Infinity/NaN). Preserve the raw string instead.
if not _is_json_finite(parsed):
return value
return parsed
def compute_tool_delta(
+3
View File
@@ -84,10 +84,13 @@ class FileMapper:
]
# Only a single full-attention group is parallelism-invariant. MLA is
# excluded: its latent KV is replicated per rank, never head-sharded.
# The V2 model runner is excluded: its KV layout is not known to be
# parallelism-invariant.
groups = kv_cache_config.kv_cache_groups
spec = groups[0].kv_cache_spec if len(groups) == 1 else None
parallel_agnostic = (
parallel_agnostic
and not vllm_config.use_v2_model_runner
and isinstance(spec, FullAttentionSpec)
and not isinstance(spec, MLAAttentionSpec)
)