A high-throughput and memory-efficient inference and serving engine for LLMs
Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git
Synced Nov 9, 03:45 UTC
[Models] Step-3.5-Flash (#33523)
Signed-off-by: Jee Jee Li <pandaleefree@gmail.com> Co-authored-by: i-zhangmingming <i-zhangmingming@stepfun.com> Co-authored-by: xiewuxun <xiewuxun@stepfun.com> Co-authored-by: zetaohong <i-hongzetao@stepfun.com> Co-authored-by: Jee Jee Li <pandaleefree@gmail.com>
csy0225 committed
Feb 2, 2026 at 10:21 UTC
c3b40dc3e74dc0f552f76a01ae38b4f1385ad0af
18 files changed
+3107
-4
docs/models/supported_models.md
+1
@@ -456,6 +456,7 @@ th {
456
| `StableLmForCausalLM` | StableLM | `stabilityai/stablelm-3b-4e1t`, `stabilityai/stablelm-base-alpha-7b-v2`, etc. | | |
457
| `Starcoder2ForCausalLM` | Starcoder2 | `bigcode/starcoder2-3b`, `bigcode/starcoder2-7b`, `bigcode/starcoder2-15b`, etc. | | ✅︎ |
458
| `Step1ForCausalLM` | Step-Audio | `stepfun-ai/Step-Audio-EditX`, etc. | ✅︎ | ✅︎ |
459
+| `Step3p5ForCausalLM` | Step-3.5-flash | `stepfun-ai/step-3.5-flash`, etc. | | ✅︎ |
460
| `TeleChat2ForCausalLM` | TeleChat2 | `Tele-AI/TeleChat2-3B`, `Tele-AI/TeleChat2-7B`, `Tele-AI/TeleChat2-35B`, etc. | ✅︎ | ✅︎ |
461
| `TeleFLMForCausalLM` | TeleFLM | `CofeAI/FLM-2-52B-Instruct-2407`, `CofeAI/Tele-FLM`, etc. | ✅︎ | ✅︎ |
462
| `XverseForCausalLM` | XVERSE | `xverse/XVERSE-7B-Chat`, `xverse/XVERSE-13B-Chat`, `xverse/XVERSE-65B-Chat`, etc. | ✅︎ | ✅︎ |
tests/kernels/core/test_activation.py
+8
-2
@@ -17,6 +17,8 @@ from vllm.model_executor.layers.activation import (
17
QuickGELU,
18
SiluAndMul,
19
SwigluOAIAndMul,
20
+ SwigluStepAndMul,
21
+ swiglustep_and_mul_triton,
22
)
23
from vllm.utils.torch_utils import set_random_seed
24
@@ -36,6 +38,7 @@ CUDA_DEVICES = [f"cuda:{i}" for i in range(1 if torch.cuda.device_count() == 1 e
38
"gelu_tanh",
39
"fatrelu",
40
"swigluoai_and_mul",
41
+ "swiglustep_and_mul",
42
],
43
)
44
@pytest.mark.parametrize("num_tokens", NUM_TOKENS)
@@ -75,9 +78,12 @@ def test_act_and_mul(
78
elif activation == "swigluoai_and_mul":
79
layer = SwigluOAIAndMul()
80
fn = torch.ops._C.swigluoai_and_mul
81
+ elif activation == "swiglustep_and_mul":
82
+ layer = SwigluStepAndMul()
83
+ fn = swiglustep_and_mul_triton
84
out = layer(x)
85
ref_out = layer.forward_native(x)
80
- if activation == "swigluoai_and_mul":
86
+ if activation in ["swigluoai_and_mul", "swiglustep_and_mul"]:
87
rtol = {
88
# For fp16, change the relative tolerance from 1e-3 to 2e-3
89
torch.float16: 2e-3,
@@ -104,7 +110,7 @@ def test_act_and_mul(
110
opcheck(fn, (out, x, threshold))
111
elif activation == "swigluoai_and_mul":
112
opcheck(fn, (out, x, layer.alpha, layer.limit))
107
- else:
113
+ elif activation != "swiglustep_and_mul":
114
opcheck(fn, (out, x))
115
116
tests/models/registry.py
+9
@@ -484,6 +484,9 @@ _TEXT_GENERATION_EXAMPLE_MODELS = {
484
"Step1ForCausalLM": _HfExamplesInfo(
485
"stepfun-ai/Step-Audio-EditX", trust_remote_code=True
486
),
487
+ "Step3p5ForCausalLM": _HfExamplesInfo(
488
+ "stepfun-ai/step-3.5-flash", is_available_online=False
489
+ ),
490
"SmolLM3ForCausalLM": _HfExamplesInfo("HuggingFaceTB/SmolLM3-3B"),
491
"StableLMEpochForCausalLM": _HfExamplesInfo("stabilityai/stablelm-zephyr-3b"),
492
"StableLmForCausalLM": _HfExamplesInfo("stabilityai/stablelm-3b-4e1t"),
@@ -1113,6 +1116,12 @@ _SPECULATIVE_DECODING_EXAMPLE_MODELS = {
1116
"Qwen3NextMTP": _HfExamplesInfo(
1117
"Qwen/Qwen3-Next-80B-A3B-Instruct", min_transformers_version="4.56.3"
1118
),
1119
+ "Step3p5MTP": _HfExamplesInfo(
1120
+ "stepfun-ai/Step-3.5-Flash",
1121
+ trust_remote_code=True,
1122
+ speculative_model="stepfun-ai/Step-3.5-Flash",
1123
+ is_available_online=False,
1124
+ ),
1125
}
1126
1127
_TRANSFORMERS_BACKEND_MODELS = {
vllm/config/speculative.py
+6
@@ -41,6 +41,7 @@ MTPModelTypes = Literal[
41
"longcat_flash_mtp",
42
"mtp",
43
"pangu_ultra_moe_mtp",
44
+ "step3p5_mtp",
45
]
46
EagleModelTypes = Literal["eagle", "eagle3", MTPModelTypes]
47
SpeculativeMethod = Literal[
@@ -264,6 +265,11 @@ class SpeculativeConfig:
265
{"n_predict": n_predict, "architectures": ["LongCatFlashMTPModel"]}
266
)
267
268
+ if hf_config.model_type == "step3p5":
269
+ hf_config.model_type = "step3p5_mtp"
270
+ n_predict = getattr(hf_config, "num_nextn_predict_layers", 1)
271
+ hf_config.update({"n_predict": n_predict, "architectures": ["Step3p5MTP"]})
272
+
273
if initial_architecture == "MistralLarge3ForCausalLM":
274
hf_config.update({"architectures": ["EagleMistralLarge3ForCausalLM"]})
275
vllm/model_executor/layers/activation.py
+90
@@ -17,11 +17,63 @@ from vllm.logger import init_logger
17
from vllm.model_executor.custom_op import CustomOp
18
from vllm.model_executor.utils import set_weight_attrs
19
from vllm.platforms import current_platform
20
+from vllm.triton_utils import tl, triton
21
from vllm.utils.collection_utils import LazyDict
22
23
logger = init_logger(__name__)
24
25
26
+@triton.jit
27
+def _swiglustep_and_mul_kernel(
28
+ o_ptr,
29
+ o_stride,
30
+ x_ptr,
31
+ x_stride,
32
+ limit: tl.constexpr,
33
+ d: tl.constexpr,
34
+ BLOCK_SIZE: tl.constexpr,
35
+) -> None:
36
+ i = tl.program_id(axis=0).to(tl.int64)
37
+ j = tl.program_id(axis=1)
38
+ o_row_ptr = o_ptr + o_stride * i
39
+ x_row_ptr = x_ptr + x_stride * i
40
+ offsets = j * BLOCK_SIZE + tl.arange(0, BLOCK_SIZE)
41
+ mask = offsets < d
42
+
43
+ gate = tl.load(x_row_ptr + offsets, mask=mask).to(tl.float32)
44
+ up = tl.load(x_row_ptr + offsets + d, mask=mask).to(tl.float32)
45
+
46
+ gate_silu = tl.sigmoid(gate) * gate
47
+ gate_clamped = tl.minimum(gate_silu, limit)
48
+ up_clamped = tl.minimum(tl.maximum(up, -limit), limit)
49
+
50
+ result = gate_clamped * up_clamped
51
+ result = result.to(x_ptr.dtype.element_ty)
52
+ tl.store(o_row_ptr + offsets, result, mask=mask)
53
+
54
+
55
+def swiglustep_and_mul_triton(
56
+ output: torch.Tensor, input: torch.Tensor, limit: float = 7.0
57
+):
58
+ b, n = input.shape
59
+ assert input.ndim == 2
60
+ assert n % 2 == 0
61
+ d = n // 2
62
+
63
+ def grid(meta):
64
+ return (b, triton.cdiv(d, meta["BLOCK_SIZE"]))
65
+
66
+ _swiglustep_and_mul_kernel[grid](
67
+ output,
68
+ output.stride(0),
69
+ input,
70
+ input.stride(0),
71
+ limit=limit,
72
+ d=d,
73
+ BLOCK_SIZE=1024,
74
+ )
75
+
76
+
77
# --8<-- [start:fatrelu_and_mul]
78
@CustomOp.register("fatrelu_and_mul")
79
class FatreluAndMul(CustomOp):
@@ -304,6 +356,44 @@ class SwigluOAIAndMul(CustomOp):
356
return f"alpha={repr(self.alpha)}, limit={repr(self.limit)}"
357
358
359
+# --8<-- [start:swiglustep_and_mul]
360
+@CustomOp.register("swiglustep_and_mul")
361
+class SwigluStepAndMul(CustomOp):
362
+ """An activation function for SwiGLU with clamping.
363
+
364
+ Computes x -> silu(x[:d]).clamp(max=limit) * x[d:].clamp(-limit, limit)
365
+ where d = x.shape[-1] // 2.
366
+
367
+ Shapes:
368
+ x: (num_tokens, 2 * d) or (batch_size, seq_len, 2 * d)
369
+ return: (num_tokens, d) or (batch_size, seq_len, d)
370
+ """
371
+
372
+ def __init__(self, limit: float = 7.0):
373
+ super().__init__()
374
+ if limit is None:
375
+ raise ValueError("SwigluStepAndMul requires limit to be set.")
376
+ self.limit = limit
377
+
378
+ def forward_native(self, x: torch.Tensor) -> torch.Tensor:
379
+ """PyTorch-native implementation equivalent to forward()."""
380
+ gate, up = x.chunk(2, dim=-1)
381
+ gate = F.silu(gate)
382
+ gate = gate.clamp(max=self.limit)
383
+ up = up.clamp(min=-self.limit, max=self.limit)
384
+ return gate * up
385
+
386
+ def forward_cuda(self, x: torch.Tensor) -> torch.Tensor:
387
+ d = x.shape[-1] // 2
388
+ output_shape = x.shape[:-1] + (d,)
389
+ out = torch.empty(output_shape, dtype=x.dtype, device=x.device)
390
+ swiglustep_and_mul_triton(out, x, self.limit)
391
+ return out
392
+
393
+ def extra_repr(self) -> str:
394
+ return f"limit={repr(self.limit)}"
395
+
396
+
397
# --8<-- [start:gelu_new]
398
@CustomOp.register("gelu_new")
399
class NewGELU(CustomOp):
vllm/model_executor/layers/fused_moe/deep_gemm_moe.py
+1
-1
@@ -144,7 +144,7 @@ class DeepGemmExperts(mk.FusedMoEPermuteExpertsUnpermute):
144
145
@staticmethod
146
def _supports_activation(activation: str) -> bool:
147
- return activation in ["silu"]
147
+ return activation in ["silu", "swiglustep"]
148
149
@staticmethod
150
def _supports_parallel_config(moe_parallel_config: FusedMoEParallelConfig) -> bool:
vllm/model_executor/layers/fused_moe/fused_moe.py
+1
-1
@@ -1939,7 +1939,7 @@ class TritonExperts(mk.FusedMoEPermuteExpertsUnpermute):
1939
1940
@staticmethod
1941
def _supports_activation(activation: str) -> bool:
1942
- return activation in ["silu", "gelu", "swigluoai"]
1942
+ return activation in ["silu", "gelu", "swigluoai", "swiglustep"]
1943
1944
@staticmethod
1945
def _supports_parallel_config(moe_parallel_config: FusedMoEParallelConfig) -> bool:
vllm/model_executor/layers/fused_moe/utils.py
+5
@@ -358,6 +358,11 @@ def apply_moe_activation(
358
torch.ops._C.gelu_and_mul(output, input)
359
elif activation == "swigluoai":
360
torch.ops._C.swigluoai_and_mul(output, input)
361
+ elif activation == "swiglustep":
362
+ from vllm.model_executor.layers.activation import swiglustep_and_mul_triton
363
+
364
+ swiglustep_and_mul_triton(output, input)
365
+
366
# Activations without gated multiplication
367
elif activation == SILU_NO_MUL:
368
output.copy_(F.silu(input))
vllm/model_executor/models/registry.py
+2
@@ -189,6 +189,7 @@ _TEXT_GENERATION_MODELS = {
189
"SeedOssForCausalLM": ("seed_oss", "SeedOssForCausalLM"),
190
"Step1ForCausalLM": ("step1", "Step1ForCausalLM"),
191
"Step3TextForCausalLM": ("step3_text", "Step3TextForCausalLM"),
192
+ "Step3p5ForCausalLM": ("step3p5", "Step3p5ForCausalLM"),
193
"StableLMEpochForCausalLM": ("stablelm", "StablelmForCausalLM"),
194
"StableLmForCausalLM": ("stablelm", "StablelmForCausalLM"),
195
"Starcoder2ForCausalLM": ("starcoder2", "Starcoder2ForCausalLM"),
@@ -495,6 +496,7 @@ _SPECULATIVE_DECODING_MODELS = {
496
"MedusaModel": ("medusa", "Medusa"),
497
"OpenPanguMTPModel": ("openpangu_mtp", "OpenPanguMTP"),
498
"Qwen3NextMTP": ("qwen3_next_mtp", "Qwen3NextMTP"),
499
+ "Step3p5MTP": ("step3p5_mtp", "Step3p5MTP"),
500
# Temporarily disabled.
501
# # TODO(woosuk): Re-enable this once the MLP Speculator is supported in V1.
502
# "MLPSpeculatorPreTrainedModel": ("mlp_speculator", "MLPSpeculator"),
vllm/model_executor/models/step3p5.py
new
+894
@@ -0,0 +1,894 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Inference-only Jurassic model."""
4
+
5
+from collections.abc import Iterable
6
+from typing import Any
7
+
8
+import torch
9
+from torch import nn
10
+from torch.nn.parameter import Parameter
11
+
12
+from vllm.compilation.decorators import support_torch_compile
13
+from vllm.config import CacheConfig, ModelConfig, VllmConfig
14
+from vllm.distributed import (
15
+ get_dp_group,
16
+ get_ep_group,
17
+ get_pp_group,
18
+ get_tensor_model_parallel_rank,
19
+ get_tensor_model_parallel_world_size,
20
+ get_tp_group,
21
+)
22
+from vllm.logger import init_logger
23
+from vllm.model_executor.layers.activation import SiluAndMul, SwigluStepAndMul
24
+from vllm.model_executor.layers.attention import Attention
25
+from vllm.model_executor.layers.fused_moe import FusedMoE
26
+from vllm.model_executor.layers.fused_moe.shared_fused_moe import SharedFusedMoE
27
+from vllm.model_executor.layers.layernorm import GemmaRMSNorm
28
+from vllm.model_executor.layers.linear import (
29
+ ColumnParallelLinear,
30
+ MergedColumnParallelLinear,
31
+ QKVParallelLinear,
32
+ ReplicatedLinear,
33
+ RowParallelLinear,
34
+)
35
+from vllm.model_executor.layers.logits_processor import LogitsProcessor
36
+from vllm.model_executor.layers.quantization.base_config import QuantizationConfig
37
+from vllm.model_executor.layers.rotary_embedding import get_rope
38
+from vllm.model_executor.layers.vocab_parallel_embedding import (
39
+ DEFAULT_VOCAB_PADDING_SIZE,
40
+ ParallelLMHead,
41
+ VocabParallelEmbedding,
42
+)
43
+from vllm.model_executor.model_loader.weight_utils import default_weight_loader
44
+from vllm.sequence import IntermediateTensors
45
+from vllm.v1.attention.backend import AttentionType
46
+
47
+from .interfaces import MixtureOfExperts, SupportsPP
48
+from .utils import (
49
+ AutoWeightsLoader,
50
+ PPMissingLayer,
51
+ WeightsMapper,
52
+ extract_layer_index,
53
+ is_pp_missing_parameter,
54
+ make_empty_intermediate_tensors_factory,
55
+ make_layers,
56
+ maybe_prefix,
57
+)
58
+
59
+logger = init_logger(__name__)
60
+
61
+
62
+class FP32ReplicatedLinear(ReplicatedLinear):
63
+ """
64
+ Use FP32 for higher precision.
65
+ """
66
+
67
+ def forward(
68
+ self,
69
+ x: torch.Tensor,
70
+ ) -> torch.Tensor | tuple[torch.Tensor, Parameter | None]:
71
+ assert self.params_dtype == torch.float32
72
+ return super().forward(x.to(torch.float32))
73
+
74
+
75
+class Step3p5MLP(nn.Module):
76
+ def __init__(
77
+ self,
78
+ config: ModelConfig,
79
+ hidden_size: int,
80
+ intermediate_size: int,
81
+ hidden_act: str,
82
+ quant_config: QuantizationConfig | None = None,
83
+ reduce_results: bool = True,
84
+ prefix: str = "",
85
+ ) -> None:
86
+ super().__init__()
87
+ self.gate_up_proj = MergedColumnParallelLinear(
88
+ hidden_size,
89
+ [intermediate_size] * 2,
90
+ bias=False,
91
+ quant_config=quant_config,
92
+ prefix=f"{prefix}.gate_up_proj",
93
+ )
94
+ self.down_proj = RowParallelLinear(
95
+ intermediate_size,
96
+ hidden_size,
97
+ bias=False,
98
+ quant_config=quant_config,
99
+ reduce_results=reduce_results,
100
+ prefix=f"{prefix}.down_proj",
101
+ )
102
+
103
+ if hidden_act != "silu":
104
+ raise ValueError(
105
+ f"Unsupported activation: {hidden_act}. Only silu is supported for now."
106
+ )
107
+ self.act_fn = SiluAndMul()
108
+ self.prefix = prefix
109
+ self.hidden_size = hidden_size
110
+ self.limit = None
111
+ layer_idx = extract_layer_index(prefix)
112
+ if (
113
+ config.swiglu_limits_shared
114
+ and config.swiglu_limits_shared[layer_idx] is not None
115
+ and config.swiglu_limits_shared[layer_idx] != 0
116
+ ):
117
+ self.limit = config.swiglu_limits_shared[layer_idx]
118
+ self.act_fn = SwigluStepAndMul(limit=self.limit)
119
+
120
+ def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
121
+ gate_up, _ = self.gate_up_proj(hidden_states)
122
+ intermediate_act = self.act_fn(gate_up)
123
+ output, _ = self.down_proj(intermediate_act)
124
+ return output
125
+
126
+
127
+class Step3p5Attention(nn.Module):
128
+ def __init__(
129
+ self,
130
+ hidden_size: int,
131
+ num_heads: int,
132
+ num_kv_heads: int,
133
+ max_position: int = 4096 * 32,
134
+ head_dim: int | None = None,
135
+ rms_norm_eps: float = 1e-06,
136
+ qkv_bias: bool = False,
137
+ rope_theta: float | list[float] | None = 10000,
138
+ cache_config: CacheConfig | None = None,
139
+ quant_config: QuantizationConfig | None = None,
140
+ rope_scaling: dict[str, Any] | None = None,
141
+ prefix: str = "",
142
+ attn_type: str = AttentionType.DECODER,
143
+ # Step3p5 specific args
144
+ sliding_window: int | None = None,
145
+ use_head_wise_attn_gate: bool = False,
146
+ layer_types: list = None,
147
+ use_rope_layers: list = None,
148
+ yarn_only_types: list = None,
149
+ swa_num_attention_heads: int | None = None,
150
+ partial_rotary_factor: float = 1.0,
151
+ ):
152
+ super().__init__()
153
+ self.hidden_size = hidden_size
154
+ self.total_num_heads = num_heads
155
+ tp_size = get_tensor_model_parallel_world_size()
156
+ self.layer_idx = extract_layer_index(prefix)
157
+ if layer_types:
158
+ enable_sliding_window = layer_types[self.layer_idx] == "sliding_attention"
159
+ else:
160
+ enable_sliding_window = self.layer_idx % 2 == 0
161
+ if yarn_only_types and layer_types[self.layer_idx] not in yarn_only_types:
162
+ rope_scaling = None
163
+
164
+ if sliding_window is not None and enable_sliding_window:
165
+ sliding_window = sliding_window
166
+ if swa_num_attention_heads is not None:
167
+ num_heads = swa_num_attention_heads
168
+ self.total_num_heads = swa_num_attention_heads
169
+ else:
170
+ sliding_window = None
171
+
172
+ if isinstance(rope_theta, list):
173
+ rope_theta = rope_theta[self.layer_idx]
174
+
175
+ self.rank = get_tensor_model_parallel_rank()
176
+ self.partial_rotary_factor = partial_rotary_factor
177
+ assert self.total_num_heads % tp_size == 0
178
+ self.num_heads = self.total_num_heads // tp_size
179
+ self.total_num_kv_heads = num_kv_heads
180
+ if self.total_num_kv_heads >= tp_size:
181
+ # Number of KV heads is greater than TP size, so we partition
182
+ # the KV heads across multiple tensor parallel GPUs.
183
+ assert self.total_num_kv_heads % tp_size == 0
184
+ else:
185
+ # Number of KV heads is less than TP size, so we replicate
186
+ # the KV heads across multiple tensor parallel GPUs.
187
+ assert tp_size % self.total_num_kv_heads == 0
188
+ self.num_kv_heads = max(1, self.total_num_kv_heads // tp_size)
189
+ self.head_dim = head_dim or hidden_size // self.total_num_heads
190
+ self.q_size = self.num_heads * self.head_dim
191
+ self.kv_size = self.num_kv_heads * self.head_dim
192
+ self.scaling = self.head_dim**-0.5
193
+ self.rope_theta = rope_theta
194
+ self.qkv_proj = QKVParallelLinear(
195
+ hidden_size,
196
+ self.head_dim,
197
+ self.total_num_heads,
198
+ self.total_num_kv_heads,
199
+ bias=qkv_bias,
200
+ quant_config=quant_config,
201
+ prefix=f"{prefix}.qkv_proj",
202
+ )
203
+ self.o_proj = RowParallelLinear(
204
+ self.total_num_heads * self.head_dim,
205
+ hidden_size,
206
+ bias=False,
207
+ quant_config=quant_config,
208
+ prefix=f"{prefix}.o_proj",
209
+ )
210
+
211
+ if rope_scaling is not None and not isinstance(rope_scaling, dict):
212
+ raise ValueError("rope_scaling must be a dict for Step3p5Attention.")
213
+
214
+ rope_parameters: dict[str, Any] = (
215
+ dict(rope_scaling) if rope_scaling is not None else {}
216
+ )
217
+ rope_parameters.setdefault("rope_type", "default")
218
+ rope_parameters["rope_theta"] = self.rope_theta
219
+ rope_parameters["partial_rotary_factor"] = partial_rotary_factor
220
+
221
+ self.rotary_emb = get_rope(
222
+ head_size=self.head_dim,
223
+ max_position=max_position,
224
+ rope_parameters=rope_parameters,
225
+ )
226
+
227
+ self.q_norm = GemmaRMSNorm(self.head_dim, rms_norm_eps)
228
+ self.k_norm = GemmaRMSNorm(self.head_dim, rms_norm_eps)
229
+ self.use_head_wise_attn_gate = use_head_wise_attn_gate
230
+ if use_head_wise_attn_gate:
231
+ self.g_proj = ColumnParallelLinear(
232
+ hidden_size,
233
+ self.total_num_heads,
234
+ bias=False,
235
+ prefix=f"{prefix}.g_proj",
236
+ )
237
+
238
+ self.use_rope = True
239
+ if use_rope_layers:
240
+ self.use_rope = use_rope_layers[self.layer_idx]
241
+
242
+ self.attn = Attention(
243
+ self.num_heads,
244
+ self.head_dim,
245
+ self.scaling,
246
+ num_kv_heads=self.num_kv_heads,
247
+ cache_config=cache_config,
248
+ quant_config=quant_config,
249
+ prefix=f"{prefix}.attn",
250
+ per_layer_sliding_window=sliding_window,
251
+ attn_type=attn_type,
252
+ )
253
+
254
+ self.max_position_embeddings = max_position
255
+ assert self.partial_rotary_factor == 1 or self.partial_rotary_factor == 0.5
256
+ self.rotary_dim = (
257
+ self.head_dim if self.partial_rotary_factor == 1 else self.head_dim // 2
258
+ )
259
+
260
+ def forward(
261
+ self,
262
+ positions: torch.Tensor,
263
+ hidden_states: torch.Tensor,
264
+ ) -> torch.Tensor:
265
+ qkv, _ = self.qkv_proj(hidden_states)
266
+ q, k, v = qkv.split([self.q_size, self.kv_size, self.kv_size], dim=-1)
267
+ # Add qk-norm inline similar to Qwen3 MOE attention
268
+ q_by_head = q.view(*q.shape[:-1], q.shape[-1] // self.head_dim, self.head_dim)
269
+ q_by_head = self.q_norm(q_by_head.contiguous())
270
+ q = q_by_head.view(q.shape)
271
+
272
+ k_by_head = k.view(*k.shape[:-1], k.shape[-1] // self.head_dim, self.head_dim)
273
+ k_by_head = self.k_norm(k_by_head.contiguous())
274
+ k = k_by_head.view(k.shape)
275
+ if self.use_rope:
276
+ q, k = self.rotary_emb(positions, q, k)
277
+ attn_output = self.attn(q, k, v)
278
+ if self.use_head_wise_attn_gate:
279
+ extra_dims, _ = self.g_proj(hidden_states)
280
+ output = (
281
+ attn_output.view(*attn_output.shape[:-1], self.num_heads, self.head_dim)
282
+ * extra_dims.unsqueeze(-1).sigmoid()
283
+ )
284
+ attn_output = output.view(*attn_output.shape)
285
+ output, _ = self.o_proj(attn_output)
286
+ return output
287
+
288
+
289
+class FusedMoEBlock(nn.Module):
290
+ def __init__(
291
+ self,
292
+ vllm_config: VllmConfig,
293
+ prefix: str = "",
294
+ ):
295
+ super().__init__()
296
+
297
+ self.tp_size = get_tensor_model_parallel_world_size()
298
+ self.layer_idx = extract_layer_index(prefix)
299
+
300
+ self.ep_size = get_ep_group().device_group.size()
301
+ self.ep_rank = get_ep_group().device_group.rank()
302
+ config = vllm_config.model_config.hf_config
303
+ quant_config = vllm_config.quant_config
304
+ parallel_config = vllm_config.parallel_config
305
+
306
+ self.hidden_size = config.hidden_size
307
+ self.enable_eplb = parallel_config.enable_eplb
308
+ self.n_routed_experts = config.moe_num_experts
309
+ self.n_logical_experts = self.n_routed_experts
310
+ self.n_redundant_experts = parallel_config.eplb_config.num_redundant_experts
311
+ self.n_physical_experts = self.n_logical_experts + self.n_redundant_experts
312
+ self.n_local_physical_experts = self.n_physical_experts // self.ep_size
313
+
314
+ self.physical_expert_start = self.ep_rank * self.n_local_physical_experts
315
+ self.physical_expert_end = (
316
+ self.physical_expert_start + self.n_local_physical_experts
317
+ )
318
+
319
+ if self.tp_size > config.moe_num_experts:
320
+ raise ValueError(
321
+ f"Tensor parallel size {self.tp_size} is greater than "
322
+ f"the number of experts {config.moe_num_experts}."
323
+ )
324
+
325
+ self.gate = FP32ReplicatedLinear(
326
+ config.hidden_size,
327
+ config.moe_num_experts,
328
+ bias=False,
329
+ quant_config=None,
330
+ params_dtype=torch.float32, # Use FP32 for higher precision.
331
+ prefix=f"{prefix}.gate",
332
+ )
333
+ self.use_moe_router_bias = config.use_moe_router_bias
334
+ assert self.use_moe_router_bias, "Only support use_moe_router_bias is true."
335
+ self.routed_scaling_factor = config.moe_router_scaling_factor
336
+ self.router_bias = nn.Parameter(
337
+ torch.zeros(config.moe_num_experts, dtype=torch.float32),
338
+ requires_grad=False,
339
+ )
340
+ self.need_fp32_gate = config.need_fp32_gate
341
+ assert self.need_fp32_gate, (
342
+ "Router logits must use FP32 precision for numerical stability."
343
+ )
344
+
345
+ activation = "silu"
346
+ swiglu_limits = config.swiglu_limits or []
347
+ swiglu_limit = (
348
+ swiglu_limits[self.layer_idx]
349
+ if self.layer_idx < len(swiglu_limits)
350
+ else None
351
+ )
352
+ if swiglu_limit not in (None, 0):
353
+ swiglu_limit = float(swiglu_limit)
354
+ assert swiglu_limit == 7.0, (
355
+ "Swiglu limit in fused moe block only suport 7.0 now."
356
+ )
357
+ activation = "swiglustep"
358
+ logger.debug(
359
+ "step3p5 layer_idx: %s, activation: %s, limit: %s",
360
+ self.layer_idx,
361
+ activation,
362
+ swiglu_limit,
363
+ )
364
+
365
+ self.share_expert = Step3p5MLP(
366
+ config=config,
367
+ hidden_size=self.hidden_size,
368
+ intermediate_size=config.share_expert_dim,
369
+ hidden_act="silu",
370
+ reduce_results=False,
371
+ quant_config=quant_config,
372
+ prefix=f"{prefix}.share_expert",
373
+ )
374
+ self.experts = SharedFusedMoE(
375
+ shared_experts=self.share_expert,
376
+ gate=self.gate,
377
+ num_experts=config.moe_num_experts,
378
+ top_k=config.moe_top_k,
379
+ hidden_size=config.hidden_size,
380
+ intermediate_size=config.moe_intermediate_size,
381
+ reduce_results=False,
382
+ renormalize=config.norm_expert_weight,
383
+ quant_config=quant_config,
384
+ activation=activation,
385
+ prefix=f"{prefix}.experts",
386
+ scoring_func=getattr(config, "moe_router_activation", "sigmoid"),
387
+ e_score_correction_bias=self.router_bias,
388
+ routed_scaling_factor=config.moe_router_scaling_factor,
389
+ enable_eplb=self.enable_eplb,
390
+ num_redundant_experts=self.n_redundant_experts,
391
+ )
392
+
393
+ def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
394
+ num_tokens, hidden_dim = hidden_states.shape
395
+ hidden_states = hidden_states.view(-1, hidden_dim)
396
+
397
+ if self.experts.is_internal_router:
398
+ # In this case, the gate/router runs inside the FusedMoE class
399
+ fused_moe_out = self.experts(
400
+ hidden_states=hidden_states, router_logits=hidden_states
401
+ )
402
+ else:
403
+ # router_logits: (num_tokens, n_experts)
404
+ router_logits, _ = self.gate(hidden_states)
405
+ fused_moe_out = self.experts(
406
+ hidden_states=hidden_states, router_logits=router_logits
407
+ )
408
+
409
+ shared_output, final_hidden_states = fused_moe_out
410
+ if self.share_expert is None:
411
+ assert shared_output is None
412
+
413
+ if self.share_expert is not None:
414
+ assert shared_output is not None
415
+ final_hidden_states += shared_output
416
+
417
+ if self.tp_size > 1:
418
+ final_hidden_states = self.experts.maybe_all_reduce_tensor_model_parallel(
419
+ final_hidden_states
420
+ )
421
+
422
+ return final_hidden_states.view(num_tokens, hidden_dim)
423
+
424
+
425
+class Step3p5DecoderLayer(nn.Module):
426
+ def __init__(
427
+ self,
428
+ vllm_config: VllmConfig,
429
+ prefix: str = "",
430
+ ) -> None:
431
+ super().__init__()
432
+ config = vllm_config.model_config.hf_config
433
+ self.hidden_size = config.hidden_size
434
+ layer_idx = extract_layer_index(prefix)
435
+ self.layer_idx = layer_idx
436
+ cache_config = vllm_config.cache_config
437
+ quant_config = vllm_config.quant_config
438
+ if cache_config is not None:
439
+ cache_config.sliding_window = None
440
+ if config.att_impl_type == "GQA":
441
+ num_attention_heads = None
442
+ num_attention_groups = None
443
+ head_dim = None
444
+ if (
445
+ getattr(config, "attention_other_setting", None)
446
+ and getattr(config, "layer_types", [])
447
+ and config.layer_types[layer_idx]
448
+ == config.attention_other_setting["attention_type"]
449
+ ):
450
+ num_attention_heads = config.attention_other_setting[
451
+ "num_attention_heads"
452
+ ]
453
+ num_attention_groups = config.attention_other_setting[
454
+ "num_attention_groups"
455
+ ]
456
+ head_dim = config.attention_other_setting["head_dim"]
457
+ partial_rotary_factors = getattr(config, "partial_rotary_factors", [])
458
+ self.self_attn = Step3p5Attention(
459
+ hidden_size=self.hidden_size,
460
+ num_heads=num_attention_heads
461
+ if num_attention_heads
462
+ else config.num_attention_heads,
463
+ max_position=config.max_position_embeddings,
464
+ num_kv_heads=num_attention_groups
465
+ if num_attention_groups
466
+ else config.num_attention_groups,
467
+ rope_theta=config.rope_theta,
468
+ rms_norm_eps=config.rms_norm_eps,
469
+ qkv_bias=getattr(config, "attention_bias", False),
470
+ head_dim=head_dim if head_dim else getattr(config, "head_dim", None),
471
+ cache_config=cache_config,
472
+ quant_config=quant_config,
473
+ rope_scaling=getattr(config, "rope_scaling", None),
474
+ sliding_window=getattr(config, "sliding_window", None),
475
+ use_head_wise_attn_gate=getattr(
476
+ config, "use_head_wise_attn_gate", False
477
+ ),
478
+ layer_types=getattr(config, "layer_types", []),
479
+ use_rope_layers=getattr(config, "use_rope_layers", []),
480
+ yarn_only_types=getattr(config, "yarn_only_types", []),
481
+ partial_rotary_factor=partial_rotary_factors[layer_idx]
482
+ if partial_rotary_factors
483
+ else 1.0,
484
+ prefix=f"{prefix}.self_attn",
485
+ )
486
+ else:
487
+ raise ValueError(
488
+ f"Unsupported attention implementation: {config.att_impl_type}"
489
+ )
490
+ self.use_moe = False
491
+ self.tp_group = get_tp_group()
492
+ self.use_fused_all_reduce = (
493
+ get_tensor_model_parallel_world_size() > 1
494
+ and get_dp_group().world_size == 1
495
+ )
496
+ if self.use_fused_all_reduce:
497
+ logger.warning_once("Enable custom fused all reduce...")
498
+ else:
499
+ logger.warning_once("Disable custom fused all reduce...")
500
+
501
+ moe_layers_enum = getattr(config, "moe_layers_enum", None)
502
+ if moe_layers_enum is not None:
503
+ moe_layers_idx = [int(i) for i in moe_layers_enum.strip().split(",")]
504
+ else:
505
+ moe_layers_idx = [i for i in range(1, config.num_hidden_layers)]
506
+ if layer_idx in moe_layers_idx:
507
+ self.moe = FusedMoEBlock(
508
+ vllm_config,
509
+ prefix=f"{prefix}.moe",
510
+ )
511
+ self.use_moe = True
512
+ else:
513
+ self.mlp = Step3p5MLP(
514
+ config=config,
515
+ hidden_size=config.hidden_size,
516
+ intermediate_size=config.intermediate_size,
517
+ hidden_act="silu",
518
+ quant_config=quant_config,
519
+ reduce_results=True,
520
+ prefix=f"{prefix}.mlp",
521
+ )
522
+ self.input_layernorm = GemmaRMSNorm(config.hidden_size, config.rms_norm_eps)
523
+ self.post_attention_layernorm = GemmaRMSNorm(
524
+ config.hidden_size, config.rms_norm_eps
525
+ )
526
+ self.prefix = prefix
527
+
528
+ def add_and_maybe_inplace_all_reduce(
529
+ self, in1: torch.Tensor, in2: torch.Tensor
530
+ ) -> torch.Tensor:
531
+ if not self.use_fused_all_reduce:
532
+ return in1 + in2
533
+ return self.tp_group.all_reduce(in1 + in2)
534
+
535
+ def forward(
536
+ self, positions: torch.Tensor, hidden_states: torch.Tensor
537
+ ) -> torch.Tensor:
538
+ residual = hidden_states
539
+ hidden_states = self.input_layernorm(hidden_states)
540
+
541
+ hidden_states = self.self_attn(
542
+ positions=positions,
543
+ hidden_states=hidden_states,
544
+ )
545
+ hidden_states += residual
546
+ residual = hidden_states
547
+ hidden_states = self.post_attention_layernorm(hidden_states)
548
+
549
+ if self.use_moe:
550
+ ffn_output = self.moe(hidden_states)
551
+ else:
552
+ ffn_output = self.mlp(hidden_states)
553
+ hidden_states = ffn_output + residual
554
+ return hidden_states
555
+
556
+
557
+@support_torch_compile
558
+class Step3p5Model(nn.Module):
559
+ def __init__(self, vllm_config: VllmConfig, prefix: str = "") -> None:
560
+ super().__init__()
561
+
562
+ self.vllm_config = vllm_config
563
+ config = vllm_config.model_config.hf_config
564
+ self.vocab_size = config.vocab_size
565
+ self.config = config
566
+
567
+ self.moe_num_experts = config.moe_num_experts
568
+
569
+ if get_pp_group().is_first_rank or (
570
+ config.tie_word_embeddings and get_pp_group().is_last_rank
571
+ ):
572
+ self.embed_tokens = VocabParallelEmbedding(
573
+ self.vocab_size,
574
+ config.hidden_size,
575
+ )
576
+ else:
577
+ self.embed_tokens = PPMissingLayer()
578
+
579
+ self.start_layer, self.end_layer, self.layers = make_layers(
580
+ config.num_hidden_layers,
581
+ lambda prefix: Step3p5DecoderLayer(
582
+ vllm_config,
583
+ prefix=prefix,
584
+ ),
585
+ prefix=f"{prefix}.layers",
586
+ )
587
+ if get_pp_group().is_last_rank:
588
+ self.norm = GemmaRMSNorm(config.hidden_size, config.rms_norm_eps)
589
+ else:
590
+ self.norm = PPMissingLayer()
591
+
592
+ self.make_empty_intermediate_tensors = make_empty_intermediate_tensors_factory(
593
+ ["hidden_states"], config.hidden_size
594
+ )
595
+
596
+ def embed_input_ids(self, input_ids: torch.Tensor) -> torch.Tensor:
597
+ return self.embed_tokens(input_ids)
598
+
599
+ def forward(
600
+ self,
601
+ input_ids: torch.Tensor,
602
+ positions: torch.Tensor,
603
+ intermediate_tensors: IntermediateTensors | None = None,
604
+ inputs_embeds: torch.Tensor | None = None,
605
+ ) -> torch.Tensor:
606
+ if get_pp_group().is_first_rank:
607
+ if inputs_embeds is not None:
608
+ hidden_states = inputs_embeds
609
+ else:
610
+ hidden_states = self.embed_input_ids(input_ids)
611
+ else:
612
+ assert intermediate_tensors is not None
613
+ hidden_states = intermediate_tensors["hidden_states"]
614
+ for i in range(self.start_layer, self.end_layer):
615
+ layer = self.layers[i]
616
+ hidden_states = layer(positions, hidden_states)
617
+
618
+ if not get_pp_group().is_last_rank:
619
+ return IntermediateTensors(
620
+ {
621
+ "hidden_states": hidden_states,
622
+ }
623
+ )
624
+
625
+ return hidden_states
626
+
627
+ def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
628
+ config = self.config
629
+ assert config.num_attention_groups > 1, "Only support GQA"
630
+ qkv_params_mapping = []
631
+ stacked_params_mapping = [
632
+ # (param_name, shard_name, shard_id)
633
+ ("qkv_proj", "q_proj", "q"),
634
+ ("qkv_proj", "k_proj", "k"),
635
+ ("qkv_proj", "v_proj", "v"),
636
+ ("gate_up_proj", "gate_proj", 0),
637
+ ("gate_up_proj", "up_proj", 1),
638
+ ]
639
+
640
+ params_dict = dict(self.named_parameters())
641
+ loaded_params: set[str] = set()
642
+
643
+ expert_params_mapping = [
644
+ (".moe.experts.w13_weight", ".moe.gate_proj.weight", "w1"),
645
+ (".moe.experts.w13_weight", ".moe.up_proj.weight", "w3"),
646
+ (".moe.experts.w2_weight", ".moe.down_proj.weight", "w2"),
647
+ ]
648
+
649
+ disable_moe_stacked_params = [data[1] for data in expert_params_mapping]
650
+
651
+ for name, loaded_weight in weights:
652
+ if name.startswith("model."):
653
+ local_name = name[len("model.") :]
654
+ full_name = name
655
+ else:
656
+ local_name = name
657
+ full_name = f"model.{name}" if name else "model"
658
+
659
+ spec_layer = get_spec_layer_idx_from_weight_name(config, full_name)
660
+ if spec_layer is not None:
661
+ continue # skip spec decode layers for main model
662
+
663
+ # Skip any layers beyond the main model's depth (e.g., MTP layers)
664
+ if full_name.startswith("model.layers."):
665
+ parts = full_name.split(".")
666
+ if len(parts) > 2 and parts[2].isdigit():
667
+ layer_idx = int(parts[2])
668
+ if layer_idx >= config.num_hidden_layers:
669
+ continue
670
+
671
+ for param_name, weight_name, shard_id in stacked_params_mapping:
672
+ if weight_name not in local_name:
673
+ continue
674
+ if any(
675
+ disable_moe_stacked_param in local_name
676
+ for disable_moe_stacked_param in disable_moe_stacked_params
677
+ ):
678
+ continue
679
+ replaced_name = local_name.replace(weight_name, param_name)
680
+ if is_pp_missing_parameter(replaced_name, self):
681
+ continue
682
+ if replaced_name not in params_dict:
683
+ continue
684
+ param = params_dict[replaced_name]
685
+ weight_loader = param.weight_loader
686
+ weight_loader(param, loaded_weight, shard_id)
687
+ loaded_params.add(replaced_name)
688
+ break
689
+ else:
690
+ for param_name, weight_name, shard_id in expert_params_mapping:
691
+ if weight_name not in local_name:
692
+ continue
693
+ replaced_name = local_name.replace(weight_name, param_name)
694
+ if is_pp_missing_parameter(replaced_name, self):
695
+ continue
696
+ if (
697
+ replaced_name.endswith(".bias")
698
+ or replaced_name.endswith("_bias")
699
+ ) and replaced_name not in params_dict:
700
+ continue
701
+ if replaced_name not in params_dict:
702
+ continue
703
+ param = params_dict[replaced_name]
704
+ weight_loader = param.weight_loader
705
+ moe_expert_num = self.moe_num_experts
706
+ assert loaded_weight.shape[0] == moe_expert_num
707
+ for expert_id in range(moe_expert_num):
708
+ loaded_weight_expert = loaded_weight[expert_id]
709
+ weight_loader(
710
+ param,
711
+ loaded_weight_expert,
712
+ replaced_name,
713
+ shard_id=shard_id,
714
+ expert_id=expert_id,
715
+ )
716
+ loaded_params.add(replaced_name)
717
+ break
718
+ else:
719
+ for (
720
+ param_name,
721
+ weight_name,
722
+ start_idx,
723
+ end_idx,
724
+ ) in qkv_params_mapping:
725
+ if weight_name not in local_name:
726
+ continue
727
+ replaced_name = local_name.replace(weight_name, param_name)
728
+ if is_pp_missing_parameter(replaced_name, self):
729
+ continue
730
+ if replaced_name not in params_dict:
731
+ continue
732
+ param = params_dict[replaced_name]
733
+ dim = param.shape[param.output_dim]
734
+ begin_idx = int(start_idx * dim)
735
+ end_idx = int(end_idx * dim)
736
+ param_slice = param.narrow(
737
+ param.output_dim, begin_idx, end_idx - begin_idx
738
+ )
739
+ param_slice.copy_(loaded_weight)
740
+ loaded_params.add(replaced_name)
741
+ break
742
+ else:
743
+ if is_pp_missing_parameter(local_name, self):
744
+ continue
745
+ if "expert_bias" in local_name:
746
+ logger.warning_once("ignore expert_bias")
747
+ continue
748
+ if local_name not in params_dict:
749
+ continue
750
+ param = params_dict[local_name]
751
+ weight_loader = getattr(
752
+ param, "weight_loader", default_weight_loader
753
+ )
754
+ weight_loader(param, loaded_weight)
755
+ loaded_params.add(local_name)
756
+ return loaded_params
757
+
758
+
759
+class Step3p5ForCausalLM(nn.Module, SupportsPP, MixtureOfExperts):
760
+ hf_to_vllm_mapper = WeightsMapper(
761
+ orig_to_new_substr={".share_expert.": ".moe.share_expert."}
762
+ )
763
+
764
+ def __init__(
765
+ self,
766
+ *,
767
+ vllm_config: VllmConfig,
768
+ prefix: str = "",
769
+ ):
770
+ super().__init__()
771
+ config = vllm_config.model_config.hf_config
772
+ lora_config = vllm_config.lora_config
773
+ self.config = config
774
+ self.vllm_config = vllm_config
775
+
776
+ self.model = Step3p5Model(
777
+ vllm_config=vllm_config, prefix=maybe_prefix(prefix, "model")
778
+ )
779
+
780
+ self.moe_layers: list[FusedMoEBlock] = []
781
+ for layer in self.model.layers:
782
+ if isinstance(layer, PPMissingLayer):
783
+ continue
784
+ assert isinstance(layer, Step3p5DecoderLayer)
785
+ if hasattr(layer, "moe") and isinstance(layer.moe, FusedMoEBlock):
786
+ self.moe_layers.append(layer.moe)
787
+
788
+ if get_pp_group().is_last_rank:
789
+ self.unpadded_vocab_size = config.vocab_size
790
+ if lora_config:
791
+ self.unpadded_vocab_size += lora_config.lora_extra_vocab_size
792
+ self.lm_head = ParallelLMHead(
793
+ self.unpadded_vocab_size,
794
+ config.hidden_size,
795
+ org_num_embeddings=config.vocab_size,
796
+ padding_size=DEFAULT_VOCAB_PADDING_SIZE
797
+ if not lora_config
798
+ else lora_config.lora_vocab_padding_size,
799
+ )
800
+ self.logits_processor = LogitsProcessor(
801
+ self.unpadded_vocab_size, config.vocab_size
802
+ )
803
+ else:
804
+ self.lm_head = PPMissingLayer()
805
+
806
+ self.make_empty_intermediate_tensors = (
807
+ self.model.make_empty_intermediate_tensors
808
+ )
809
+
810
+ # Set MoE hyperparameters
811
+ self.expert_weights = []
812
+ assert len(self.moe_layers) > 0, "No MoE layers found in the model."
813
+ example_layer = self.moe_layers[0]
814
+ self.num_moe_layers = len(self.moe_layers)
815
+ self.num_expert_groups = 1
816
+ self.num_shared_experts = 0
817
+ self.num_logical_experts = example_layer.n_logical_experts
818
+ self.num_physical_experts = example_layer.n_physical_experts
819
+ self.num_local_physical_experts = example_layer.n_local_physical_experts
820
+ self.num_routed_experts = example_layer.n_routed_experts
821
+ self.num_redundant_experts = example_layer.n_redundant_experts
822
+
823
+ def forward(
824
+ self,
825
+ input_ids: torch.Tensor,
826
+ positions: torch.Tensor,
827
+ intermediate_tensors: IntermediateTensors | None = None,
828
+ inputs_embeds: torch.Tensor | None = None,
829
+ ):
830
+ hidden_states = self.model(
831
+ input_ids, positions, intermediate_tensors, inputs_embeds
832
+ )
833
+ return hidden_states
834
+
835
+ def compute_logits(self, hidden_states: torch.Tensor) -> torch.Tensor:
836
+ hidden_states = self.model.norm(hidden_states)
837
+ logits = self.logits_processor(self.lm_head, hidden_states)
838
+ return logits
839
+
840
+ def embed_input_ids(self, input_ids: torch.Tensor) -> torch.Tensor:
841
+ return self.model.embed_tokens(input_ids)
842
+
843
+ def set_eplb_state(
844
+ self,
845
+ expert_load_view: torch.Tensor,
846
+ logical_to_physical_map: torch.Tensor,
847
+ logical_replica_count: torch.Tensor,
848
+ ) -> None:
849
+ for layer_idx, layer in enumerate(self.moe_layers):
850
+ experts = layer.experts
851
+ assert isinstance(experts, FusedMoE)
852
+ # Register the expert weights.
853
+ self.expert_weights.append(experts.get_expert_weights())
854
+ experts.set_eplb_state(
855
+ moe_layer_idx=layer_idx,
856
+ expert_load_view=expert_load_view,
857
+ logical_to_physical_map=logical_to_physical_map,
858
+ logical_replica_count=logical_replica_count,
859
+ )
860
+
861
+ def update_physical_experts_metadata(
862
+ self,
863
+ num_physical_experts: int,
864
+ num_local_physical_experts: int,
865
+ ) -> None:
866
+ assert self.num_local_physical_experts == num_local_physical_experts
867
+ self.num_physical_experts = num_physical_experts
868
+ self.num_local_physical_experts = num_local_physical_experts
869
+ self.num_redundant_experts = num_physical_experts - self.num_logical_experts
870
+ for layer in self.moe_layers:
871
+ assert isinstance(layer, FusedMoEBlock)
872
+ layer.n_local_physical_experts = num_local_physical_experts
873
+ layer.n_physical_experts = num_physical_experts
874
+ layer.n_redundant_experts = self.num_redundant_experts
875
+ layer.experts.update_expert_map()
876
+
877
+ def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
878
+ loader = AutoWeightsLoader(self)
879
+ return loader.load_weights(weights, mapper=self.hf_to_vllm_mapper)
880
+
881
+
882
+def get_spec_layer_idx_from_weight_name(
883
+ config: ModelConfig, weight_name: str
884
+) -> int | None:
885
+ if hasattr(config, "num_nextn_predict_layers") and (
886
+ config.num_nextn_predict_layers > 0
887
+ ):
888
+ layer_idx = config.num_hidden_layers
889
+ for i in range(config.num_nextn_predict_layers):
890
+ if weight_name.startswith(
891
+ f"layers.{layer_idx + i}." # Step3p5Model
892
+ ) or weight_name.startswith(f"model.layers.{layer_idx + i}."): # Step3p5MTP
893
+ return layer_idx + i
894
+ return None
vllm/model_executor/models/step3p5_mtp.py
new
+315
@@ -0,0 +1,315 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+from collections.abc import Iterable
4
+
5
+import torch
6
+import torch.nn as nn
7
+from transformers import PretrainedConfig
8
+
9
+from vllm.config import VllmConfig
10
+from vllm.logger import init_logger
11
+from vllm.model_executor.layers.layernorm import GemmaRMSNorm
12
+from vllm.model_executor.layers.logits_processor import LogitsProcessor
13
+from vllm.model_executor.layers.quantization import QuantizationConfig
14
+from vllm.model_executor.layers.vocab_parallel_embedding import (
15
+ ParallelLMHead,
16
+ VocabParallelEmbedding,
17
+)
18
+from vllm.model_executor.model_loader.weight_utils import default_weight_loader
19
+from vllm.sequence import IntermediateTensors
20
+
21
+from .step3p5 import Step3p5DecoderLayer, get_spec_layer_idx_from_weight_name
22
+from .utils import maybe_prefix
23
+
24
+logger = init_logger(__name__)
25
+
26
+
27
+class SharedHead(nn.Module):
28
+ def __init__(
29
+ self,
30
+ config: PretrainedConfig,
31
+ quant_config: QuantizationConfig | None = None,
32
+ ) -> None:
33
+ super().__init__()
34
+ self.norm = GemmaRMSNorm(config.hidden_size, config.rms_norm_eps)
35
+ self.head = ParallelLMHead(
36
+ config.vocab_size, config.hidden_size, quant_config=quant_config
37
+ )
38
+
39
+ def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
40
+ return self.norm(hidden_states)
41
+
42
+
43
+class Step3p5AMultiTokenPredictorLayer(nn.Module):
44
+ def __init__(
45
+ self,
46
+ vllm_config: VllmConfig,
47
+ prefix: str,
48
+ ) -> None:
49
+ super().__init__()
50
+ config = vllm_config.model_config.hf_config
51
+ quant_config = vllm_config.quant_config
52
+ self.enorm = GemmaRMSNorm(config.hidden_size, config.rms_norm_eps)
53
+ self.hnorm = GemmaRMSNorm(config.hidden_size, config.rms_norm_eps)
54
+ self.eh_proj = nn.Linear(config.hidden_size * 2, config.hidden_size, bias=False)
55
+ self.shared_head = SharedHead(config=config, quant_config=quant_config)
56
+ self.mtp_block = Step3p5DecoderLayer(
57
+ vllm_config,
58
+ prefix=f"{prefix}.mtp_block",
59
+ )
60
+
61
+ def forward(
62
+ self,
63
+ input_ids: torch.Tensor,
64
+ positions: torch.Tensor,
65
+ previous_hidden_states: torch.Tensor,
66
+ inputs_embeds: torch.Tensor | None = None,
67
+ spec_step_index: int = 0,
68
+ ) -> torch.Tensor:
69
+ assert inputs_embeds is not None
70
+ inputs_embeds = self.enorm(inputs_embeds)
71
+ previous_hidden_states = self.hnorm(previous_hidden_states)
72
+
73
+ hidden_states = self.eh_proj(
74
+ torch.cat([inputs_embeds, previous_hidden_states], dim=-1)
75
+ )
76
+
77
+ hidden_states = self.mtp_block(positions=positions, hidden_states=hidden_states)
78
+ return hidden_states
79
+
80
+
81
+class Step3p5AMultiTokenPredictor(nn.Module):
82
+ def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
83
+ super().__init__()
84
+ config = vllm_config.model_config.hf_config
85
+ self.embed_tokens = VocabParallelEmbedding(
86
+ config.vocab_size,
87
+ config.hidden_size,
88
+ )
89
+ self.mtp_start_layer_idx = config.num_hidden_layers
90
+ self.num_mtp_layers = config.num_nextn_predict_layers
91
+ # to map the exact layer index from weights
92
+ self.layers = torch.nn.ModuleDict(
93
+ {
94
+ str(idx): Step3p5AMultiTokenPredictorLayer(
95
+ vllm_config,
96
+ f"{prefix}.layers.{idx}",
97
+ )
98
+ for idx in range(
99
+ self.mtp_start_layer_idx,
100
+ self.mtp_start_layer_idx + self.num_mtp_layers,
101
+ )
102
+ }
103
+ )
104
+
105
+ self.logits_processor = LogitsProcessor(config.vocab_size)
106
+
107
+ def forward(
108
+ self,
109
+ input_ids: torch.Tensor,
110
+ positions: torch.Tensor,
111
+ previous_hidden_states: torch.Tensor,
112
+ inputs_embeds: torch.Tensor | None = None,
113
+ spec_step_idx: int = 0,
114
+ ) -> torch.Tensor:
115
+ if inputs_embeds is None:
116
+ inputs_embeds = self.embed_tokens(input_ids)
117
+ current_step_idx = spec_step_idx % self.num_mtp_layers
118
+ return self.layers[str(self.mtp_start_layer_idx + current_step_idx)](
119
+ input_ids,
120
+ positions,
121
+ previous_hidden_states,
122
+ inputs_embeds,
123
+ current_step_idx,
124
+ )
125
+
126
+ def compute_logits(
127
+ self,
128
+ hidden_states: torch.Tensor,
129
+ spec_step_idx: int = 0,
130
+ ) -> torch.Tensor:
131
+ current_step_idx = spec_step_idx % self.num_mtp_layers
132
+ mtp_layer = self.layers[str(self.mtp_start_layer_idx + current_step_idx)]
133
+ logits = self.logits_processor(
134
+ mtp_layer.shared_head.head, mtp_layer.shared_head(hidden_states)
135
+ )
136
+ return logits
137
+
138
+ def embed_input_ids(self, input_ids: torch.Tensor) -> torch.Tensor:
139
+ return self.embed_tokens(input_ids)
140
+
141
+
142
+class Step3p5MTP(nn.Module):
143
+ def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
144
+ super().__init__()
145
+ self.config = vllm_config.model_config.hf_config
146
+ self.vllm_config = vllm_config
147
+ self.model = Step3p5AMultiTokenPredictor(
148
+ vllm_config=vllm_config, prefix=maybe_prefix(prefix, "model")
149
+ )
150
+
151
+ def embed_input_ids(self, input_ids: torch.Tensor) -> torch.Tensor:
152
+ return self.model.embed_input_ids(input_ids)
153
+
154
+ def forward(
155
+ self,
156
+ input_ids: torch.Tensor,
157
+ positions: torch.Tensor,
158
+ hidden_states: torch.Tensor,
159
+ intermediate_tensors: IntermediateTensors | None = None,
160
+ inputs_embeds: torch.Tensor | None = None,
161
+ spec_step_idx: int = 0,
162
+ ) -> torch.Tensor:
163
+ hidden_states = self.model(
164
+ input_ids, positions, hidden_states, inputs_embeds, spec_step_idx
165
+ )
166
+ return hidden_states
167
+
168
+ def compute_logits(
169
+ self,
170
+ hidden_states: torch.Tensor,
171
+ spec_step_idx: int = 0,
172
+ ) -> torch.Tensor | None:
173
+ return self.model.compute_logits(hidden_states, spec_step_idx)
174
+
175
+ def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
176
+ stacked_params_mapping = [
177
+ # (param_name, shard_name, shard_id)
178
+ ("qkv_proj", "q_proj", "q"),
179
+ ("qkv_proj", "k_proj", "k"),
180
+ ("qkv_proj", "v_proj", "v"),
181
+ ("gate_up_proj", "gate_proj", 0),
182
+ ("gate_up_proj", "up_proj", 1),
183
+ ]
184
+
185
+ expert_params_mapping = [
186
+ (".moe.experts.w13_weight", ".moe.gate_proj.weight", "w1"),
187
+ (".moe.experts.w13_weight", ".moe.up_proj.weight", "w3"),
188
+ (".moe.experts.w2_weight", ".moe.down_proj.weight", "w2"),
189
+ ]
190
+
191
+ params_dict = dict(self.named_parameters())
192
+ loaded_params: set[str] = set()
193
+ for name, loaded_weight in weights:
194
+ if "rotary_emb.inv_freq" in name:
195
+ continue
196
+ spec_layer = get_spec_layer_idx_from_weight_name(self.config, name)
197
+ if "embed_tokens" not in name and spec_layer is None:
198
+ continue
199
+ name = self._rewrite_spec_layer_name(spec_layer, name)
200
+ for param_name, weight_name, shard_id in stacked_params_mapping:
201
+ # Skip non-stacked layers and experts (experts handled below).
202
+ if weight_name not in name:
203
+ continue
204
+ # We have mlp.experts[0].gate_proj in the checkpoint.
205
+ # Since we handle the experts below in expert_params_mapping,
206
+ # we need to skip here BEFORE we update the name, otherwise
207
+ # name will be updated to mlp.experts[0].gate_up_proj, which
208
+ # will then be updated below in expert_params_mapping
209
+ # for mlp.experts[0].gate_gate_up_proj, which breaks load.
210
+ if ("mlp.experts." in name) and name not in params_dict:
211
+ continue
212
+ if "experts" in name or "moe" in name:
213
+ continue
214
+ name = name.replace(weight_name, param_name)
215
+ # Skip loading extra bias for GPTQ models.
216
+ if name.endswith(".bias") and name not in params_dict:
217
+ continue
218
+
219
+ param = params_dict[name]
220
+ weight_loader = param.weight_loader
221
+ weight_loader(param, loaded_weight, shard_id)
222
+ break
223
+ else:
224
+ for mapping in expert_params_mapping:
225
+ param_name, weight_name, shard_id = mapping
226
+ if weight_name not in name:
227
+ continue
228
+ name = name.replace(weight_name, param_name)
229
+ # Skip loading extra bias for GPTQ models.
230
+ if (
231
+ name.endswith(".bias") or name.endswith("_bias")
232
+ ) and name not in params_dict:
233
+ continue
234
+ param = params_dict[name]
235
+ weight_loader = param.weight_loader
236
+ for expert_id in range(loaded_weight.shape[0]):
237
+ loaded_weight_expert = loaded_weight[expert_id]
238
+ weight_loader(
239
+ param,
240
+ loaded_weight_expert,
241
+ name,
242
+ shard_id=shard_id,
243
+ expert_id=expert_id,
244
+ )
245
+ loaded_params.add(name)
246
+ break
247
+ else:
248
+ # Skip loading extra bias for GPTQ models.
249
+ if (
250
+ name.endswith(".bias")
251
+ and name not in params_dict
252
+ or "tok_embeddings" in name
253
+ ):
254
+ continue
255
+
256
+ if spec_layer is not None and ".transformer." in name:
257
+ name = name.replace(".transformer.", ".")
258
+ if "shared_head" in name:
259
+ name = name.replace("shared_head.output", "shared_head.head")
260
+ if "embed_tokens" in name:
261
+ assert (
262
+ hasattr(self.config, "num_nextn_predict_layers")
263
+ and self.config.num_nextn_predict_layers > 0
264
+ )
265
+ name = "model.embed_tokens.weight"
266
+ param = params_dict[name]
267
+ weight_loader = getattr(
268
+ param, "weight_loader", default_weight_loader
269
+ )
270
+ weight_loader(param, loaded_weight)
271
+ loaded_params.add(name)
272
+ params_need_to_load = set(params_dict.keys())
273
+ # Some KV cache scales are optional: checkpoints may omit them and vLLM
274
+ # will fall back to default scales during initialization.
275
+ optional_params = {
276
+ name
277
+ for name, param in params_dict.items()
278
+ if name.endswith((".k_scale", ".v_scale", ".q_scale", ".prob_scale"))
279
+ and getattr(param, "numel", lambda: 0)() == 1
280
+ and getattr(param, "requires_grad", False) is False
281
+ }
282
+ params_need_to_load -= optional_params
283
+ if params_need_to_load != loaded_params:
284
+ missing_params = list(params_need_to_load - loaded_params)
285
+ param_name_example = missing_params[0]
286
+ raise RuntimeError(
287
+ "Some parameters like "
288
+ f"{param_name_example} are not in the checkpoint and will falsely "
289
+ "use random initialization"
290
+ )
291
+ return loaded_params
292
+
293
+ def _rewrite_spec_layer_name(self, spec_layer: int, name: str) -> str:
294
+ """
295
+ Rewrite the weight name to match the format of the original model.
296
+ Add .mtp_block for modules in transformer layer block for spec layer
297
+ """
298
+ spec_layer_weight_names = [
299
+ "embed_tokens",
300
+ "enorm",
301
+ "hnorm",
302
+ "eh_proj",
303
+ "shared_head",
304
+ ]
305
+ spec_layer_weight = False
306
+ for weight_name in spec_layer_weight_names:
307
+ if weight_name in name:
308
+ spec_layer_weight = True
309
+ break
310
+ if not spec_layer_weight:
311
+ # treat rest weights as weights for transformer layer block
312
+ name = name.replace(
313
+ f"model.layers.{spec_layer}.", f"model.layers.{spec_layer}.mtp_block."
314
+ )
315
+ return name
vllm/reasoning/__init__.py
+4
@@ -84,6 +84,10 @@ _REASONING_PARSERS_TO_REGISTER = {
84
"step3_reasoning_parser",
85
"Step3ReasoningParser",
86
),
87
+ "step3p5": (
88
+ "step3p5_reasoning_parser",
89
+ "Step3p5ReasoningParser",
90
+ ),
91
}
92
93
vllm/reasoning/step3p5_reasoning_parser.py
new
+153
@@ -0,0 +1,153 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+from collections.abc import Sequence
5
+
6
+from vllm.entrypoints.openai.chat_completion.protocol import (
7
+ ChatCompletionRequest,
8
+)
9
+from vllm.entrypoints.openai.engine.protocol import DeltaMessage
10
+from vllm.entrypoints.openai.responses.protocol import (
11
+ ResponsesRequest,
12
+)
13
+from vllm.reasoning.basic_parsers import BaseThinkingReasoningParser
14
+from vllm.tokenizers import TokenizerLike
15
+
16
+
17
+class Step3p5ReasoningParser(BaseThinkingReasoningParser):
18
+ """
19
+ Reasoning parser for Step3p5 model.
20
+
21
+ Step3p5 uses the <think>...</think> format, but it tends to emit an extra
22
+ newline immediately before and/or after the </think> token. This parser trims:
23
+ - the newline right before </think>
24
+ - the newline right after </think>
25
+ """
26
+
27
+ @property
28
+ def start_token(self) -> str:
29
+ return "<think>"
30
+
31
+ @property
32
+ def end_token(self) -> str:
33
+ return "</think>"
34
+
35
+ def __init__(self, tokenizer: TokenizerLike, *args, **kwargs):
36
+ super().__init__(tokenizer, *args, **kwargs)
37
+
38
+ # Used to hold a trailing "\n" from reasoning content so we can decide
39
+ # whether it is immediately before </think>.
40
+ self._pending_reasoning_newline = False
41
+
42
+ # Used to delay the reasoning end detection.
43
+ # This is necessary to remove the newline appears immediately after </think>,
44
+ # which may cause the end detection to be delayed by one round.
45
+ self.end_offset = 1
46
+
47
+ def is_reasoning_end(self, input_ids: Sequence[int]) -> bool:
48
+ if self.end_token_id in input_ids and self.end_offset > 0:
49
+ self.end_offset -= 1
50
+ return False
51
+ return self.end_offset < 1
52
+
53
+ def is_reasoning_end_streaming(
54
+ self, input_ids: Sequence[int], delta_ids: Sequence[int]
55
+ ) -> bool:
56
+ if self.end_token_id in input_ids and self.end_offset > 0:
57
+ self.end_offset -= 1
58
+ return False
59
+ return self.end_offset < 1
60
+
61
+ def extract_reasoning(
62
+ self,
63
+ model_output: str,
64
+ request: ChatCompletionRequest | ResponsesRequest,
65
+ ) -> tuple[str | None, str | None]:
66
+ reasoning, content = super().extract_reasoning(model_output, request)
67
+ if reasoning is not None:
68
+ reasoning = reasoning.removesuffix("\n")
69
+ if content is not None:
70
+ content = content.removeprefix("\n")
71
+ return reasoning or None, content or None
72
+
73
+ def extract_reasoning_streaming(
74
+ self,
75
+ previous_text: str,
76
+ current_text: str,
77
+ delta_text: str,
78
+ previous_token_ids: Sequence[int],
79
+ current_token_ids: Sequence[int],
80
+ delta_token_ids: Sequence[int],
81
+ ) -> DeltaMessage | None:
82
+ # Drop the immediate newline that models often emit after </think>.
83
+ if previous_text.endswith(self.end_token) and delta_text:
84
+ if delta_text == "\n":
85
+ return None
86
+ elif delta_text.startswith("\n"):
87
+ remaining = delta_text.removeprefix("\n")
88
+ return DeltaMessage(content=remaining) if remaining else None
89
+
90
+ ret = super().extract_reasoning_streaming(
91
+ previous_text,
92
+ current_text,
93
+ delta_text,
94
+ previous_token_ids,
95
+ current_token_ids,
96
+ delta_token_ids,
97
+ )
98
+
99
+ if ret is None:
100
+ return None
101
+
102
+ # Compatibility path for models that don't generate the start token:
103
+ # treat everything before </think> as reasoning and everything after
104
+ # as content.
105
+ if (
106
+ self.start_token_id not in previous_token_ids
107
+ and self.start_token_id not in delta_token_ids
108
+ ):
109
+ if self.end_token_id in delta_token_ids:
110
+ end_index = delta_text.find(self.end_token)
111
+ reasoning = delta_text[:end_index]
112
+ content = delta_text[end_index + len(self.end_token) :]
113
+ ret = DeltaMessage(reasoning=reasoning, content=content or None)
114
+ elif self.end_token_id in previous_token_ids:
115
+ ret = DeltaMessage(content=delta_text)
116
+ else:
117
+ ret = DeltaMessage(reasoning=delta_text)
118
+
119
+ reasoning_to_output = ret.reasoning
120
+ content_to_output = ret.content
121
+
122
+ # Reasoning: handle the newline immediately before </think>.
123
+ if reasoning_to_output is not None:
124
+ if self._pending_reasoning_newline:
125
+ reasoning_to_output = "\n" + reasoning_to_output
126
+ self._pending_reasoning_newline = False
127
+
128
+ if reasoning_to_output.endswith("\n"):
129
+ reasoning_to_output = reasoning_to_output.removesuffix("\n")
130
+ if self.end_token in delta_text:
131
+ # Trailing "\n" is right before </think>, drop it.
132
+ self._pending_reasoning_newline = False
133
+ else:
134
+ # Hold the trailing "\n" until we know whether </think> follows.
135
+ self._pending_reasoning_newline = True
136
+
137
+ # Content: handle the newline immediately after </think>.
138
+ if content_to_output is not None:
139
+ # No need to get into parser again to remove newline after </think>.
140
+ self.end_offset -= 1
141
+
142
+ # If we have content, reasoning must have ended.
143
+ self._pending_reasoning_newline = False
144
+
145
+ if self.end_token in delta_text and content_to_output.startswith("\n"):
146
+ content_to_output = content_to_output.removeprefix("\n")
147
+
148
+ reasoning_to_output = reasoning_to_output or None
149
+ content_to_output = content_to_output or None
150
+ if reasoning_to_output is None and content_to_output is None:
151
+ return None
152
+
153
+ return DeltaMessage(reasoning=reasoning_to_output, content=content_to_output)
vllm/tool_parsers/__init__.py
+4
@@ -134,6 +134,10 @@ _TOOL_PARSERS_TO_REGISTER = {
134
"step3_tool_parser",
135
"Step3ToolParser",
136
),
137
+ "step3p5": (
138
+ "step3p5_tool_parser",
139
+ "Step3p5ToolParser",
140
+ ),
141
"xlam": (
142
"xlam_tool_parser",
143
"xLAMToolParser",
vllm/tool_parsers/step3p5_tool_parser.py
new
+1511
@@ -0,0 +1,1511 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+import ast
4
+import json
5
+from collections.abc import Sequence
6
+from typing import Any
7
+from xml.parsers.expat import ParserCreate
8
+
9
+import regex as re
10
+
11
+from vllm.entrypoints.chat_utils import make_tool_call_id
12
+from vllm.entrypoints.openai.chat_completion.protocol import (
13
+ ChatCompletionRequest,
14
+ ChatCompletionToolsParam,
15
+)
16
+from vllm.entrypoints.openai.engine.protocol import (
17
+ DeltaFunctionCall,
18
+ DeltaMessage,
19
+ DeltaToolCall,
20
+ ExtractedToolCallInformation,
21
+ FunctionCall,
22
+ ToolCall,
23
+)
24
+from vllm.logger import init_logger
25
+from vllm.tokenizers import TokenizerLike
26
+from vllm.tool_parsers.abstract_tool_parser import (
27
+ ToolParser,
28
+ ToolParserManager,
29
+)
30
+
31
+logger = init_logger(__name__)
32
+
33
+
34
+class StreamingXMLToolCallParser:
35
+ """
36
+ Simplified streaming XML tool call parser
37
+ Supports streaming input, parsing, and output
38
+ """
39
+
40
+ def __init__(self):
41
+ self.reset_streaming_state()
42
+
43
+ # Tool configuration information
44
+ self.tools: list[ChatCompletionToolsParam] | None = None
45
+ self.tool_call_start_token: str = "<tool_call>"
46
+ self.tool_call_end_token: str = "</tool_call>"
47
+ self.function_start_token: str = "<function="
48
+ self.function_end_token: str = "</function>"
49
+ self.parameter_start_token: str = "<parameter="
50
+ self.parameter_end_token: str = "</parameter>"
51
+
52
+ def reset_streaming_state(self):
53
+ """Reset streaming parsing state"""
54
+
55
+ self.deltas = []
56
+ # state for streaming
57
+ self.tool_call_index = 0
58
+ self.current_call_id = None
59
+ self.last_completed_call_id = None
60
+ self.current_function_name = None
61
+ self.current_function_open = False
62
+ self.parameters = {}
63
+ self.current_param_name = None
64
+ self.current_param_value = ""
65
+ self.current_param_value_converted = ""
66
+ self.current_param_is_first = False
67
+ self.should_emit_end_newline = False
68
+ self.start_quote_emitted = False
69
+
70
+ self.streaming_buffer = ""
71
+ self.last_processed_pos = 0
72
+
73
+ self.text_content_buffer = ""
74
+
75
+ # state for preprocessing and deferred parsing
76
+ self._pre_inside_parameter = False
77
+ self._pre_param_buffer = ""
78
+ self._pre_current_param_name = None
79
+ self.defer_current_parameter = False
80
+ self.deferred_param_raw_value = ""
81
+
82
+ # recreate parser
83
+ self.parser = ParserCreate()
84
+ self.setup_parser()
85
+
86
+ def parse_single_streaming_chunks(self, xml_chunk: str) -> DeltaMessage:
87
+ """
88
+ Parse single streaming XML chunk and return Delta response
89
+ This is the actual streaming interface that receives chunks
90
+ one by one and maintains internal state
91
+
92
+ Args:
93
+ xml_chunk: Single XML chunk string
94
+ Returns:
95
+ DeltaMessage: Contains delta information generated by this chunk,
96
+ returns empty response if no complete elements
97
+ """
98
+ # Record delta count before processing
99
+ initial_delta_count = len(self.deltas)
100
+
101
+ self.streaming_buffer += xml_chunk
102
+
103
+ found_elements = self._process_complete_xml_elements()
104
+
105
+ if found_elements:
106
+ # If complete elements found, check if end events were missed
107
+ # some tags may not have been triggered
108
+ try:
109
+ new_deltas = self.deltas[initial_delta_count:]
110
+ # If this chunk contains </function>
111
+ # but didn't generate '}', then complete it
112
+ if (
113
+ self.current_call_id is not None
114
+ and self.function_end_token in xml_chunk
115
+ ):
116
+ # - Added '}' (non-empty parameter ending)
117
+ # - Added '{}' (empty parameter function)
118
+ has_function_close = any(
119
+ (
120
+ td.tool_calls
121
+ and any(
122
+ (
123
+ tc.function
124
+ and tc.id == self.current_call_id
125
+ and isinstance(tc.function.arguments, str)
126
+ and (tc.function.arguments in ("}", "{}"))
127
+ )
128
+ for tc in td.tool_calls
129
+ )
130
+ )
131
+ for td in new_deltas
132
+ )
133
+ if not has_function_close:
134
+ # Close potentially unclosed element
135
+ if self.current_param_name:
136
+ self._end_element("parameter")
137
+ if self.current_function_name:
138
+ self._end_element("function")
139
+ # If this chunk contains </tool_call>
140
+ # but didn't generate final empty delta, then complete it
141
+ if (
142
+ self.current_call_id is not None
143
+ and self.tool_call_end_token in xml_chunk
144
+ ):
145
+ has_toolcall_close = any(
146
+ (
147
+ td.tool_calls
148
+ and any(
149
+ (
150
+ tc.type == "function"
151
+ and tc.function
152
+ and tc.function.arguments == ""
153
+ and tc.id == self.current_call_id
154
+ )
155
+ for tc in td.tool_calls
156
+ )
157
+ )
158
+ for td in new_deltas
159
+ )
160
+ if not has_toolcall_close:
161
+ # Close potentially unclosed element
162
+ if self.current_param_name:
163
+ self._end_element("parameter")
164
+ if self.current_function_name:
165
+ self._end_element("function")
166
+ self._end_element("tool_call")
167
+ except Exception as e:
168
+ logger.warning("Error with fallback parsing: %s", e)
169
+ # Merge newly generated deltas into single response
170
+ result_delta = self._merge_new_deltas_to_single_response(
171
+ initial_delta_count
172
+ )
173
+ return result_delta
174
+ else:
175
+ # No complete elements, check if there's unoutput text content
176
+ if self.text_content_buffer and self.tool_call_index == 0:
177
+ # Has text content but no tool_call yet, output text content
178
+ text_delta = DeltaMessage(content=self.text_content_buffer)
179
+ self._emit_delta(text_delta)
180
+ # Clear buffer to avoid duplicate output
181
+ self.text_content_buffer = ""
182
+ return text_delta
183
+
184
+ # If this chunk contains end tags but wasn't triggered by parser,
185
+ # manually complete end events
186
+ # Only execute when still on the same call as when entered,
187
+ # to prevent accidentally closing new calls
188
+ # in multi <tool_call> scenarios
189
+ if self.current_call_id is not None and (
190
+ self.function_end_token in xml_chunk
191
+ or self.tool_call_end_token in xml_chunk
192
+ ):
193
+ # Close potentially unclosed element
194
+ if self.current_param_name:
195
+ self._end_element("parameter")
196
+ if self.function_end_token in xml_chunk and self.current_function_name:
197
+ self._end_element("function")
198
+ if self.tool_call_end_token in xml_chunk:
199
+ self._end_element("tool_call")
200
+ # Return the merged delta result generated by this fallback
201
+ result_delta = self._merge_new_deltas_to_single_response(
202
+ initial_delta_count
203
+ )
204
+ return result_delta
205
+
206
+ # No complete elements, return empty response
207
+ return DeltaMessage(content=None)
208
+
209
+ def _escape_xml_special_chars(self, text: str) -> str:
210
+ """
211
+ Escape XML special characters
212
+ Args:
213
+ text: Original text
214
+ Returns:
215
+ Escaped text
216
+ """
217
+ xml_escapes = {
218
+ "&": "&",
219
+ "<": "<",
220
+ ">": ">",
221
+ '"': """,
222
+ "'": "'",
223
+ }
224
+
225
+ for char, escape in xml_escapes.items():
226
+ text = text.replace(char, escape)
227
+
228
+ return text
229
+
230
+ def _process_complete_xml_elements(self) -> bool:
231
+ """
232
+ Process complete XML elements in buffer
233
+
234
+ Returns:
235
+ bool: Whether complete elements were found and processed
236
+ """
237
+ found_any = False
238
+
239
+ while self.last_processed_pos < len(self.streaming_buffer):
240
+ # Find next complete xml element
241
+ element, end_pos = self._find_next_complete_element(self.last_processed_pos)
242
+ if element is None:
243
+ # No complete element found, wait for more data
244
+ break
245
+
246
+ # Check if this element should be skipped
247
+ if self._should_skip_element(element):
248
+ self.last_processed_pos = end_pos
249
+ continue
250
+
251
+ # Found complete XML element, process it
252
+ try:
253
+ preprocessed_element = self._preprocess_xml_chunk(element)
254
+ # Check if this is the first tool_call start
255
+ if (
256
+ (
257
+ preprocessed_element.strip().startswith("<tool_call>")
258
+ or preprocessed_element.strip().startswith("<function name=")
259
+ )
260
+ and self.tool_call_index == 0
261
+ ) and self.text_content_buffer:
262
+ # First tool_call starts,
263
+ # output previously collected text content first
264
+ text_delta = DeltaMessage(content=self.text_content_buffer)
265
+ self._emit_delta(text_delta)
266
+ # Clear buffer for potential subsequent text content
267
+ self.text_content_buffer = ""
268
+
269
+ # If a new tool_call starts and
270
+ # there are already completed tool_calls with function name
271
+ if (
272
+ preprocessed_element.strip().startswith("<tool_call>")
273
+ and self.tool_call_index > 0
274
+ and self.current_call_id
275
+ and self.current_function_name
276
+ ):
277
+ # Reset parser state but preserve generated deltas
278
+ if self.current_param_name:
279
+ self._end_element("parameter")
280
+ if self.current_function_open:
281
+ self._end_element("function")
282
+ # Output final tool_call tail delta
283
+ final_delta = DeltaMessage(
284
+ role=None,
285
+ content=None,
286
+ reasoning_content=None,
287
+ tool_calls=[
288
+ DeltaToolCall(
289
+ index=self.tool_call_index - 1,
290
+ id=self.current_call_id,
291
+ type="function",
292
+ function=DeltaFunctionCall(name=None, arguments=""),
293
+ )
294
+ ],
295
+ )
296
+ self._emit_delta(final_delta)
297
+ # Reset XML parser and current call state
298
+ self._reset_xml_parser_after_tool_call()
299
+ # Parse preprocessed element
300
+ self.parser.Parse(preprocessed_element, False)
301
+ found_any = True
302
+
303
+ except Exception as e:
304
+ logger.warning("Error when parsing XML elements: %s", e)
305
+
306
+ # Update processed position
307
+ self.last_processed_pos = end_pos
308
+
309
+ return found_any
310
+
311
+ def _fix_incomplete_tag_in_chunk(self, chunk: str) -> str:
312
+ """
313
+ Fallback: fix incomplete <parameter=xxx or <function=xxx tags
314
+ (missing >)
315
+ Examples: <parameter=-C: -> <parameter=-C>, <parameter=parameter=-n:
316
+ -> <parameter=-n>
317
+ Also handles missing = cases: <function xxx> -> <function=xxx>,
318
+ <functionxxx> -> <function=xxx>
319
+ Only fixes tags that pass validation (parameter exists in tool definition)
320
+ """
321
+ # First, handle missing = cases for function tags
322
+ chunk = self._fix_missing_equals_in_function_tag(chunk)
323
+
324
+ for tag_type in ["parameter", "function"]:
325
+ pattern = f"<{tag_type}="
326
+ if pattern not in chunk:
327
+ continue
328
+
329
+ start_idx = chunk.find(pattern)
330
+ after_tag = chunk[start_idx:]
331
+ gt_pos = after_tag.find(">")
332
+ lt_pos = after_tag.find("<", len(pattern))
333
+
334
+ # Skip if already well-formed
335
+ if (
336
+ gt_pos != -1
337
+ and (lt_pos == -1 or gt_pos < lt_pos)
338
+ and pattern in after_tag[:gt_pos]
339
+ ):
340
+ continue
341
+
342
+ # Extract tag name (stop at space, newline, or <)
343
+ content = chunk[start_idx + len(pattern) :]
344
+ end_pos = next(
345
+ (i for i, ch in enumerate(content) if ch in (" ", "\n", "<")),
346
+ len(content),
347
+ )
348
+ tag_name = content[:end_pos]
349
+
350
+ if not tag_name:
351
+ continue
352
+
353
+ # Remove duplicate prefix: <parameter=parameter=xxx -> <parameter=xxx
354
+ if tag_name.startswith(f"{tag_type}="):
355
+ tag_name = tag_name[len(tag_type) + 1 :]
356
+
357
+ # Remove trailing non-alphanumeric chars (keep - and _)
358
+ while tag_name and not (
359
+ tag_name[-1].isalnum() or tag_name[-1] in ("-", "_")
360
+ ):
361
+ tag_name = tag_name[:-1]
362
+
363
+ if not tag_name:
364
+ continue
365
+
366
+ # Validate parameter exists in tool definition
367
+ if tag_type == "parameter" and not self._validate_parameter_name(tag_name):
368
+ continue
369
+
370
+ # Apply fix
371
+ chunk = chunk.replace(
372
+ f"<{tag_type}={content[:end_pos]}", f"<{tag_type}={tag_name}>", 1
373
+ )
374
+
375
+ return chunk
376
+
377
+ def _fix_missing_equals_in_function_tag(self, chunk: str) -> str:
378
+ """
379
+ Fix missing = in function tags: <function xxx> or <functionxxx>
380
+ Examples:
381
+ <function execute_bash> -> <function=execute_bash>
382
+ <functionexecute_bash> -> <function=execute_bash>
383
+ Only fixes if function name exists in tool definition
384
+ """
385
+ # already correct
386
+ if "<function=" in chunk:
387
+ return chunk
388
+
389
+ # Pattern 1: <function xxx> (with space/newline but no =)
390
+ pattern1 = r"<function\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*>"
391
+ match1 = re.search(pattern1, chunk)
392
+ if match1:
393
+ func_name = match1.group(1).strip()
394
+ # must validate function name exists before fixing
395
+ if func_name and self._validate_function_name(func_name):
396
+ original = match1.group(0)
397
+ fixed = f"<function={func_name}>"
398
+ chunk = chunk.replace(original, fixed, 1)
399
+ return chunk
400
+
401
+ # Pattern 2: <functionxxx> (no space, no =)
402
+ # only match <function followed by letters
403
+ pattern2 = r"<function([a-zA-Z_][a-zA-Z0-9_]*)\s*>"
404
+ match2 = re.search(pattern2, chunk)
405
+ if match2:
406
+ func_name = match2.group(1).strip()
407
+ # must validate function name exists before fixing
408
+ if func_name and self._validate_function_name(func_name):
409
+ original = match2.group(0)
410
+ fixed = f"<function={func_name}>"
411
+ chunk = chunk.replace(original, fixed, 1)
412
+ return chunk
413
+
414
+ return chunk
415
+
416
+ def _validate_function_name(self, func_name: str) -> bool:
417
+ """Check if function name exists in tool definitions"""
418
+ if not self.tools:
419
+ return False
420
+
421
+ for tool in self.tools:
422
+ if (
423
+ hasattr(tool, "type")
424
+ and tool.type == "function"
425
+ and hasattr(tool, "function")
426
+ and hasattr(tool.function, "name")
427
+ and tool.function.name == func_name
428
+ ):
429
+ return True
430
+
431
+ return False
432
+
433
+ def _validate_parameter_name(self, param_name: str) -> bool:
434
+ """Check if parameter exists in current function's tool definition"""
435
+ if not self.tools or not self.current_function_name:
436
+ return True
437
+
438
+ for tool in self.tools:
439
+ if (
440
+ hasattr(tool, "type")
441
+ and tool.type == "function"
442
+ and hasattr(tool, "function")
443
+ and hasattr(tool.function, "name")
444
+ and tool.function.name == self.current_function_name
445
+ ):
446
+ if not hasattr(tool.function, "parameters"):
447
+ return True
448
+ params = tool.function.parameters
449
+ if isinstance(params, dict):
450
+ properties = params.get("properties", params)
451
+ return param_name in properties
452
+ break
453
+
454
+ return True
455
+
456
+ def _should_skip_element(self, element: str) -> bool:
457
+ """
458
+ Determine whether an element should be skipped
459
+
460
+ Args:
461
+ element: Element to evaluate
462
+
463
+ Returns:
464
+ bool: True means should skip, False means should process
465
+ """
466
+
467
+ # If it's a tool_call XML tag, don't skip
468
+ if (
469
+ element.startswith(self.tool_call_start_token)
470
+ or element.startswith(self.function_start_token)
471
+ or element.startswith(self.parameter_start_token)
472
+ ):
473
+ return False
474
+
475
+ # If currently not parsing tool calls and not blank,
476
+ # collect this text instead of skipping
477
+ # Only process other XML elements after tool_call appears,
478
+ # otherwise treat as plain text
479
+ if self.current_call_id is None and element:
480
+ # Collect text content to buffer
481
+ self.text_content_buffer += element
482
+ return True # Still skip, but content has been collected
483
+
484
+ # If currently parsing tool calls,
485
+ # this might be parameter value, don't skip
486
+ if self.current_call_id is not None:
487
+ return False
488
+
489
+ # Skip blank content
490
+ return not element
491
+
492
+ def _find_next_complete_element(self, start_pos: int) -> tuple[str | None, int]:
493
+ """
494
+ Find next complete XML element from specified position
495
+
496
+ Args:
497
+ start_pos: Position to start searching
498
+
499
+ Returns:
500
+ (Complete element string, element end position),
501
+ returns (None, start_pos) if no complete element found
502
+ """
503
+ buffer = self.streaming_buffer[start_pos:]
504
+
505
+ if not buffer:
506
+ return None, start_pos
507
+
508
+ if buffer.startswith("<"):
509
+ # Check if this is an incomplete parameter/function tag
510
+ # e.g., <parameter=-C: or <function=xxx
511
+ is_incomplete_param = (
512
+ buffer.startswith("<parameter=") and ">" not in buffer.split("\n")[0]
513
+ )
514
+ is_incomplete_func = (
515
+ buffer.startswith("<function=") and ">" not in buffer.split("\n")[0]
516
+ )
517
+
518
+ if is_incomplete_param or is_incomplete_func:
519
+ # Find the corresponding closing tag
520
+ tag_type = "parameter" if is_incomplete_param else "function"
521
+ closing_tag = f"</{tag_type}>"
522
+ closing_pos = buffer.find(closing_tag)
523
+
524
+ if closing_pos != -1:
525
+ # Found closing tag, return complete element including closing tag
526
+ complete_element = buffer[: closing_pos + len(closing_tag)]
527
+ return complete_element, start_pos + closing_pos + len(closing_tag)
528
+
529
+ # Need to ensure no new < appears,
530
+ # find the nearest one between < and >
531
+ tag_end = buffer.find("<", 1)
532
+ tag_end2 = buffer.find(">", 1)
533
+ if tag_end != -1 and tag_end2 != -1:
534
+ # Next nearest is <
535
+ if tag_end < tag_end2:
536
+ return buffer[:tag_end], start_pos + tag_end
537
+ # Next nearest is >, means found XML element
538
+ else:
539
+ return buffer[: tag_end2 + 1], start_pos + tag_end2 + 1
540
+ elif tag_end != -1:
541
+ return buffer[:tag_end], start_pos + tag_end
542
+ elif tag_end2 != -1:
543
+ return buffer[: tag_end2 + 1], start_pos + tag_end2 + 1
544
+ else:
545
+ # If currently not parsing tool calls (entering a tool_call),
546
+ # check if starts with <tool_call> or <function=
547
+ if self.current_call_id is None:
548
+ # Check if might be start of <tool_call>
549
+ if buffer == "<tool_call>"[: len(buffer)]:
550
+ # Might be start of <tool_call>, wait for more data
551
+ return None, start_pos
552
+ elif (
553
+ buffer.startswith("<function=")
554
+ or buffer == "<function="[: len(buffer)]
555
+ ):
556
+ # Might be start of <function=, wait for more data
557
+ # to get the complete function tag
558
+ return None, start_pos
559
+ else:
560
+ # Not start of <tool_call> or <function=, treat as text
561
+ return buffer, start_pos + len(buffer)
562
+ else:
563
+ # When parsing tool calls,
564
+ # wait for more data to get complete tag
565
+ return None, start_pos
566
+ else:
567
+ # Find text content (until next < or buffer end)
568
+ next_tag_pos = buffer.find("<")
569
+ if next_tag_pos != -1:
570
+ # Found text content
571
+ text_content = buffer[:next_tag_pos]
572
+ return text_content, start_pos + next_tag_pos
573
+ else:
574
+ # Buffer end is all text, process
575
+ # (no longer wait for more data)
576
+ remaining = buffer
577
+ return remaining, start_pos + len(remaining)
578
+
579
+ def _merge_new_deltas_to_single_response(self, initial_count: int) -> DeltaMessage:
580
+ """
581
+ Merge newly generated deltas from this processing
582
+ into a single DeltaMessage
583
+
584
+ Args:
585
+ initial_count: Delta count before processing
586
+
587
+ Returns:
588
+ Merged DeltaMessage containing all newly generated delta information
589
+ """
590
+ if len(self.deltas) <= initial_count:
591
+ return DeltaMessage(content=None)
592
+
593
+ # Get newly generated deltas
594
+ new_deltas = self.deltas[initial_count:]
595
+
596
+ if len(new_deltas) == 1:
597
+ # Only one new delta, return directly
598
+ return new_deltas[0]
599
+
600
+ # Merge multiple new deltas
601
+ merged_tool_calls: list[DeltaToolCall] = []
602
+ merged_content: str = ""
603
+
604
+ for delta in new_deltas:
605
+ if delta.content:
606
+ merged_content += delta.content
607
+ if delta.tool_calls:
608
+ # For tool_calls, we need to intelligently merge arguments
609
+ for tool_call in delta.tool_calls:
610
+ # Find if there's already a tool_call with the same call_id
611
+ existing_call = None
612
+ for existing in merged_tool_calls:
613
+ if existing.id == tool_call.id:
614
+ existing_call = existing
615
+ break
616
+
617
+ if existing_call and existing_call.function:
618
+ # Merge to existing tool_call
619
+ if tool_call.function and tool_call.function.name:
620
+ existing_call.function.name = tool_call.function.name
621
+ if (
622
+ tool_call.function
623
+ and tool_call.function.arguments is not None
624
+ ):
625
+ if existing_call.function.arguments is None:
626
+ existing_call.function.arguments = ""
627
+
628
+ # For streaming JSON parameters,
629
+ # simply concatenate in order
630
+ new_args = tool_call.function.arguments
631
+ existing_call.function.arguments += new_args
632
+ if tool_call.type:
633
+ existing_call.type = tool_call.type
634
+ else:
635
+ # Add new tool_call
636
+ merged_tool_calls.append(tool_call)
637
+
638
+ return DeltaMessage(
639
+ content=merged_content if merged_content else None,
640
+ tool_calls=merged_tool_calls,
641
+ )
642
+
643
+ def _preprocess_xml_chunk(self, chunk: str) -> str:
644
+ """
645
+ Preprocess XML chunk, handle non-standard formats,
646
+ and escape special characters
647
+
648
+ Args:
649
+ chunk: Original XML chunk
650
+
651
+ Returns:
652
+ Processed XML chunk
653
+ """
654
+
655
+ # Check if this is a tool_call related element
656
+ is_tool_call = False
657
+ if chunk.startswith(self.tool_call_start_token) or chunk.startswith(
658
+ self.tool_call_end_token
659
+ ):
660
+ is_tool_call = True
661
+ # Check for function tags (including malformed ones without =)
662
+ # <function=xxx>, </function>, <function xxx>, <functionxxx>
663
+ if (
664
+ chunk.startswith(self.function_start_token)
665
+ or chunk.startswith(self.function_end_token)
666
+ or chunk.startswith("<function ")
667
+ or re.match(r"^<function[a-zA-Z_]", chunk)
668
+ ): # <functionXXX without space or =
669
+ is_tool_call = True
670
+ if chunk.startswith(self.parameter_start_token) or chunk.startswith(
671
+ self.parameter_end_token
672
+ ):
673
+ is_tool_call = True
674
+
675
+ # Fallback: fix incomplete <parameter= or <function= tags without
676
+ # closing >
677
+ # This handles cases like: <parameter=-C:\n or <parameter=-B 5\n
678
+ # Apply when parsing tool calls OR when chunk looks like a function/
679
+ # parameter tag
680
+ if (
681
+ self.current_call_id is not None
682
+ or chunk.startswith("<function")
683
+ or chunk.startswith("<parameter")
684
+ ):
685
+ chunk = self._fix_incomplete_tag_in_chunk(chunk)
686
+
687
+ # Handle <function=name> format -> <function name="name">
688
+ processed = re.sub(r"<function=([^>]+)>", r'<function name="\1">', chunk)
689
+ # Handle <parameter=name> format -> <parameter name="name">
690
+ processed = re.sub(r"<parameter=([^>]+)>", r'<parameter name="\1">', processed)
691
+
692
+ original_chunk = chunk
693
+ # If in parameter value accumulation mode
694
+ if self._pre_inside_parameter:
695
+ # Parameter end: output accumulated raw text
696
+ # safely then return </parameter>
697
+ if processed.startswith("</parameter>"):
698
+ body_text = self._pre_param_buffer
699
+ # Trigger deferred parsing mode
700
+ # literal_eval+json output in end_element
701
+ self.defer_current_parameter = True
702
+ self.deferred_param_raw_value = body_text
703
+ # Clean up state
704
+ self._pre_inside_parameter = False
705
+ self._pre_param_buffer = ""
706
+ self._pre_current_param_name = None
707
+ safe_text = self._escape_xml_special_chars(body_text)
708
+ return f"{safe_text}</parameter>"
709
+ else:
710
+ # If this is the first block of content after entering parameter
711
+ # evaluate if deferred parsing is needed;
712
+ # If not needed, exit accumulation mode
713
+ # and pass through directly
714
+ if self._pre_param_buffer == "":
715
+ # Get current parameter type
716
+ param_type = (
717
+ self._get_param_type(self._pre_current_param_name)
718
+ if self._pre_current_param_name
719
+ else "string"
720
+ )
721
+ # Only these types need deferred parsing to
722
+ # handle Python literals containing single quotes
723
+ is_object_type = param_type in ["object"]
724
+ is_complex_type = (
725
+ param_type in ["array", "arr", "sequence"]
726
+ or param_type.startswith("dict")
727
+ or param_type.startswith("list")
728
+ )
729
+
730
+ # Only delay when contains container symbols
731
+ # and has single quotes and is complex type
732
+ has_container_hint = (
733
+ ("[" in original_chunk)
734
+ or ("{" in original_chunk)
735
+ or ("(" in original_chunk)
736
+ )
737
+
738
+ # Determine if deferred parsing is needed
739
+ need_defer = False
740
+ if is_complex_type:
741
+ # Complex type, always need deferred parsing
742
+ need_defer = True
743
+ elif (
744
+ is_object_type
745
+ and has_container_hint
746
+ and ("'" in original_chunk)
747
+ ):
748
+ # Object type with container symbols
749
+ # and single quotes, need deferred parsing
750
+ need_defer = True
751
+
752
+ if not need_defer:
753
+ # No need for deferred parsing,
754
+ # exit parameter mode directly
755
+ self._pre_inside_parameter = False
756
+ return self._escape_xml_special_chars(original_chunk)
757
+ self._pre_param_buffer += original_chunk
758
+ return ""
759
+
760
+ # Parameter start: enable accumulation
761
+ if processed.startswith("<parameter name="):
762
+ m = re.match(r'<parameter name="([^"]+)">', processed)
763
+ if m:
764
+ self._pre_current_param_name = m.group(1)
765
+ self._pre_inside_parameter = True
766
+ self._pre_param_buffer = ""
767
+ return processed
768
+
769
+ # If processed doesn't contain special_token, escape processed
770
+ # This is because XML parsing encounters special characters
771
+ # and reports errors, so escaping is needed
772
+ if not is_tool_call:
773
+ processed = self._escape_xml_special_chars(processed)
774
+ return processed
775
+
776
+ def _emit_delta(self, delta: DeltaMessage):
777
+ """Emit Delta response (streaming output)"""
778
+ self.deltas.append(delta)
779
+
780
+ def _auto_close_open_parameter_if_needed(self, incoming_tag: str | None = None):
781
+ """Before starting to process new elements,
782
+ if there are unclosed tags from before,
783
+ automatically complete their endings to the parser.
784
+ - If there are unclosed parameters,
785
+ it's equivalent to feeding `</parameter>`
786
+ - When about to start a new function or tool_call,
787
+ if there are unclosed functions, complete `</function>`.
788
+ - When about to start a new tool_call,
789
+ if there are unclosed tool_calls, complete `</tool_call>`.
790
+ """
791
+ # First close unclosed parameters
792
+ if self.current_param_name:
793
+ self._end_element("parameter")
794
+
795
+ # If about to start new function or tool_call,
796
+ # and there are unclosed functions, close function first
797
+ if incoming_tag in ("function", "tool_call") and self.current_function_name:
798
+ self._end_element("function")
799
+
800
+ # If about to start new tool_call,
801
+ # and there are unclosed tool_calls, close tool_call first
802
+ if incoming_tag == "tool_call" and self.current_call_id:
803
+ self._end_element("tool_call")
804
+
805
+ def _start_element(self, name: str, attrs: dict[str, str]):
806
+ """Handle XML start element events"""
807
+
808
+ if name == "root":
809
+ return
810
+
811
+ if name == "tool_call":
812
+ # Before opening new tool_call,
813
+ # automatically complete previous unclosed tags
814
+ self._auto_close_open_parameter_if_needed("tool_call")
815
+
816
+ self.parameters = {}
817
+ self.current_call_id = make_tool_call_id()
818
+ self.current_param_is_first = True
819
+ self.tool_call_index += 1
820
+ elif name.startswith("function") or (name == "function"):
821
+ # If missing tool_call, manually complete
822
+ if not self.current_call_id:
823
+ self._start_element("tool_call", {})
824
+ # Before opening new function,
825
+ # automatically complete previous unclosed tags (parameter/function)
826
+ self._auto_close_open_parameter_if_needed("function")
827
+ function_name = self._extract_function_name(name, attrs)
828
+ self.current_function_name = function_name
829
+ self.current_function_open = True
830
+ if function_name:
831
+ delta = DeltaMessage(
832
+ tool_calls=[
833
+ DeltaToolCall(
834
+ index=self.tool_call_index - 1,
835
+ id=self.current_call_id,
836
+ type="function",
837
+ function=DeltaFunctionCall(
838
+ name=function_name, arguments=""
839
+ ),
840
+ )
841
+ ]
842
+ )
843
+ self._emit_delta(delta)
844
+ elif name.startswith("parameter") or (name == "parameter"):
845
+ # If previous parameter hasn't ended normally,
846
+ # complete its end first, then start new parameter
847
+ self._auto_close_open_parameter_if_needed("parameter")
848
+ param_name = self._extract_parameter_name(name, attrs)
849
+ self.current_param_name = param_name
850
+ self.current_param_value = ""
851
+ self.current_param_value_converted = ""
852
+ self.start_quote_emitted = False # Reset start quote flag
853
+
854
+ # Only output parameter name and colon,
855
+ # don't output quotes
856
+ # decide after parameter value type is determined
857
+ if param_name:
858
+ if not self.parameters:
859
+ # First parameter
860
+ # start JSON, only output parameter name and colon
861
+ json_start = f'{{"{param_name}": '
862
+ delta = DeltaMessage(
863
+ tool_calls=[
864
+ DeltaToolCall(
865
+ index=self.tool_call_index - 1,
866
+ id=self.current_call_id,
867
+ type="function",
868
+ function=DeltaFunctionCall(
869
+ name=None, arguments=json_start
870
+ ),
871
+ )
872
+ ]
873
+ )
874
+ self._emit_delta(delta)
875
+ self.current_param_is_first = True
876
+ else:
877
+ # Subsequent parameters
878
+ # add comma and parameter name, no quotes
879
+ json_continue = f', "{param_name}": '
880
+ delta = DeltaMessage(
881
+ tool_calls=[
882
+ DeltaToolCall(
883
+ index=self.tool_call_index - 1,
884
+ id=self.current_call_id,
885
+ type="function",
886
+ function=DeltaFunctionCall(
887
+ name=None, arguments=json_continue
888
+ ),
889
+ )
890
+ ]
891
+ )
892
+ self._emit_delta(delta)
893
+ self.current_param_is_first = False
894
+
895
+ def _char_data(self, data: str):
896
+ """Handle XML character data events"""
897
+ if data and self.current_param_name:
898
+ # If preprocessing stage determines deferred parsing is needed,
899
+ # only cache character data, no streaming output
900
+ if self.defer_current_parameter:
901
+ original_data = data
902
+ if self.should_emit_end_newline:
903
+ original_data = "\n" + original_data
904
+ self.should_emit_end_newline = False
905
+ if original_data.endswith("\n"):
906
+ self.should_emit_end_newline = True
907
+ original_data = original_data[:-1]
908
+ self.current_param_value += original_data
909
+ return
910
+
911
+ param_type = self._get_param_type(self.current_param_name)
912
+
913
+ # Check if this is the first time receiving data for this parameter
914
+ # If this is the first packet of data and starts with \n, remove \n
915
+ if not self.current_param_value and data.startswith("\n"):
916
+ data = data[1:]
917
+
918
+ # Output start quote for string type (if not already output)
919
+ if (
920
+ param_type in ["string", "str", "text", "varchar", "char", "enum"]
921
+ and not self.start_quote_emitted
922
+ ):
923
+ quote_delta = DeltaMessage(
924
+ tool_calls=[
925
+ DeltaToolCall(
926
+ index=self.tool_call_index - 1,
927
+ id=self.current_call_id,
928
+ type="function",
929
+ function=DeltaFunctionCall(name=None, arguments='"'),
930
+ )
931
+ ]
932
+ )
933
+ self._emit_delta(quote_delta)
934
+ self.start_quote_emitted = True
935
+
936
+ if not data:
937
+ return
938
+
939
+ original_data = data
940
+ # Delay output of trailing newline
941
+ if self.should_emit_end_newline:
942
+ original_data = "\n" + original_data
943
+ self.should_emit_end_newline = False
944
+ if original_data.endswith("\n"):
945
+ self.should_emit_end_newline = True
946
+ original_data = original_data[:-1]
947
+ self.current_param_value += original_data
948
+
949
+ # convert parameter value by param_type
950
+ converted_value = self._convert_param_value(
951
+ self.current_param_value, param_type
952
+ )
953
+ output_data = self._convert_for_json_streaming(converted_value, param_type)
954
+
955
+ delta_data = output_data[len(self.current_param_value_converted) :]
956
+ self.current_param_value_converted = output_data
957
+
958
+ delta = DeltaMessage(
959
+ tool_calls=[
960
+ DeltaToolCall(
961
+ index=self.tool_call_index - 1,
962
+ id=self.current_call_id,
963
+ type="function",
964
+ function=DeltaFunctionCall(name=None, arguments=delta_data),
965
+ )
966
+ ]
967
+ )
968
+ self._emit_delta(delta)
969
+
970
+ def _end_element(self, name: str):
971
+ """Handle XML end element events"""
972
+
973
+ if name == "root":
974
+ return
975
+
976
+ # If function or tool_call ends and there are still unclosed parameters,
977
+ # complete parameter end first
978
+ if (
979
+ name.startswith("function") or name == "function" or name == "tool_call"
980
+ ) and self.current_param_name:
981
+ self._auto_close_open_parameter_if_needed()
982
+
983
+ if (
984
+ name.startswith("parameter") or name == "parameter"
985
+ ) and self.current_param_name:
986
+ # End current parameter
987
+ param_name = self.current_param_name
988
+ param_value = self.current_param_value
989
+
990
+ # If in deferred parsing mode,
991
+ # perform overall parsing on raw content
992
+ # accumulated in preprocessing stage and output once
993
+ if self.defer_current_parameter:
994
+ raw_text = (
995
+ self.deferred_param_raw_value
996
+ if self.deferred_param_raw_value
997
+ else param_value
998
+ )
999
+ parsed_value = None
1000
+ output_arguments = None
1001
+ try:
1002
+ # If previously delayed trailing newline,
1003
+ # add it back before parsing
1004
+ if self.should_emit_end_newline:
1005
+ raw_for_parse = raw_text + "\n"
1006
+ else:
1007
+ raw_for_parse = raw_text
1008
+ parsed_value = ast.literal_eval(raw_for_parse)
1009
+ output_arguments = json.dumps(parsed_value, ensure_ascii=False)
1010
+ except Exception:
1011
+ # Fallback: output as string as-is
1012
+ output_arguments = json.dumps(raw_text, ensure_ascii=False)
1013
+ parsed_value = raw_text
1014
+
1015
+ delta = DeltaMessage(
1016
+ tool_calls=[
1017
+ DeltaToolCall(
1018
+ index=self.tool_call_index - 1,
1019
+ id=self.current_call_id,
1020
+ type="function",
1021
+ function=DeltaFunctionCall(
1022
+ name=None, arguments=output_arguments
1023
+ ),
1024
+ )
1025
+ ]
1026
+ )
1027
+ self._emit_delta(delta)
1028
+
1029
+ # Clean up and store
1030
+ self.should_emit_end_newline = False
1031
+ self.parameters[param_name] = parsed_value
1032
+ self.current_param_name = None
1033
+ self.current_param_value = ""
1034
+ self.current_param_value_converted = ""
1035
+ self.start_quote_emitted = False
1036
+ self.defer_current_parameter = False
1037
+ self.deferred_param_raw_value = ""
1038
+ return
1039
+
1040
+ param_type = self._get_param_type(param_name)
1041
+
1042
+ # convert complete parameter value by param_type
1043
+ converted_value = self._convert_param_value(param_value, param_type)
1044
+
1045
+ # Decide whether to add end quote based on parameter type
1046
+ if param_type in ["string", "str", "text", "varchar", "char", "enum"]:
1047
+ # For empty string parameters, need special handling
1048
+ if not param_value and not self.start_quote_emitted:
1049
+ # No start quote output,
1050
+ # directly output complete empty string
1051
+ delta = DeltaMessage(
1052
+ tool_calls=[
1053
+ DeltaToolCall(
1054
+ index=self.tool_call_index - 1,
1055
+ id=self.current_call_id,
1056
+ type="function",
1057
+ function=DeltaFunctionCall(name=None, arguments='""'),
1058
+ )
1059
+ ]
1060
+ )
1061
+ self._emit_delta(delta)
1062
+ else:
1063
+ # Non-empty parameter value, output end quote
1064
+ delta = DeltaMessage(
1065
+ tool_calls=[
1066
+ DeltaToolCall(
1067
+ index=self.tool_call_index - 1,
1068
+ id=self.current_call_id,
1069
+ type="function",
1070
+ function=DeltaFunctionCall(name=None, arguments='"'),
1071
+ )
1072
+ ]
1073
+ )
1074
+ self._emit_delta(delta)
1075
+
1076
+ self.should_emit_end_newline = False
1077
+ # Store converted value
1078
+ self.parameters[param_name] = converted_value
1079
+ self.current_param_name = None
1080
+ self.current_param_value = ""
1081
+ self.current_param_value_converted = ""
1082
+ self.start_quote_emitted = False
1083
+
1084
+ elif name.startswith("function") or name == "function":
1085
+ # if there are parameters, close JSON object
1086
+ if self.parameters:
1087
+ delta = DeltaMessage(
1088
+ tool_calls=[
1089
+ DeltaToolCall(
1090
+ index=self.tool_call_index - 1,
1091
+ id=self.current_call_id,
1092
+ type="function",
1093
+ function=DeltaFunctionCall(name=None, arguments="}"),
1094
+ )
1095
+ ]
1096
+ )
1097
+ self._emit_delta(delta)
1098
+ # return empty object
1099
+ else:
1100
+ delta = DeltaMessage(
1101
+ tool_calls=[
1102
+ DeltaToolCall(
1103
+ index=self.tool_call_index - 1,
1104
+ id=self.current_call_id,
1105
+ type="function",
1106
+ function=DeltaFunctionCall(name=None, arguments="{}"),
1107
+ )
1108
+ ]
1109
+ )
1110
+ self._emit_delta(delta)
1111
+ self.current_function_open = False
1112
+ self.current_function_name = (
1113
+ None # Clear function name to prevent duplicate closing
1114
+ )
1115
+
1116
+ elif name == "tool_call":
1117
+ # Before ending tool_call,
1118
+ # ensure function is closed to complete missing right brace
1119
+ if self.current_function_open:
1120
+ # If there are still unclosed parameters, close them first
1121
+ if self.current_param_name:
1122
+ self._end_element("parameter")
1123
+ # Close function, ensure output '}' or '{}'
1124
+ self._end_element("function")
1125
+ # Final Delta
1126
+ delta = DeltaMessage(
1127
+ tool_calls=[
1128
+ DeltaToolCall(
1129
+ index=self.tool_call_index - 1,
1130
+ id=self.current_call_id,
1131
+ type="function",
1132
+ function=DeltaFunctionCall(name=None, arguments=""),
1133
+ )
1134
+ ]
1135
+ )
1136
+ self._emit_delta(delta)
1137
+
1138
+ # Check if there's text content to output (between tool_calls)
1139
+ if self.text_content_buffer.strip():
1140
+ text_delta = DeltaMessage(content=self.text_content_buffer)
1141
+ self._emit_delta(text_delta)
1142
+
1143
+ self._reset_xml_parser_after_tool_call()
1144
+
1145
+ def setup_parser(self):
1146
+ """Set up XML parser event handlers"""
1147
+ self.parser.buffer_text = True
1148
+ self.parser.StartElementHandler = self._start_element
1149
+ self.parser.EndElementHandler = self._end_element
1150
+ self.parser.CharacterDataHandler = self._char_data
1151
+
1152
+ def set_tools(self, tools: list[ChatCompletionToolsParam] | None):
1153
+ """Set tool configuration information"""
1154
+ self.tools = tools
1155
+
1156
+ def _extract_function_name(self, name: str, attrs: dict[str, str]) -> str | None:
1157
+ """Extract function name from various formats"""
1158
+ if attrs and "name" in attrs:
1159
+ return attrs["name"]
1160
+
1161
+ if "=" in name:
1162
+ parts = name.split("=", 1)
1163
+ if len(parts) == 2 and parts[0] == "function":
1164
+ return parts[1]
1165
+
1166
+ return None
1167
+
1168
+ def _extract_parameter_name(self, name: str, attrs: dict[str, str]) -> str | None:
1169
+ """Extract parameter name from various formats"""
1170
+ if attrs and "name" in attrs:
1171
+ return attrs["name"]
1172
+
1173
+ if "=" in name:
1174
+ parts = name.split("=", 1)
1175
+ if len(parts) == 2 and parts[0] == "parameter":
1176
+ return parts[1]
1177
+
1178
+ return None
1179
+
1180
+ def _get_param_type(self, param_name: str) -> str:
1181
+ """Get parameter type based on tool configuration, defaults to string
1182
+ Args:
1183
+ param_name: Parameter name
1184
+
1185
+ Returns:
1186
+ Parameter type
1187
+ """
1188
+ if not self.tools or not self.current_function_name:
1189
+ return "string"
1190
+
1191
+ for tool in self.tools:
1192
+ if not hasattr(tool, "type") or not (
1193
+ hasattr(tool, "function") and hasattr(tool.function, "name")
1194
+ ):
1195
+ continue
1196
+ if (
1197
+ tool.type == "function"
1198
+ and tool.function.name == self.current_function_name
1199
+ ):
1200
+ if not hasattr(tool.function, "parameters"):
1201
+ return "string"
1202
+ params = tool.function.parameters
1203
+ if isinstance(params, dict) and "properties" in params:
1204
+ properties = params["properties"]
1205
+ if param_name in properties and isinstance(
1206
+ properties[param_name], dict
1207
+ ):
1208
+ return self.repair_param_type(
1209
+ str(properties[param_name].get("type", "string"))
1210
+ )
1211
+ elif isinstance(params, dict) and param_name in params:
1212
+ param_config = params[param_name]
1213
+ if isinstance(param_config, dict):
1214
+ return self.repair_param_type(
1215
+ str(param_config.get("type", "string"))
1216
+ )
1217
+ break
1218
+ return "string"
1219
+
1220
+ def repair_param_type(self, param_type: str) -> str:
1221
+ """Repair unknown parameter types by treating them as string
1222
+ Args:
1223
+ param_type: Parameter type
1224
+
1225
+ Returns:
1226
+ Repaired parameter type
1227
+ """
1228
+ if (
1229
+ param_type in ["string", "str", "text", "varchar", "char", "enum"]
1230
+ or param_type.startswith("int")
1231
+ or param_type.startswith("uint")
1232
+ or param_type.startswith("long")
1233
+ or param_type.startswith("short")
1234
+ or param_type.startswith("unsigned")
1235
+ or param_type.startswith("num")
1236
+ or param_type.startswith("float")
1237
+ or param_type in ["boolean", "bool", "binary"]
1238
+ or (
1239
+ param_type in ["object", "array", "arr", "sequence"]
1240
+ or param_type.startswith("dict")
1241
+ or param_type.startswith("list")
1242
+ )
1243
+ ):
1244
+ return param_type
1245
+ else:
1246
+ return "string"
1247
+
1248
+ def _convert_param_value(self, param_value: str, param_type: str) -> Any:
1249
+ """Convert value based on parameter type
1250
+ Args:
1251
+ param_value: Parameter value
1252
+ param_type: Parameter type
1253
+
1254
+ Returns:
1255
+ Converted value
1256
+ """
1257
+ if param_value.lower() == "null":
1258
+ return None
1259
+
1260
+ param_type = param_type.strip().lower()
1261
+ if param_type in ["string", "str", "text", "varchar", "char", "enum"]:
1262
+ return param_value
1263
+ elif (
1264
+ param_type.startswith("int")
1265
+ or param_type.startswith("uint")
1266
+ or param_type.startswith("long")
1267
+ or param_type.startswith("short")
1268
+ or param_type.startswith("unsigned")
1269
+ ):
1270
+ try:
1271
+ return int(param_value)
1272
+ except (ValueError, TypeError):
1273
+ logger.warning(
1274
+ "Parsed value '%s' is not an integer, degenerating to string.",
1275
+ param_value,
1276
+ )
1277
+ return param_value
1278
+ elif param_type.startswith("num") or param_type.startswith("float"):
1279
+ try:
1280
+ float_param_value: float = float(param_value)
1281
+ return (
1282
+ float_param_value
1283
+ if float_param_value - int(float_param_value) != 0
1284
+ else int(float_param_value)
1285
+ )
1286
+ except (ValueError, TypeError):
1287
+ logger.warning(
1288
+ "Parsed value '%s' is not a float, degenerating to string.",
1289
+ param_value,
1290
+ )
1291
+ return param_value
1292
+ elif param_type in ["boolean", "bool", "binary"]:
1293
+ param_value = param_value.lower()
1294
+ return param_value == "true"
1295
+ else:
1296
+ return param_value
1297
+
1298
+ def _convert_for_json_streaming(self, converted_value: Any, param_type: str) -> str:
1299
+ """Convert converted_value based on
1300
+ whether it's empty and if type is string
1301
+ Args:
1302
+ converted_value: Converted value
1303
+ param_type: Parameter type
1304
+
1305
+ Returns:
1306
+ Converted string for streaming output
1307
+ """
1308
+ # Check if value is empty, but exclude numeric 0
1309
+ if converted_value is None or converted_value == "":
1310
+ return ""
1311
+
1312
+ if param_type in ["string", "str", "text", "varchar", "char", "enum"]:
1313
+ # String type, remove double quotes
1314
+ return json.dumps(converted_value, ensure_ascii=False)[1:-1]
1315
+ else:
1316
+ # Non-string type, return complete JSON string
1317
+ if not isinstance(converted_value, str):
1318
+ return json.dumps(converted_value, ensure_ascii=False)
1319
+ else:
1320
+ return converted_value
1321
+
1322
+ def _reset_xml_parser_after_tool_call(self):
1323
+ """
1324
+ Each tool_call is treated as a separate XML document,
1325
+ so we need to reset the parser after each tool_call.
1326
+ """
1327
+
1328
+ # recreate XML parser
1329
+ self.parser = ParserCreate()
1330
+ self.setup_parser()
1331
+
1332
+ # Reset current tool_call state
1333
+ if self.current_call_id:
1334
+ self.last_completed_call_id = self.current_call_id
1335
+ self.current_call_id = None
1336
+ self.current_function_name = None
1337
+ self.current_function_open = False
1338
+ self.parameters = {}
1339
+ self.current_param_name = None
1340
+ self.current_param_value = ""
1341
+ self.current_param_value_converted = ""
1342
+ self.current_param_is_first = False
1343
+ self.should_emit_end_newline = False
1344
+ self.start_quote_emitted = False
1345
+ self.text_content_buffer = ""
1346
+
1347
+ # Reset preprocessing and deferred parsing state
1348
+ self._pre_inside_parameter = False
1349
+ self._pre_param_buffer = ""
1350
+ self._pre_current_param_name = None
1351
+ self.defer_current_parameter = False
1352
+ self.deferred_param_raw_value = ""
1353
+
1354
+
1355
+@ToolParserManager.register_module("step3p5")
1356
+class Step3p5ToolParser(ToolParser):
1357
+ def __init__(self, tokenizer: TokenizerLike):
1358
+ super().__init__(tokenizer)
1359
+ self.parser = StreamingXMLToolCallParser()
1360
+
1361
+ # Add missing attributes for compatibility with serving_chat.py
1362
+ self.prev_tool_call_arr: list[dict] = []
1363
+ self.streamed_args_for_tool: list[str] = []
1364
+
1365
+ logger.info(
1366
+ "vLLM Successfully import tool parser %s !", self.__class__.__name__
1367
+ )
1368
+
1369
+ def extract_tool_calls(
1370
+ self,
1371
+ model_output: str,
1372
+ request: ChatCompletionRequest,
1373
+ ) -> ExtractedToolCallInformation:
1374
+ self.parser.reset_streaming_state()
1375
+ # Reset tool call tracking arrays for new extraction
1376
+ self.prev_tool_call_arr = []
1377
+ self.streamed_args_for_tool = []
1378
+ if request:
1379
+ self.parser.set_tools(request.tools)
1380
+ result = self.parser.parse_single_streaming_chunks(model_output)
1381
+ if not result.tool_calls:
1382
+ return ExtractedToolCallInformation(
1383
+ tool_calls=[],
1384
+ tools_called=False,
1385
+ content=result.content,
1386
+ )
1387
+ else:
1388
+ tool_calls = []
1389
+ for tool_call in result.tool_calls:
1390
+ if tool_call.function and tool_call.function.name:
1391
+ tool_calls.append(
1392
+ ToolCall(
1393
+ id=tool_call.id,
1394
+ type=tool_call.type,
1395
+ function=FunctionCall(
1396
+ name=tool_call.function.name,
1397
+ arguments=tool_call.function.arguments,
1398
+ ),
1399
+ )
1400
+ )
1401
+
1402
+ # Update tool call tracking arrays for compatibility
1403
+ tool_index = (
1404
+ tool_call.index
1405
+ if tool_call.index is not None
1406
+ else len(self.prev_tool_call_arr) - 1
1407
+ )
1408
+
1409
+ # Ensure we have enough entries in our tracking arrays
1410
+ while len(self.prev_tool_call_arr) <= tool_index:
1411
+ self.prev_tool_call_arr.append({"name": "", "arguments": ""})
1412
+ while len(self.streamed_args_for_tool) <= tool_index:
1413
+ self.streamed_args_for_tool.append("")
1414
+
1415
+ # Update tool call information
1416
+ self.prev_tool_call_arr[tool_index]["name"] = (
1417
+ tool_call.function.name
1418
+ )
1419
+ self.prev_tool_call_arr[tool_index]["arguments"] = (
1420
+ tool_call.function.arguments
1421
+ )
1422
+
1423
+ # Update streamed arguments
1424
+ if tool_call.function.arguments:
1425
+ self.streamed_args_for_tool[tool_index] = (
1426
+ tool_call.function.arguments
1427
+ )
1428
+
1429
+ return ExtractedToolCallInformation(
1430
+ tool_calls=tool_calls,
1431
+ tools_called=len(tool_calls) > 0,
1432
+ content=result.content,
1433
+ )
1434
+
1435
+ def extract_tool_calls_streaming(
1436
+ self,
1437
+ previous_text: str,
1438
+ current_text: str,
1439
+ delta_text: str,
1440
+ previous_token_ids: Sequence[int],
1441
+ current_token_ids: Sequence[int],
1442
+ delta_token_ids: Sequence[int],
1443
+ request: ChatCompletionRequest,
1444
+ ) -> DeltaMessage | None:
1445
+ if not previous_text:
1446
+ self.parser.reset_streaming_state()
1447
+ # Reset tool call tracking arrays for new streaming session
1448
+ self.prev_tool_call_arr = []
1449
+ self.streamed_args_for_tool = []
1450
+ if request:
1451
+ self.parser.set_tools(request.tools)
1452
+
1453
+ # Model sometimes outputs separately causing delta_text to be empty.
1454
+ # If there were tool_calls before and all current tool_calls have ended,
1455
+ # return an empty tool_call for outer streaming output
1456
+ # to correctly output tool_call field
1457
+ if not delta_text and delta_token_ids:
1458
+ open_calls = current_text.count(
1459
+ self.parser.tool_call_start_token
1460
+ ) - current_text.count(self.parser.tool_call_end_token)
1461
+ if (
1462
+ open_calls == 0
1463
+ and self.parser.tool_call_index > 0
1464
+ or not self.parser.tool_call_index
1465
+ and current_text
1466
+ ):
1467
+ return DeltaMessage(content="")
1468
+ return None
1469
+
1470
+ # Parse the delta text and get the result
1471
+ result = self.parser.parse_single_streaming_chunks(delta_text)
1472
+
1473
+ # Update tool call tracking arrays based on incremental parsing results
1474
+ if result and result.tool_calls:
1475
+ for tool_call in result.tool_calls:
1476
+ if tool_call.function:
1477
+ tool_index = (
1478
+ tool_call.index
1479
+ if tool_call.index is not None
1480
+ else len(self.prev_tool_call_arr) - 1
1481
+ )
1482
+
1483
+ # Ensure we have enough entries in our tracking arrays
1484
+ while len(self.prev_tool_call_arr) <= tool_index:
1485
+ self.prev_tool_call_arr.append({"name": "", "arguments": ""})
1486
+ while len(self.streamed_args_for_tool) <= tool_index:
1487
+ self.streamed_args_for_tool.append("")
1488
+
1489
+ # Update tool name if provided
1490
+ if tool_call.function.name:
1491
+ self.prev_tool_call_arr[tool_index]["name"] = (
1492
+ tool_call.function.name
1493
+ )
1494
+
1495
+ # Update arguments incrementally
1496
+ if tool_call.function.arguments is not None:
1497
+ # Concatenate the incremental arguments
1498
+ # to the existing streamed arguments
1499
+ self.prev_tool_call_arr[tool_index]["arguments"] += (
1500
+ tool_call.function.arguments
1501
+ )
1502
+ self.streamed_args_for_tool[tool_index] += (
1503
+ tool_call.function.arguments
1504
+ )
1505
+ return result
1506
+
1507
+ def parser_should_check_for_unstreamed_tool_arg_tokens(self) -> bool:
1508
+ """
1509
+ Skip the remaining_call calculation in serving_chat
1510
+ """
1511
+ return False
vllm/transformers_utils/config.py
+1
@@ -97,6 +97,7 @@ _CONFIG_REGISTRY: dict[str, type[PretrainedConfig]] = LazyConfigDict(
97
ultravox="UltravoxConfig",
98
step3_vl="Step3VLConfig",
99
step3_text="Step3TextConfig",
100
+ step3p5="Step3p5Config",
101
qwen3_asr="Qwen3ASRConfig",
102
qwen3_next="Qwen3NextConfig",
103
lfm2_moe="Lfm2MoeConfig",
vllm/transformers_utils/configs/__init__.py
+2
@@ -52,6 +52,7 @@ _CLASS_TO_MODULE: dict[str, str] = {
52
"Step3VLConfig": "vllm.transformers_utils.configs.step3_vl",
53
"Step3VisionEncoderConfig": "vllm.transformers_utils.configs.step3_vl",
54
"Step3TextConfig": "vllm.transformers_utils.configs.step3_vl",
55
+ "Step3p5Config": "vllm.transformers_utils.configs.step3p5",
56
"Qwen3ASRConfig": "vllm.transformers_utils.configs.qwen3_asr",
57
"Qwen3NextConfig": "vllm.transformers_utils.configs.qwen3_next",
58
"Tarsier2Config": "vllm.transformers_utils.configs.tarsier2",
@@ -95,6 +96,7 @@ __all__ = [
96
"Step3VLConfig",
97
"Step3VisionEncoderConfig",
98
"Step3TextConfig",
99
+ "Step3p5Config",
100
"Qwen3ASRConfig",
101
"Qwen3NextConfig",
102
"Tarsier2Config",
vllm/transformers_utils/configs/step3p5.py
new
+100
@@ -0,0 +1,100 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+from typing import Any
4
+
5
+from transformers.configuration_utils import PretrainedConfig
6
+
7
+
8
+class Step3p5Config(PretrainedConfig):
9
+ model_type = "step3p5"
10
+
11
+ def __init__(
12
+ self,
13
+ hidden_size: int = 5120,
14
+ intermediate_size: int = 13312,
15
+ num_attention_heads: int = 40,
16
+ num_attention_groups: int = 8,
17
+ num_hidden_layers: int = 48,
18
+ max_seq_len: int = 4096,
19
+ vocab_size: int = 65536,
20
+ rms_norm_eps: float = 1e-5,
21
+ moe_every_n_layer: int = 2,
22
+ use_moe: bool = False,
23
+ moe_intermediate_size: int = 10240,
24
+ moe_num_experts: int = 16,
25
+ moe_top_k: int = 4,
26
+ moe_layer_offset: int = 0,
27
+ rope_theta: float | list[float] | None = 500000,
28
+ rope_scaling: dict[str, Any] | None = None,
29
+ head_dim: int | None = None,
30
+ share_expert_dim: int | None = None,
31
+ norm_expert_weight: bool = True,
32
+ bos_token_id: list[int] | int | None = None,
33
+ eos_token_id: list[int] | int | None = None,
34
+ moe_router_activation: str = "softmax",
35
+ moe_router_scaling_factor: float = 1.0,
36
+ att_impl_type: str = "GQA",
37
+ use_head_wise_attn_gate: bool = False,
38
+ use_moe_router_bias: bool = True,
39
+ need_fp32_gate: bool = True,
40
+ layer_types: list[str] | None = None,
41
+ use_rope_layers: list[bool] | None = None,
42
+ yarn_only_types: list[str] | None = None,
43
+ attention_other_setting: dict[str, Any] | None = None,
44
+ num_nextn_predict_layers: int = 0,
45
+ swiglu_limits: list[float] | None = None,
46
+ swiglu_limits_shared: list[float] | None = None,
47
+ max_position_embeddings: int | None = None,
48
+ **kwargs,
49
+ ):
50
+ self.hidden_size = hidden_size
51
+ self.intermediate_size = intermediate_size
52
+ self.num_attention_heads = num_attention_heads
53
+ self.num_attention_groups = num_attention_groups
54
+ self.num_hidden_layers = num_hidden_layers
55
+ self.max_seq_len = max_seq_len
56
+ self.vocab_size = vocab_size
57
+ self.rms_norm_eps = rms_norm_eps
58
+ self.use_moe = use_moe
59
+ self.moe_intermediate_size = moe_intermediate_size
60
+ self.moe_every_n_layer = moe_every_n_layer
61
+ self.moe_num_experts = moe_num_experts
62
+ self.num_experts_per_tok = moe_top_k
63
+ self.moe_top_k = moe_top_k
64
+ self.moe_layer_offset = moe_layer_offset
65
+
66
+ self.rope_theta = rope_theta
67
+ self.rope_scaling = rope_scaling
68
+ self.head_dim = head_dim
69
+ if share_expert_dim is None:
70
+ self.share_expert_dim = self.moe_intermediate_size * self.moe_top_k
71
+ else:
72
+ self.share_expert_dim = share_expert_dim
73
+ self.norm_expert_weight = norm_expert_weight
74
+
75
+ self.max_position_embeddings = max_position_embeddings
76
+ self.moe_router_activation = moe_router_activation
77
+ self.moe_router_scaling_factor = moe_router_scaling_factor
78
+ self.use_moe_router_bias = use_moe_router_bias
79
+ self.need_fp32_gate = need_fp32_gate
80
+
81
+ self.att_impl_type = att_impl_type
82
+ self.use_head_wise_attn_gate = use_head_wise_attn_gate
83
+ self.layer_types = layer_types
84
+ self.use_rope_layers = use_rope_layers
85
+ self.yarn_only_types = yarn_only_types
86
+ self.attention_other_setting = attention_other_setting
87
+ self.num_nextn_predict_layers = num_nextn_predict_layers
88
+ self.swiglu_limits = swiglu_limits
89
+ self.swiglu_limits_shared = swiglu_limits_shared
90
+
91
+ resolved_bos_token_id = 1 if bos_token_id is None else bos_token_id
92
+ resolved_eos_token_id = [2, 3] if eos_token_id is None else eos_token_id
93
+ self.bos_token_id = resolved_bos_token_id
94
+ self.eos_token_id = resolved_eos_token_id
95
+
96
+ super().__init__(
97
+ bos_token_id=resolved_bos_token_id,
98
+ eos_token_id=resolved_eos_token_id,
99
+ **kwargs,
100
+ )