forked from Karylab-cklius/vllm
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78d2334aab | ||
|
|
b53b1c7ffe | ||
|
|
79ca54d221 | ||
|
|
09f3cd5c10 | ||
|
|
ea6078fe6a | ||
|
|
a0df04e477 | ||
|
|
e2352c2974 | ||
|
|
25faa1f4cc | ||
|
|
4583630b56 | ||
|
|
21da47dabe | ||
|
|
6c379b9e54 | ||
|
|
5099474633 | ||
|
|
058cc0a8b6 | ||
|
|
837db7605e | ||
|
|
7b375c8502 |
+14
-16
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
# testing
|
||||
pytest
|
||||
tensorizer==2.10.1
|
||||
tensorizer==2.12.1
|
||||
pytest-forked
|
||||
pytest-asyncio
|
||||
pytest-rerunfailures
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
# testing
|
||||
pytest
|
||||
tensorizer==2.10.1
|
||||
tensorizer==2.12.1
|
||||
pytest-forked
|
||||
pytest-asyncio
|
||||
pytest-rerunfailures
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
# testing
|
||||
pytest
|
||||
tensorizer==2.10.1
|
||||
tensorizer==2.12.1
|
||||
pytest-forked
|
||||
pytest-asyncio
|
||||
pytest-rerunfailures
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
+2
-2
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user