Files
wehub-resource-sync 59a0a3844c
PR Test AMD / cancel-on-close (push) Has been skipped
PR Test NVIDIA ARM / scan (push) Has been skipped
PR Test NVIDIA / cancel-on-close (push) Has been skipped
PR Test AMD / scan (push) Has been skipped
PR Test NVIDIA ARM / cancel-on-close (push) Has been skipped
PR Test NVIDIA / scan (push) Has been skipped
Release Docker Images / build (cu129-torch-2.11.0) (push) Has been skipped
Release Docker Images / build (cu130-torch-2.11.0) (push) Has been skipped
Release PyPI / publish (push) Has been skipped
Scheduler Python Test / test (push) Successful in 27m19s
Docs / build (push) Successful in 28m8s
Scheduler C++ Test / test (push) Successful in 28m19s
Scheduler C++ Test / test-flat (push) Successful in 28m18s
Docs / deploy (push) Has been cancelled
PR Test AMD / finish (push) Has been cancelled
PR Test NVIDIA / finish (push) Has been cancelled
PR Test NVIDIA ARM / finish (push) Has been cancelled
PR Test NVIDIA ARM / ${{ matrix.name }} (${{ matrix.runner }}) (push) Has been cancelled
PR Test AMD / ${{ matrix.name }} (${{ matrix.runner }}) (push) Has been cancelled
PR Test NVIDIA / ${{ matrix.name }} (${{ matrix.runner }}) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:32:31 +08:00

285 lines
13 KiB
Python

# Copyright (c) 2026 LightSeek Foundation
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
"""Request tokenization helpers for the async frontend."""
from __future__ import annotations
import asyncio
import json
import time
from typing import TYPE_CHECKING
from tokenspeed.runtime.engine.io_struct import (
EmbeddingReqInput,
GenerateReqInput,
SessionParams,
TokenizedEmbeddingReqInput,
TokenizedGenerateReqInput,
)
from tokenspeed.runtime.grammar.reasoning_structural_tag import (
structural_tag_for_reasoning_json_schema,
)
from tokenspeed.runtime.multimodal.embedder import pad_input_tokens
from tokenspeed.runtime.multimodal.mrope import compute_mrope_positions
from tokenspeed.runtime.sampling.sampling_params import SamplingParams
if TYPE_CHECKING:
from tokenspeed.runtime.engine.async_llm import AsyncLLM
class InputProcessor:
"""Owns request-input logic: validation, tokenization, and the
tokenized-object prep for parallel-sampling fan-out. Callers
(``AsyncLLM``) stay thin — they route requests through this
class and then dispatch the resulting tokenized payloads to the
scheduler.
"""
def __init__(self, engine: AsyncLLM):
self.engine = engine
def _maybe_wrap_json_schema_for_reasoning(self, sampling: dict) -> None:
# Without this, xgrammar locks onto ``{`` at token 0 and the
# model can't emit ``<think>…</think>`` before the JSON.
if "json_schema" not in sampling:
return
reasoning_parser = getattr(self.engine.server_args, "reasoning_parser", None)
if not reasoning_parser:
return
try:
schema = sampling["json_schema"]
if isinstance(schema, str):
schema = json.loads(schema)
wrapped = structural_tag_for_reasoning_json_schema(reasoning_parser, schema)
except Exception as exc:
self.engine.logger.warning(
"reasoning-parser=%s: failed to wrap json_schema (%s); "
"falling back.",
reasoning_parser,
exc,
)
return
if wrapped is None:
return
sampling.pop("json_schema", None)
sampling["structural_tag"] = wrapped
def validate_request(self, obj: GenerateReqInput | EmbeddingReqInput) -> None:
"""Reject cross-type requests before any other processing.
An ``EmbeddingReqInput`` arriving at a generation-only engine
is a configuration mistake, not a runtime condition, so we
raise eagerly instead of letting it reach tokenization.
"""
if isinstance(obj, EmbeddingReqInput) and self.engine.is_generation:
raise ValueError("Embedding and rerank model requests are not supported.")
async def tokenize_batch(
self,
objs: list[GenerateReqInput | EmbeddingReqInput],
) -> list[TokenizedGenerateReqInput | TokenizedEmbeddingReqInput]:
"""Tokenize a list of requests in parallel.
Used by the batched fan-out path in ``AsyncLLM._handle_batch_request``.
The single-request path stays on ``tokenize_one_request`` —
avoiding the ``asyncio.gather`` hop keeps the hot path flat.
"""
return await asyncio.gather(*(self.tokenize_one_request(obj) for obj in objs))
async def tokenize_one_request(
self,
obj: GenerateReqInput | EmbeddingReqInput,
) -> TokenizedGenerateReqInput | TokenizedEmbeddingReqInput:
"""Tokenize one request without changing current behavior."""
input_embeds = None
multimodal_inputs = None
input_ids_unpadded = None
input_text = obj.text
input_ids = obj.input_ids
if obj.input_embeds is not None:
if self.engine.server_args.enable_prefix_caching:
raise ValueError(
"input_embeds is provided while prefix caching is enabled. "
"Please add `--no-enable-prefix-caching` when you launch the server "
"if you want to use input_embeds as inputs."
)
input_embeds = obj.input_embeds
elif input_ids is None:
if self.engine.tokenizer is None:
raise ValueError(
"The engine initialized with skip_tokenizer_init=True cannot "
"accept text prompts. Please provide input_ids or re-initialize "
"the engine with skip_tokenizer_init=False."
)
input_ids = self.engine.tokenizer.encode(input_text)
precomputed_mm = (
isinstance(obj, GenerateReqInput)
and obj.precomputed_multimodal_inputs is not None
)
if precomputed_mm:
# Gateway-side preprocess path (e.g. SMG): mm tensors are already
# built by an upstream preprocessor and the input_ids carry the
# expanded placeholder tokens (im_token_id) at the right offsets.
# We still need to run pad_input_tokens so the engine's
# MultimodalEmbedder can plan encoder-token scatter ranges from each
# item's offsets — the bare placeholder token alone would not
# encode per-item uniqueness needed by the radix prefix layer.
if not self.engine.model_config.is_multimodal_active:
raise ValueError(
"precomputed_multimodal_inputs is provided for a text-only model."
)
multimodal_inputs = obj.precomputed_multimodal_inputs
multimodal_inputs.ensure_pad_values()
# MRoPE-aware models (Qwen2/3-VL, …) require 3-axis position_ids
# derived from image_grid_thw + the image_token_id placeholders in
# input_ids. SMG ships precomputed mm inputs with mrope_* unset; if
# left None, model_executor falls back to a 1-D linear position
# override — silently degrading OCR accuracy. Compute them here, on
# the un-padded input_ids (so get_rope_index can still locate the
# image regions) BEFORE pad_input_tokens substitutes per-image
# pad_value over the placeholders, then pad for the embed splice.
if (
input_ids is not None
and getattr(multimodal_inputs, "mrope_positions", None) is None
):
mrope_positions, mrope_position_delta = compute_mrope_positions(
self.engine.model_config.hf_config,
list(input_ids),
multimodal_inputs.mm_items,
)
multimodal_inputs.mrope_positions = mrope_positions
multimodal_inputs.mrope_position_delta = mrope_position_delta
if mrope_position_delta is not None:
multimodal_inputs.mrope_position_delta_scalar = int(
mrope_position_delta.flatten()[0].item()
)
if input_ids is not None:
input_ids_unpadded = list(input_ids)
input_ids = pad_input_tokens(list(input_ids), multimodal_inputs)
if self.engine.is_generation:
session_params = (
SessionParams(**obj.session_params) if obj.session_params else None
)
input_token_num = len(input_ids) if input_ids is not None else 0
if input_token_num >= self.engine.context_len:
raise ValueError(
f"The input ({input_token_num} tokens) is longer than the "
f"model's context length ({self.engine.context_len} tokens)."
)
max_new_tokens = obj.sampling_params.get("max_new_tokens")
# Resolve to a finite cap bounded by remaining context. Both
# Req.check_finished and RequestState.check_finished read this field;
# leaving it None lets a request reach the per-request page-table cap.
adjusted_max_new_tokens = self.engine.context_len - input_token_num
if max_new_tokens is None:
obj.sampling_params.update({"max_new_tokens": adjusted_max_new_tokens})
elif max_new_tokens + input_token_num >= self.engine.context_len:
self.engine.logger.warning(
"Requested(rid=%s) token count exceeds the model's maximum context length of %s tokens. You requested a total of %s tokens: %s tokens from the input messages and %s tokens for the completion. The max_new_tokens will be truncated to %s.",
obj.rid,
self.engine.context_len,
max_new_tokens + input_token_num,
input_token_num,
max_new_tokens,
adjusted_max_new_tokens,
)
obj.sampling_params.update({"max_new_tokens": adjusted_max_new_tokens})
self._maybe_wrap_json_schema_for_reasoning(obj.sampling_params)
sampling_params = SamplingParams(**obj.sampling_params)
sampling_params.resolve_seed(obj.rid)
sampling_params.normalize(self.engine.tokenizer)
sampling_params.verify(self.engine.model_config.vocab_size)
# Output logprobs: two request dialects, one compute path. vLLM uses
# sampling_params.logprobs; SGLang uses GenerateReqInput.return_logprob
# (+ top_logprobs_num / logprob_start_len / token_ids_logprob). Either way
# the scheduler computes only the sampled token's logprob; the response
# dialect is chosen at render time. Gate unsupported CAPABILITIES loudly
# here rather than silently clamping the request shape.
sglang_req = bool(getattr(obj, "return_logprob", False))
return_logprob = sampling_params.logprobs is not None or sglang_req
# Output logprobs are gated by the static server arg enable_output_logprobs
# (the sampler only gathers them when on). Reject loudly instead of
# silently returning empty logprobs when the server cannot honor it.
if return_logprob and not self.engine.server_args.enable_output_logprobs:
raise ValueError(
"logprobs were requested but the server was started without "
"enable_output_logprobs; restart with enable_output_logprobs=True "
"to return output logprobs."
)
if sglang_req:
# vLLM top-k / full-vocab are gated in SamplingParams.verify(); gate
# the SGLang capability knobs here for parity.
if getattr(obj, "top_logprobs_num", 0):
raise ValueError(
"top_logprobs_num > 0 (output top-k logprobs) is not supported "
"yet; use top_logprobs_num=0 (the sampled token's logprob)."
)
if (getattr(obj, "logprob_start_len", -1) or -1) >= 0:
raise ValueError(
"logprob_start_len >= 0 (prompt logprobs) is not supported yet."
)
if getattr(obj, "token_ids_logprob", None):
raise ValueError("token_ids_logprob is not supported yet.")
logprob_start_len = -1
top_logprobs_num = 0
token_ids_logprob = None
if isinstance(obj, GenerateReqInput):
return TokenizedGenerateReqInput(
obj.rid,
input_text,
input_ids,
sampling_params,
return_logprob,
logprob_start_len,
top_logprobs_num,
token_ids_logprob,
obj.stream,
bootstrap_host=obj.bootstrap_host,
bootstrap_port=obj.bootstrap_port,
bootstrap_room=obj.bootstrap_room,
input_embeds=input_embeds,
session_params=session_params,
custom_logit_processor=obj.custom_logit_processor,
return_hidden_states=obj.return_hidden_states,
created_time=time.time(),
input_multi_ids=obj.input_multi_ids,
input_extra_infos=obj.input_extra_infos,
input_ids_unpadded=input_ids_unpadded,
multimodal_inputs=multimodal_inputs,
)
return TokenizedEmbeddingReqInput(
obj.rid,
input_text,
input_ids,
sampling_params,
created_time=time.time(),
)