Merge remote-tracking branch 'origin/main' into dev/vllm-align

This commit is contained in:
tzhouam committed 2026-02-23 02:09:09 +00:00
commit 292da4b0d8
36 files changed
+4167 -2632

No files matched your search

+1 -1
View File
@@ -176,7 +176,7 @@ steps:
# type: DirectoryOrCreate
- label: "Omni Model Test"
timeout_in_minutes: 15
timeout_in_minutes: 20
depends_on: image-build
commands:
- export VLLM_LOGGING_LEVEL=DEBUG
+629
View File
@@ -0,0 +1,629 @@
#!/usr/bin/env python3
"""
PR Reviewer using GLM API for vllm-omni project.
"""
import json
import logging
import os
import sys
import time
from dataclasses import dataclass
from typing import Any, TypedDict
import requests
# Type definitions for API responses
class PRDetails(TypedDict):
"""Type definition for GitHub PR details response."""
title: str
body: str
number: int
state: str
user: dict[str, Any]
class GLMMessage(TypedDict):
"""Type definition for GLM API message."""
role: str
content: str
class GLMChoice(TypedDict):
"""Type definition for GLM API choice."""
message: GLMMessage
finish_reason: str
class GLMResponse(TypedDict):
"""Type definition for GLM API response."""
choices: list[GLMChoice]
usage: dict[str, int] | None
class GitHubComment(TypedDict):
"""Type definition for GitHub comment."""
id: int
body: str
created_at: str
user: dict[str, Any]
# Configuration
TRIGGER_PHRASE: str = "@vllm-omni-reviewer"
DEFAULT_GLM_API_URL: str = "https://open.bigmodel.cn/api/paas/v4/chat/completions" # noqa: E501
DEFAULT_GLM_MODEL: str = "glm-5"
DEFAULT_COOLDOWN_MINUTES: int = 5
DEFAULT_MAX_RETRIES: int = 3
DEFAULT_RETRY_DELAY: float = 1.0
MAX_DIFF_SIZE: int = 100_000 # Maximum diff size in characters
@dataclass
class Config:
"""Configuration for the PR reviewer."""
glm_api_url: str
glm_model: str
cooldown_minutes: int
max_retries: int
retry_delay: float
max_diff_size: int
# Setup logging
logging.basicConfig(
level=logging.INFO,
format="[PR Reviewer] %(asctime)s - %(levelname)s - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logger: logging.Logger = logging.getLogger(__name__)
def get_config() -> Config:
"""Load configuration from environment variables with defaults."""
return Config(
glm_api_url=os.getenv("GLM_API_URL", DEFAULT_GLM_API_URL),
glm_model=os.getenv("GLM_MODEL", DEFAULT_GLM_MODEL),
cooldown_minutes=int(
os.getenv(
"PR_REVIEWER_COOLDOWN_MINUTES",
str(DEFAULT_COOLDOWN_MINUTES),
)
),
max_retries=int(
os.getenv(
"PR_REVIEWER_MAX_RETRIES",
str(DEFAULT_MAX_RETRIES),
)
),
retry_delay=float(os.getenv("PR_REVIEWER_RETRY_DELAY", str(DEFAULT_RETRY_DELAY))),
max_diff_size=int(os.getenv("PR_REVIEWER_MAX_DIFF_SIZE", str(MAX_DIFF_SIZE))), # noqa: E501
)
def get_env_var(name: str) -> str:
"""
Get an environment variable or raise an error.
Args:
name: Name of the environment variable.
Returns:
The value of the environment variable.
Raises:
SystemExit: If the environment variable is not set.
"""
value = os.environ.get(name)
if not value:
logger.error(f"Environment variable {name} is not set")
sys.exit(1)
return value
def check_trigger(comment_body: str) -> bool:
"""
Check if the comment contains the trigger phrase.
Args:
comment_body: The body of the comment to check.
Returns:
True if the trigger phrase is found, False otherwise.
"""
return TRIGGER_PHRASE in comment_body
def fetch_pr_diff(
repo_name: str,
pr_number: int,
token: str,
max_size: int = MAX_DIFF_SIZE,
) -> str | None:
"""
Fetch the diff for a pull request.
Args:
repo_name: The repository name in format "owner/repo".
pr_number: The pull request number.
token: GitHub authentication token.
max_size: Maximum diff size in characters.
Returns:
The diff content as a string, or None if fetching failed.
Returns empty string if diff is larger than max_size.
"""
url: str = f"https://api.github.com/repos/{repo_name}/pulls/{pr_number}"
headers: dict[str, str] = {
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github.v3.diff",
}
logger.info(f"Fetching PR diff from {url}")
response = requests.get(url, headers=headers, timeout=30)
if response.status_code == 200:
diff: str = response.text
if len(diff) > max_size:
logger.warning(
f"Diff size ({len(diff)} bytes) exceeds maximum "
f"({max_size} bytes), truncating to first "
f"{max_size} bytes"
)
return diff[:max_size] + "\n\n... [Diff truncated due to size] ..."
logger.info(f"Successfully fetched diff ({len(diff)} bytes)")
return diff
else:
logger.error(f"Failed to fetch PR diff: {response.status_code}")
logger.error(f"Response: {response.text}")
return None
def fetch_pr_details(
repo_name: str,
pr_number: int,
token: str,
) -> PRDetails | None:
"""
Fetch PR details including title and description.
Args:
repo_name: The repository name in format "owner/repo".
pr_number: The pull request number.
token: GitHub authentication token.
Returns:
A dictionary containing PR details, or None if fetching failed.
"""
url: str = f"https://api.github.com/repos/{repo_name}/pulls/{pr_number}"
headers: dict[str, str] = {
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github.v3+json",
}
logger.info(f"Fetching PR details from {url}")
response = requests.get(url, headers=headers, timeout=30)
if response.status_code == 200:
return response.json()
else:
logger.error(f"Failed to fetch PR details: {response.status_code}")
return None
def build_review_prompt(pr_title: str, pr_description: str, diff: str) -> str:
"""
Build the prompt for the GLM-4.7 API.
Args:
pr_title: The title of the pull request.
pr_description: The description/body of the pull request.
diff: The diff content of the pull request.
Returns:
The formatted prompt string for the API.
"""
return f"""You are an expert code reviewer for the VLLM-Omni project. \
Please review the following pull request:
## Pull Request Details
**Title:** {pr_title}
**Description:**
{pr_description if pr_description else "No description provided."}
## Code Changes (Diff)
{diff}
## Review Guidelines
Please provide a comprehensive code review with the following sections:
### 1. Overview
- Brief summary of the changes
- Overall assessment (positive, neutral, or concerns)
### 2. Code Quality
- Code style and consistency
- Potential bugs or edge cases
- Performance considerations
- Error handling
### 3. Architecture & Design
- Integration with existing codebase
- Design patterns and best practices
- Potential improvements
### 4. Security & Safety
- Security concerns (if any)
- Resource management
- Input validation
### 5. Testing & Documentation
- Test coverage considerations
- Documentation completeness
- Examples and usage clarity
### 6. Specific Suggestions
- Line-by-line specific feedback (use `file:line` format)
- Concrete actionable suggestions
- Code examples for improvements (if applicable)
### 7. Approval Status
- **LGTM** (Looks Good To Me) if the PR is ready to merge
- **LGTM with suggestions** if the PR is good but has minor suggestions
- **Changes requested** if significant changes are needed
## Important Notes
- Be constructive and helpful
- Focus on objective technical feedback
- Acknowledge good practices when you see them
- Prioritize critical issues over nitpicks
- If the diff is empty or minimal, acknowledge this and provide
any relevant context-specific guidance
Please format your response in Markdown with clear section headers.
"""
def validate_glm_response(data: dict[str, Any]) -> str | None:
"""
Validate and extract content from GLM API response.
Args:
data: The response data from GLM API.
Returns:
The review content string if valid, None otherwise.
"""
# Check if choices exists and is a non-empty list
if "choices" not in data:
logger.error("GLM API response missing 'choices' field")
logger.error(f"Response structure: {json.dumps(data, indent=2)}")
return None
choices = data["choices"]
if not isinstance(choices, list):
logger.error(f"GLM API 'choices' is not a list: {type(choices)}")
return None
if len(choices) == 0:
logger.error("GLM API 'choices' is an empty list")
return None
# Check if first choice has message
try:
first_choice = choices[0]
if not isinstance(first_choice, dict):
logger.error(f"GLM API choice is not a dict: {type(first_choice)}")
return None
if "message" not in first_choice:
logger.error("GLM API choice missing 'message' field")
logger.error(f"Choice structure: {json.dumps(first_choice, indent=2)}") # noqa: E501
return None
message = first_choice["message"]
if not isinstance(message, dict):
logger.error(f"GLM API message is not a dict: {type(message)}")
return None
if "content" not in message:
logger.error("GLM API message missing 'content' field")
logger.error(f"Message structure: {json.dumps(message, indent=2)}")
return None
content = message["content"]
if not isinstance(content, str):
logger.error(f"GLM API content is not a string: {type(content)}")
return None
return content
except (KeyError, IndexError, TypeError) as e:
logger.error(f"Failed to parse GLM API response: {e}")
logger.error(f"Response: {json.dumps(data, indent=2)}")
return None
def call_glm_api(prompt: str, api_key: str, config: Config) -> str | None:
"""
Call the GLM-4.7 API to get code review with retry logic.
Args:
prompt: The prompt to send to the API.
api_key: The GLM API key.
config: Configuration object.
Returns:
The review content as a string, or None if all retries failed.
"""
headers: dict[str, str] = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
payload: dict[str, Any] = {
"model": config.glm_model,
"messages": [{"role": "user", "content": prompt}],
"temperature": 0.3,
"max_tokens": 32000,
"top_p": 0.9,
}
last_error: str | None = None
for attempt in range(config.max_retries):
try:
logger.info(f"Calling GLM API ({config.glm_model}) - Attempt {attempt + 1}/{config.max_retries}")
response = requests.post(
config.glm_api_url,
headers=headers,
json=payload,
timeout=120,
)
if response.status_code == 200:
data = response.json()
review = validate_glm_response(data)
if review:
logger.info(f"Successfully received review ({len(review)} chars)") # noqa: E501
return review
else:
last_error = "Failed to validate API response structure"
logger.error(last_error)
else:
last_error = f"GLM API request failed: {response.status_code} - {response.text}"
logger.error(last_error)
except requests.exceptions.Timeout:
last_error = f"GLM API request timed out (attempt {attempt + 1})"
logger.error(last_error)
except requests.exceptions.RequestException as e:
last_error = f"GLM API request exception: {e}"
logger.error(last_error)
except json.JSONDecodeError as e:
last_error = f"Failed to decode GLM API response as JSON: {e}"
logger.error(last_error)
# Exponential backoff before retry
if attempt < config.max_retries - 1:
wait_time: float = config.retry_delay * (2**attempt)
logger.info(f"Waiting {wait_time}s before retry...") # noqa: E501
time.sleep(wait_time)
logger.error(
f"All {config.max_retries} attempts failed. Last error: {last_error}" # noqa: E501
)
return None
def check_cooldown( # noqa: E501
repo_name: str,
pr_number: int,
token: str,
cooldown_minutes: int,
) -> bool:
"""
Check if the PR is within the cooldown period.
Args:
repo_name: The repository name in format "owner/repo".
pr_number: The pull request number.
token: GitHub authentication token.
cooldown_minutes: Cooldown period in minutes.
Returns:
True if within cooldown period (should skip), False otherwise.
"""
from datetime import datetime, timedelta
url: str = (
f"https://api.github.com/repos/{repo_name}/issues/"
f"{pr_number}/comments" # noqa: E501
)
headers: dict[str, str] = {
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github.v3+json",
}
logger.info(f"Checking cooldown period ({cooldown_minutes} minutes)")
response = requests.get(url, headers=headers, timeout=30)
if response.status_code != 200:
logger.warning(f"Failed to check cooldown: {response.status_code}, proceeding with review")
return False
comments: list[dict[str, Any]] = response.json()
cutoff_time: datetime = datetime.utcnow() - timedelta(minutes=cooldown_minutes) # noqa: E501
for comment in reversed(comments):
# Check if this is a bot comment
body: str = comment.get("body", "")
if "VLLM-Omni PR Review" in body or "PR Reviewer Bot" in body:
created_at_str: str = comment.get("created_at", "")
try:
# Parse GitHub timestamp format
created_at = datetime.fromisoformat(created_at_str.replace("Z", "+00:00"))
created_at = created_at.replace(tzinfo=None)
if created_at > cutoff_time:
logger.info(f"PR is within cooldown period (last review: {created_at_str})")
return True
except ValueError:
logger.warning(f"Failed to parse comment timestamp: {created_at_str}") # noqa: E501
continue
logger.info("PR is outside cooldown period, proceeding with review")
return False
def post_review_comment( # noqa: E501
repo_name: str,
pr_number: int,
token: str,
review: str,
) -> bool:
"""
Post the review as a comment on the PR.
Args:
repo_name: The repository name in format "owner/repo".
pr_number: The pull request number.
token: GitHub authentication token.
review: The review content to post.
Returns:
True if posting succeeded, False otherwise.
"""
url: str = (
f"https://api.github.com/repos/{repo_name}/issues/"
f"{pr_number}/comments" # noqa: E501
)
headers: dict[str, str] = {
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github.v3+json",
}
# Format the review comment
comment_body: str = f"""## 🤖 VLLM-Omni PR Review
{review}
---
*This review was generated automatically by the VLLM-Omni PR Reviewer Bot
using {os.getenv("GLM_MODEL", DEFAULT_GLM_MODEL)}.*
"""
payload: dict[str, str] = {"body": comment_body}
logger.info(f"Posting review comment to PR #{pr_number}")
response = requests.post(url, headers=headers, json=payload, timeout=30)
if response.status_code == 201:
logger.info("Successfully posted review comment")
return True
else:
logger.error(f"Failed to post comment: {response.status_code}")
logger.error(f"Response: {response.text}")
return False
def main() -> int:
"""
Main entry point for the PR reviewer bot.
Returns:
0 on success, 1 on error.
"""
logger.info("VLLM-Omni PR Reviewer Bot starting...")
# Load configuration
config: Config = get_config()
logger.info(
f"Configuration: model={config.glm_model}, "
f"cooldown={config.cooldown_minutes}min, "
f"max_retries={config.max_retries}"
)
# Get environment variables
token: str = get_env_var("GITHUB_TOKEN")
api_key: str = get_env_var("GLM_API_KEY")
repo_name: str = get_env_var("REPO_NAME")
pr_number_str: str = get_env_var("PR_NUMBER")
comment_body: str = get_env_var("COMMENT_BODY")
try:
pr_number: int = int(pr_number_str)
except ValueError:
logger.error(f"Invalid PR number: {pr_number_str}")
return 1
logger.info(f"Repository: {repo_name}")
logger.info(f"PR Number: {pr_number}")
# Check if the comment contains the trigger phrase
if not check_trigger(comment_body):
logger.info(
f"Comment does not contain trigger phrase '{TRIGGER_PHRASE}', exiting" # noqa: E501
)
return 0
logger.info("Trigger phrase detected! Starting review process...")
# Check cooldown period
if check_cooldown(repo_name, pr_number, token, config.cooldown_minutes):
logger.info("Skipping review due to cooldown period")
return 0
# Fetch PR details
logger.info("Step 1/4: Fetching PR details...")
pr_details: PRDetails | None = fetch_pr_details(repo_name, pr_number, token) # noqa: E501
if not pr_details:
logger.error("Failed to fetch PR details")
return 1
pr_title: str = pr_details.get("title", "Unknown")
pr_description: str = pr_details.get("body", "")
logger.info(f"PR Title: {pr_title}")
# Fetch PR diff
logger.info("Step 2/4: Fetching PR diff...")
diff: str | None = fetch_pr_diff(repo_name, pr_number, token, config.max_diff_size)
if diff is None:
logger.error("Failed to fetch PR diff")
return 1
if not diff:
logger.warning("Warning: Empty diff - this might be a draft PR or no code changes")
# Build prompt
logger.info("Step 3/4: Building review prompt...")
prompt: str = build_review_prompt(pr_title, pr_description, diff)
# Call GLM API
logger.info("Step 4/4: Calling GLM API...")
review: str | None = call_glm_api(prompt, api_key, config)
if not review:
logger.error("Failed to get review from GLM API")
return 1
# Post review comment
logger.info("Posting review comment...")
if not post_review_comment(repo_name, pr_number, token, review):
logger.error("Failed to post review comment")
return 1
logger.info("PR review completed successfully!")
return 0
if __name__ == "__main__":
sys.exit(main())
+62
View File
@@ -0,0 +1,62 @@
name: VLLM-Omni PR Reviewer
on:
issue_comment:
types: [created]
permissions:
contents: read
pull-requests: write
issues: write
jobs:
pr-reviewer:
name: Review Pull Request
runs-on: ubuntu-latest
timeout-minutes: 10
# Only run when the comment is from a collaborator/owner/member
if: |
github.event_name == 'issue_comment' &&
github.event.issue.pull_request != null &&
contains(github.event.comment.body, '@vllm-omni-reviewer') &&
(github.event.comment.author_association == 'MEMBER' ||
github.event.comment.author_association == 'COLLABORATOR' ||
github.event.comment.author_association == 'OWNER')
steps:
- name: Checkout repository
uses: actions/checkout@v4.2.2
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5.3.0
with:
python-version: '3.11'
cache: 'pip'
- name: Install dependencies
run: |
python -m pip install --upgrade pip==24.0
pip install requests==2.31.0 pyyaml==6.0.1
- name: Run PR Reviewer
id: reviewer
env:
GLM_API_KEY: ${{ secrets.GLM_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.issue.number || github.event.pull_request.number }}
COMMENT_BODY: ${{ github.event.comment.body }}
REPO_NAME: ${{ github.repository }}
PR_REVIEWER_COOLDOWN_MINUTES: 5
PR_REVIEWER_MAX_RETRIES: 3
run: |
python .github/scripts/pr_reviewer.py 2>&1 | tee "/tmp/pr_review_${PR_NUMBER}.log"
- name: Upload review logs
if: always()
uses: actions/upload-artifact@v4.4.3
with:
name: pr-review-logs-${{ github.event.issue.number || github.event.pull_request.number }}
path: /tmp/pr_review_*.log
retention-days: 7
if-no-files-found: ignore
+139
View File
@@ -0,0 +1,139 @@
# Argument to configure vllm base image if pre-built
ARG VLLM_BASE=vllm-base
FROM intel/deep-learning-essentials:2025.3.2-0-devel-ubuntu24.04 AS vllm-base
WORKDIR /workspace/
ARG PYTHON_VERSION=3.12
ARG PIP_EXTRA_INDEX_URL="https://download.pytorch.org/whl/xpu"
RUN wget -O- https://apt.repos.intel.com/intel-gpg-keys/GPG-PUB-KEY-INTEL-SW-PRODUCTS.PUB | gpg --dearmor | tee /usr/share/keyrings/oneapi-archive-keyring.gpg > /dev/null && \
echo "deb [signed-by=/usr/share/keyrings/oneapi-archive-keyring.gpg] https://apt.repos.intel.com/oneapi all main" | tee /etc/apt/sources.list.d/oneAPI.list && \
add-apt-repository -y ppa:kobuk-team/intel-graphics
RUN apt clean && apt-get update -y && \
apt-get install -y --no-install-recommends --fix-missing \
curl \
ffmpeg \
git \
libsndfile1 \
libsm6 \
libxext6 \
libgl1 \
lsb-release \
libaio-dev \
numactl \
wget \
vim \
python3.12 \
python3.12-dev \
python3-pip
RUN apt update && apt upgrade -y && \
apt install -y libze1 libze-dev libze-intel-gpu1 intel-opencl-icd libze-intel-gpu-raytracing intel-ocloc && \
apt install -y intel-oneapi-compiler-dpcpp-cpp-2025.3
ENV PATH="/root/.local/bin:$PATH"
ENV VIRTUAL_ENV="/opt/venv"
ENV UV_PYTHON_INSTALL_DIR=/opt/uv/python
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
RUN uv venv --python ${PYTHON_VERSION} --seed ${VIRTUAL_ENV}
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
# This oneccl contains the BMG support which is not the case for default version of oneapi 2025.2.
ARG ONECCL_INSTALLER="intel-oneccl-2021.15.7.8_offline.sh"
RUN wget "https://github.com/uxlfoundation/oneCCL/releases/download/2021.15.7/${ONECCL_INSTALLER}" && \
bash "${ONECCL_INSTALLER}" -a --silent --eula accept && \
rm "${ONECCL_INSTALLER}" && \
echo "source /opt/intel/oneapi/setvars.sh --force" >> /root/.bashrc && \
echo "source /opt/intel/oneapi/ccl/2021.15/env/vars.sh --force" >> /root/.bashrc
RUN rm -f /opt/intel/oneapi/ccl/latest && \
ln -s /opt/intel/oneapi/ccl/2021.15 /opt/intel/oneapi/ccl/latest
SHELL ["bash", "-c"]
CMD ["bash", "-c", "source /root/.bashrc && exec bash"]
WORKDIR /workspace/
ENV UV_HTTP_TIMEOUT=500
# Configure package index for XPU
ENV PIP_EXTRA_INDEX_URL=${PIP_EXTRA_INDEX_URL}
ENV UV_EXTRA_INDEX_URL=${PIP_EXTRA_INDEX_URL}
ENV UV_INDEX_STRATEGY="unsafe-best-match"
ENV UV_LINK_MODE="copy"
ARG VLLM_VERSION=v0.16.0
RUN git clone -b ${VLLM_VERSION} https://github.com/vllm-project/vllm
WORKDIR /workspace/vllm
RUN --mount=type=cache,target=/root/.cache/uv \
uv pip install --upgrade pip && \
uv pip install -r requirements/xpu.txt
# used for suffix method speculative decoding
# build deps for proto + nanobind-based extensions to set up the build environment
RUN --mount=type=cache,target=/root/.cache/uv \
uv pip install grpcio-tools protobuf nanobind
# arctic-inference is built from source which needs torch-xpu properly installed first
RUN --mount=type=cache,target=/root/.cache/uv \
source /opt/intel/oneapi/setvars.sh --force && \
source /opt/intel/oneapi/ccl/2021.15/env/vars.sh --force && \
export CMAKE_PREFIX_PATH="$(python -c 'import site; print(site.getsitepackages()[0])'):${CMAKE_PREFIX_PATH}" && \
uv pip install --no-build-isolation arctic-inference==0.1.1
ENV LD_LIBRARY_PATH="$LD_LIBRARY_PATH:/usr/local/lib/"
ENV VLLM_TARGET_DEVICE=xpu
ENV VLLM_WORKER_MULTIPROC_METHOD=spawn
RUN --mount=type=cache,target=/root/.cache/uv \
uv pip install --no-build-isolation .
CMD ["/bin/bash"]
FROM vllm-base AS vllm-openai
# install additional dependencies for openai api server
RUN --mount=type=cache,target=/root/.cache/uv \
uv pip install accelerate hf_transfer pytest pytest_asyncio lm_eval[api] modelscope
# install development dependencies (for testing)
RUN uv pip install -e tests/vllm_test_utils
# install nixl from source code
ENV NIXL_VERSION=0.7.0
RUN python /workspace/vllm/tools/install_nixl_from_source_ubuntu.py
# ensure vllm is properly installed
RUN python -c "import vllm, inspect; print(vllm.__file__)"
RUN uv pip show vllm
CMD ["/bin/bash"]
ENTRYPOINT []
FROM ${VLLM_BASE} AS vllm-omni
WORKDIR /workspace/vllm-omni
COPY . .
ENV VLLM_OMNI_TARGET_DEVICE=xpu
RUN uv pip install --no-cache-dir ".[dev]" --no-build-isolation
# FIX triton
RUN --mount=type=cache,target=/root/.cache/uv \
uv pip uninstall triton triton-xpu && \
uv pip install triton-xpu==3.6.0
# remove torch bundled oneccl to avoid conflicts
RUN --mount=type=cache,target=/root/.cache/uv \
uv pip uninstall oneccl oneccl-devel
FROM vllm-omni AS vllm-omni-openai
RUN ln -sf /usr/bin/python3 /usr/bin/python
ENV VLLM_WORKER_MULTIPROC_METHOD=spawn
ENTRYPOINT ["vllm", "serve", "--omni"]
@@ -5,4 +5,5 @@ vLLM-Omni supports the following hardware platforms:
- [GPU](gpu.md)
- [NVIDIA CUDA](gpu.md)
- [AMD ROCm](gpu.md)
- [Intel XPU](gpu.md)
- [NPU](npu.md)
+20 -1
View File
@@ -18,6 +18,10 @@ vLLM-Omni is a Python library that supports the following GPU variants. The libr
--8<-- "docs/getting_started/installation/gpu/rocm.inc.md:requirements"
=== "Intel XPU"
--8<-- "docs/getting_started/installation/gpu/xpu.inc.md:requirements"
## Set up using Python
### Create a new Python environment
@@ -32,11 +36,14 @@ Note: Pre-built wheels are currently only available for vLLM-Omni 0.11.0rc1, 0.1
--8<-- "docs/getting_started/installation/gpu/cuda.inc.md:pre-built-wheels"
=== "AMD ROCm"
--8<-- "docs/getting_started/installation/gpu/rocm.inc.md:pre-built-wheels"
=== "Intel XPU"
--8<-- "docs/getting_started/installation/gpu/xpu.inc.md:pre-built-wheels"
[](){ #build-from-source }
### Build wheel from source
@@ -49,6 +56,10 @@ Note: Pre-built wheels are currently only available for vLLM-Omni 0.11.0rc1, 0.1
--8<-- "docs/getting_started/installation/gpu/rocm.inc.md:build-wheel-from-source"
=== "Intel XPU"
--8<-- "docs/getting_started/installation/gpu/xpu.inc.md:build-wheel-from-source"
## Set up using Docker
### Pre-built images
@@ -61,8 +72,16 @@ Note: Pre-built wheels are currently only available for vLLM-Omni 0.11.0rc1, 0.1
--8<-- "docs/getting_started/installation/gpu/rocm.inc.md:pre-built-images"
=== "Intel XPU"
--8<-- "docs/getting_started/installation/gpu/xpu.inc.md:pre-built-images"
### Build your own docker image
=== "AMD ROCm"
--8<-- "docs/getting_started/installation/gpu/rocm.inc.md:build-docker"
=== "Intel XPU"
--8<-- "docs/getting_started/installation/gpu/xpu.inc.md:build-docker"
@@ -0,0 +1,48 @@
# --8<-- [start:requirements]
- GPU: Validated on Intel® Arc™ B-Series.
# --8<-- [end:requirements]
# --8<-- [start:set-up-using-python]
vLLM-Omni currently recommends using the Docker image setup steps below.
# --8<-- [start:pre-built-wheels]
# --8<-- [end:pre-built-wheels]
# --8<-- [start:build-wheel-from-source]
# --8<-- [end:build-wheel-from-source]
# --8<-- [start:build-docker]
#### Build docker image
```bash
DOCKER_BUILDKIT=1 docker build -f docker/Dockerfile.xpu -t vllm-omni-xpu --shm-size=4g .
```
#### Launch the docker image
##### Launch with OpenAI API Server
```
docker run -it -d --shm-size 10g \
--name {container_name} \
--net=host \
--ipc=host \
--privileged \
-v /dev/dri/by-path:/dev/dri/by-path \
--device /dev/dri:/dev/dri \
-v ~/.cache/huggingface:/root/.cache/huggingface \
--env "HF_TOKEN=$HF_TOKEN" \
vllm-omni-xpu \
--model Qwen/Qwen2.5-Omni-3B --port 8091
```
# --8<-- [end:build-docker]
# --8<-- [start:pre-built-images]
# --8<-- [end:pre-built-images]
+255
View File
@@ -0,0 +1,255 @@
# VLLM-Omni PR Reviewer
## Overview
The VLLM-Omni PR Reviewer is an automated code review bot powered by GLM-4.7 AI model. It helps maintain code quality by providing intelligent feedback on pull requests.
## Features
- **Intelligent Code Analysis**: Leverages GLM-4.7 for understanding code context and providing meaningful feedback
- **Comprehensive Reviews**: Covers code quality, architecture, security, testing, and documentation
- **Structured Output**: Provides well-formatted reviews with clear sections and actionable suggestions
- **Rate Limiting**: Built-in cooldown mechanism to prevent excessive API usage
- **Retry Logic**: Automatic retries with exponential backoff for transient API failures
- **Defensive Parsing**: Robust validation of API responses to handle malformed data
- **Cost Control**: Only repository members/collaborators/owners can trigger reviews
## How to Use
### Triggering a Review
To trigger an automated PR review, mention the bot in a PR comment:
```
@vllm-omni-reviewer please review
```
Or include in your PR description:
```
@vllm-omni-reviewer
```
The bot will automatically review your changes and post a detailed comment.
## What Gets Reviewed
- **vLLM Architecture Compatibility**: Ensures changes align with vLLM's design patterns
- **Multi-modal Integration**: Reviews audio, vision, and text processing implementations
- **Performance Implications**: Analyzes impact on inference latency and throughput
- **Code Quality**: Checks Python best practices, type hints, and documentation
- **Security Considerations**: Identifies potential security vulnerabilities
- **Testing Coverage**: Recommends additional test cases when needed
## Review Output
The bot posts a structured review comment with:
- **Overview**: Brief summary of the PR's purpose
- **Critical Issues (Must Fix)**: Blocking issues that need to be addressed
- **Important Issues (Should Fix)**: Significant concerns that should be resolved
- **Minor Issues & Suggestions**: Small improvements and optional suggestions
- **Positive Aspects**: Highlights well-implemented features
- **Performance Considerations**: Analysis of performance impact
- **Testing Recommendations**: Suggestions for additional tests
- **Overall Assessment**: Final recommendation (Approve/Request Changes/Needs Major Work)
## Rate Limiting and Cooldown
The bot includes a cooldown mechanism to prevent excessive API usage:
- **Default cooldown**: 5 minutes between reviews per PR
- **Configurable**: Can be adjusted via `PR_REVIEWER_COOLDOWN_MINUTES` environment variable
- **Smart detection**: Checks for previous bot comments before starting a review
If you trigger a review within the cooldown period, the bot will log a message and skip the review.
## Architecture
```
┌─────────────────┐
│ PR Comment │
│ @vllm-omni- │
│ reviewer │
└────────┬────────┘
│
▼
┌─────────────────────────────────┐
│ GitHub Actions Workflow │
│ (.github/workflows/ │
│ pr-reviewer.yml) │
│ │
│ - Python 3.11 │
│ - requests==2.31.0 │
│ - pyyaml==6.0.1 │
└────────┬────────────────────────┘
│
▼
┌─────────────────────────────────┐
│ PR Reviewer Script │
│ (.github/scripts/ │
│ pr_reviewer.py) │
│ │
│ 1. Check cooldown │
│ 2. Fetch PR details & diff │
│ 3. Build review prompt │
│ 4. Call GLM-4.7 API │
│ (with retry logic) │
│ 5. Validate response │
│ 6. Post review comment │
└────────┬────────────────────────┘
│
▼
┌─────────────────────────────────┐
│ GLM-4.7 API │
│ (open.bigmodel.cn) │
└─────────────────────────────────┘
```
1. **GitHub Actions Workflow** (`.github/workflows/pr-reviewer.yml`): Triggers on @mention
2. **Python Script** (`.github/scripts/pr_reviewer.py`): Fetches PR data and calls GLM-4.7 API
3. **GLM-4.7 API**: Provides intelligent code analysis
## Testing
### Testing the PR Reviewer Bot
To test the PR reviewer bot before deploying to production:
1. **Create a test PR** - Make a small, safe change (e.g., documentation update)
2. **Open the PR** - Create a pull request with a descriptive title
3. **Trigger the review** - Comment `@vllm-omni-reviewer` on the PR
4. **Monitor results** - Check the Actions tab for workflow execution logs
### Running Unit Tests
The bot includes comprehensive unit tests that can be run locally:
```bash
# Run all tests
pytest .github/tests/test_pr_reviewer.py -v
# Run specific test
pytest .github/tests/test_pr_reviewer.py::TestCheckTrigger -v
# Run with coverage
pytest .github/tests/test_pr_reviewer.py --cov=.github/scripts/pr_reviewer.py --cov-report=term-missing
```
### What to Look For
When testing, verify that:
- [ ] The workflow triggers on the `@vllm-omni-reviewer` comment
- [ ] The cooldown mechanism works correctly
- [ ] The GLM API call completes without errors (with retry if needed)
- [ ] A review comment is posted to the PR
- [ ] The review content is meaningful and well-structured
- [ ] The cost is within the expected range (0.50-5 CNY)
### Safe Test Changes
For testing, consider making these types of safe changes:
- Documentation updates (like adding this Testing section)
- Comment improvements
- README enhancements
- Non-functional file additions
### Example Test PR
A good test PR might:
- Update a documentation file
- Add explanatory comments
- Improve code formatting
- Fix a minor typo
These changes are safe to merge if the test is successful and won't affect functionality.
## Troubleshooting
### Bot Doesn't Respond
1. **Check permissions** - Verify you have Owner/Member/Collaborator access
2. **Check Actions tab** - Look for workflow execution and view logs
3. **Check cooldown** - If another review was posted recently, wait for the cooldown period
4. **Check API key** - Ensure `GLM_API_KEY` is configured in repository secrets
### API Errors
If the GLM API call fails:
- Check the Actions tab for detailed error logs
- Verify the `GLM_API_KEY` secret is correctly configured
- Ensure sufficient API quota is available
- The bot will automatically retry up to 3 times with exponential backoff
### Review Seems Truncated
If the review appears incomplete:
- Large diffs may be truncated at 100,000 characters
- Check the logs for truncation warnings
- Consider breaking large PRs into smaller chunks
## Configuration
### Required Secrets
The following secret must be configured in the repository settings:
- `GLM_API_KEY` - Your GLM (BigModel) API key for accessing the GLM-4.7 API
To add the secret:
1. Go to repository Settings → Secrets and variables → Actions
2. Click "New repository secret"
3. Name: `GLM_API_KEY`
4. Value: Your GLM API key
### Optional Configuration
The following optional environment variables can be set in the workflow file:
| Variable | Default | Description |
|----------|---------|-------------|
| `GLM_API_URL` | `https://open.bigmodel.cn/api/paas/v4/chat/completions` | GLM API endpoint |
| `GLM_MODEL` | `glm-4.7` | Model to use for reviews |
| `PR_REVIEWER_COOLDOWN_MINUTES` | `5` | Cooldown period between reviews |
| `PR_REVIEWER_MAX_RETRIES` | `3` | Maximum API retry attempts |
| `PR_REVIEWER_RETRY_DELAY` | `1.0` | Base delay for retry backoff (seconds) |
| `PR_REVIEWER_MAX_DIFF_SIZE` | `100000` | Maximum diff size before truncation |
### Workflow Customization
The workflow can be customized in `.github/workflows/pr-reviewer.yml`:
- Change Python version (default: 3.11)
- Adjust timeout value (default: 10 minutes)
- Modify trigger conditions
- Add additional dependencies
## Code Quality
The PR reviewer script follows vllm-omni coding standards:
- **Type hints**: All functions have complete type hints following mypy strict mode
- **Logging**: Uses Python's logging module for structured logging
- **Testing**: Comprehensive unit tests with pytest
- **Pre-commit**: Script is checked by pre-commit hooks (flake8)
## Cost Estimate
| Component | Cost |
|-----------|------|
| GitHub Actions (public repo) | Free |
| GLM API (glm-4.7) | ~0.50-5 CNY per PR (varies by size) |
| Total (20 PRs/month) | ~10-100 CNY/month (~$2-15 USD) |
## Contributing
To improve the PR reviewer bot:
1. Edit `.github/scripts/pr_reviewer.py` for logic changes
2. Edit `.github/workflows/pr-reviewer.yml` for workflow changes
3. Add tests to `.github/tests/test_pr_reviewer.py`
4. Run `pre-commit run --files .github/scripts/pr_reviewer.py` to check code quality
5. Test thoroughly with a test PR before deploying to production
## License
This bot is part of the VLLM-Omni project and follows the same license terms.
+124 -88
View File
@@ -4,18 +4,21 @@ Provides single and batch sample inputs for CustomVoice, VoiceDesign, and Base
tasks, then runs Omni generation and saves output wav files.
"""
import logging
import os
from typing import NamedTuple
from typing import Any, NamedTuple
import soundfile as sf
import torch
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
from vllm import SamplingParams
from vllm.utils.argparse_utils import FlexibleArgumentParser
from vllm_omni import Omni
logger = logging.getLogger(__name__)
class QueryResult(NamedTuple):
"""Container for a prepared Omni request."""
@@ -24,6 +27,44 @@ class QueryResult(NamedTuple):
model_name: str
def _estimate_prompt_len(
additional_information: dict[str, Any],
model_name: str,
_cache: dict[str, Any] = {},
) -> int:
"""Estimate prompt_token_ids placeholder length for the Talker stage.
The AR Talker replaces all input embeddings via ``preprocess``, so the
placeholder values are irrelevant but the **length** must match the
embeddings that ``preprocess`` will produce.
"""
try:
from vllm_omni.model_executor.models.qwen3_tts.configuration_qwen3_tts import Qwen3TTSConfig
from vllm_omni.model_executor.models.qwen3_tts.qwen3_tts_talker import (
Qwen3TTSTalkerForConditionalGeneration,
)
if model_name not in _cache:
from transformers import AutoTokenizer
tok = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True, padding_side="left")
cfg = Qwen3TTSConfig.from_pretrained(model_name, trust_remote_code=True)
_cache[model_name] = (tok, getattr(cfg, "talker_config", None))
tok, tcfg = _cache[model_name]
task_type = (additional_information.get("task_type") or ["CustomVoice"])[0]
return Qwen3TTSTalkerForConditionalGeneration.estimate_prompt_len_from_additional_information(
additional_information=additional_information,
task_type=task_type,
tokenize_prompt=lambda t: tok(t, padding=False)["input_ids"],
codec_language_id=getattr(tcfg, "codec_language_id", None),
spk_is_dialect=getattr(tcfg, "spk_is_dialect", None),
)
except Exception as exc:
logger.warning("Failed to estimate prompt length, using fallback 2048: %s", exc)
return 2048
def get_custom_voice_query(use_batch_sample: bool = False) -> QueryResult:
"""Build CustomVoice sample inputs.
@@ -34,6 +75,7 @@ def get_custom_voice_query(use_batch_sample: bool = False) -> QueryResult:
QueryResult with Omni inputs and the CustomVoice model path.
"""
task_type = "CustomVoice"
model_name = "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice"
if use_batch_sample:
texts = ["其实我真的有发现,我是一个特别善于观察别人情绪的人。", "She said she would be here by noon."]
instructs = ["", "Very happy."]
@@ -41,18 +83,18 @@ def get_custom_voice_query(use_batch_sample: bool = False) -> QueryResult:
speakers = ["Vivian", "Ryan"]
inputs = []
for text, instruct, language, speaker in zip(texts, instructs, languages, speakers):
prompt = f"<|im_start|>assistant\n{text}<|im_end|>\n<|im_start|>assistant\n"
additional_information = {
"task_type": [task_type],
"text": [text],
"instruct": [instruct],
"language": [language],
"speaker": [speaker],
"max_new_tokens": [2048],
}
inputs.append(
{
"prompt": prompt,
"additional_information": {
"task_type": [task_type],
"text": [text],
"instruct": [instruct],
"language": [language],
"speaker": [speaker],
"max_new_tokens": [2048],
},
"prompt_token_ids": [0] * _estimate_prompt_len(additional_information, model_name),
"additional_information": additional_information,
}
)
else:
@@ -60,21 +102,21 @@ def get_custom_voice_query(use_batch_sample: bool = False) -> QueryResult:
language = "Chinese"
speaker = "Vivian"
instruct = "用特别愤怒的语气说"
prompts = f"<|im_start|>assistant\n{text}<|im_end|>\n<|im_start|>assistant\n"
additional_information = {
"task_type": [task_type],
"text": [text],
"language": [language],
"speaker": [speaker],
"instruct": [instruct],
"max_new_tokens": [2048],
}
inputs = {
"prompt": prompts,
"additional_information": {
"task_type": [task_type],
"text": [text],
"language": [language],
"speaker": [speaker],
"instruct": [instruct],
"max_new_tokens": [2048],
},
"prompt_token_ids": [0] * _estimate_prompt_len(additional_information, model_name),
"additional_information": additional_information,
}
return QueryResult(
inputs=inputs,
model_name="Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice",
model_name=model_name,
)
@@ -88,6 +130,7 @@ def get_voice_design_query(use_batch_sample: bool = False) -> QueryResult:
QueryResult with Omni inputs and the VoiceDesign model path.
"""
task_type = "VoiceDesign"
model_name = "Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign"
if use_batch_sample:
texts = [
"哥哥,你回来啦,人家等了你好久好久了,要抱抱!",
@@ -100,39 +143,39 @@ def get_voice_design_query(use_batch_sample: bool = False) -> QueryResult:
languages = ["Chinese", "English"]
inputs = []
for text, instruct, language in zip(texts, instructs, languages):
prompt = f"<|im_start|>assistant\n{text}<|im_end|>\n<|im_start|>assistant\n"
inputs.append(
{
"prompt": prompt,
"additional_information": {
"task_type": [task_type],
"text": [text],
"language": [language],
"instruct": [instruct],
"max_new_tokens": [2048],
"non_streaming_mode": [True],
},
}
)
else:
text = "哥哥,你回来啦,人家等了你好久好久了,要抱抱!"
instruct = "体现撒娇稚嫩的萝莉女声,音调偏高且起伏明显,营造出黏人、做作又刻意卖萌的听觉效果。"
language = "Chinese"
prompt = f"<|im_start|>assistant\n{text}<|im_end|>\n<|im_start|>assistant\n"
inputs = {
"prompt": prompt,
"additional_information": {
additional_information = {
"task_type": [task_type],
"text": [text],
"language": [language],
"instruct": [instruct],
"max_new_tokens": [2048],
"non_streaming_mode": [True],
},
}
inputs.append(
{
"prompt_token_ids": [0] * _estimate_prompt_len(additional_information, model_name),
"additional_information": additional_information,
}
)
else:
text = "哥哥,你回来啦,人家等了你好久好久了,要抱抱!"
instruct = "体现撒娇稚嫩的萝莉女声,音调偏高且起伏明显,营造出黏人、做作又刻意卖萌的听觉效果。"
language = "Chinese"
additional_information = {
"task_type": [task_type],
"text": [text],
"language": [language],
"instruct": [instruct],
"max_new_tokens": [2048],
"non_streaming_mode": [True],
}
inputs = {
"prompt_token_ids": [0] * _estimate_prompt_len(additional_information, model_name),
"additional_information": additional_information,
}
return QueryResult(
inputs=inputs,
model_name="Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign",
model_name=model_name,
)
@@ -147,6 +190,7 @@ def get_base_query(use_batch_sample: bool = False, mode_tag: str = "icl") -> Que
QueryResult with Omni inputs and the Base model path.
"""
task_type = "Base"
model_name = "Qwen/Qwen3-TTS-12Hz-1.7B-Base"
ref_audio_path_1 = "https://qianwen-res.oss-cn-beijing.aliyuncs.com/Qwen3-TTS-Repo/clone_2.wav"
ref_audio_single = ref_audio_path_1
ref_text_single = (
@@ -163,38 +207,38 @@ def get_base_query(use_batch_sample: bool = False, mode_tag: str = "icl") -> Que
syn_lang_batch = ["Chinese", "English"]
inputs = []
for text, language in zip(syn_text_batch, syn_lang_batch):
prompt = f"<|im_start|>assistant\n{text}<|im_end|>\n<|im_start|>assistant\n"
inputs.append(
{
"prompt": prompt,
"additional_information": {
"task_type": [task_type],
"ref_audio": [ref_audio_single],
"ref_text": [ref_text_single],
"text": [text],
"language": [language],
"x_vector_only_mode": [x_vector_only_mode],
"max_new_tokens": [2048],
},
}
)
else:
prompt = f"<|im_start|>assistant\n{syn_text_single}<|im_end|>\n<|im_start|>assistant\n"
inputs = {
"prompt": prompt,
"additional_information": {
additional_information = {
"task_type": [task_type],
"ref_audio": [ref_audio_single],
"ref_text": [ref_text_single],
"text": [syn_text_single],
"language": [syn_lang_single],
"text": [text],
"language": [language],
"x_vector_only_mode": [x_vector_only_mode],
"max_new_tokens": [2048],
},
}
inputs.append(
{
"prompt_token_ids": [0] * _estimate_prompt_len(additional_information, model_name),
"additional_information": additional_information,
}
)
else:
additional_information = {
"task_type": [task_type],
"ref_audio": [ref_audio_single],
"ref_text": [ref_text_single],
"text": [syn_text_single],
"language": [syn_lang_single],
"x_vector_only_mode": [x_vector_only_mode],
"max_new_tokens": [2048],
}
inputs = {
"prompt_token_ids": [0] * _estimate_prompt_len(additional_information, model_name),
"additional_information": additional_information,
}
return QueryResult(
inputs=inputs,
model_name="Qwen/Qwen3-TTS-12Hz-1.7B-Base",
model_name=model_name,
)
@@ -223,30 +267,22 @@ def main(args):
stage_init_timeout=args.stage_init_timeout,
)
sampling_params = SamplingParams(
temperature=0.9,
top_p=1.0,
top_k=50,
max_tokens=2048,
seed=42,
detokenize=False,
repetition_penalty=1.05,
)
sampling_params_list = [
sampling_params,
]
output_dir = args.output_dir if getattr(args, "output_dir", None) else args.output_wav
os.makedirs(output_dir, exist_ok=True)
omni_generator = omni.generate(query_result.inputs, sampling_params_list)
omni_generator = omni.generate(query_result.inputs, sampling_params_list=None)
for stage_outputs in omni_generator:
for output in stage_outputs.request_output:
request_id = output.request_id
audio_tensor = output.outputs[0].multimodal_output["audio"]
audio_data = output.outputs[0].multimodal_output["audio"]
# async_chunk mode returns a list of chunks; concatenate them.
if isinstance(audio_data, list):
audio_tensor = torch.cat(audio_data, dim=-1)
else:
audio_tensor = audio_data
output_wav = os.path.join(output_dir, f"output_{request_id}.wav")
audio_samplerate = output.outputs[0].multimodal_output["sr"].item()
sr_val = output.outputs[0].multimodal_output["sr"]
audio_samplerate = sr_val.item() if hasattr(sr_val, "item") else int(sr_val[-1])
# Convert to numpy array and ensure correct format
audio_numpy = audio_tensor.float().detach().cpu().numpy()
@@ -15,38 +15,43 @@ from PIL import Image
SEED = 42
DEFAULT_SAMPLING_PARAMS = {
"thinker": {
"temperature": 0.0,
"top_p": 1.0,
"top_k": -1,
"max_tokens": 2048,
"seed": SEED,
"detokenize": True,
"repetition_penalty": 1.1,
},
"talker": {
"temperature": 0.9,
"top_p": 0.8,
"top_k": 40,
"max_tokens": 2048,
"seed": SEED,
"detokenize": True,
"repetition_penalty": 1.05,
"stop_token_ids": [8294],
},
"code2wav": {
"temperature": 0.0,
"top_p": 1.0,
"top_k": -1,
"max_tokens": 2048,
"seed": SEED,
"detokenize": True,
"repetition_penalty": 1.1,
},
}
SUPPORTED_MODELS: dict[str, dict[str, Any]] = {
"Qwen/Qwen2.5-Omni-3B": {
"sampling_params": DEFAULT_SAMPLING_PARAMS,
},
"Qwen/Qwen2.5-Omni-7B": {
"sampling_params": {
"thinker": {
"temperature": 0.0,
"top_p": 1.0,
"top_k": -1,
"max_tokens": 2048,
"seed": SEED,
"detokenize": True,
"repetition_penalty": 1.1,
},
"talker": {
"temperature": 0.9,
"top_p": 0.8,
"top_k": 40,
"max_tokens": 2048,
"seed": SEED,
"detokenize": True,
"repetition_penalty": 1.05,
"stop_token_ids": [8294],
},
"code2wav": {
"temperature": 0.0,
"top_p": 1.0,
"top_k": -1,
"max_tokens": 2048,
"seed": SEED,
"detokenize": True,
"repetition_penalty": 1.1,
},
},
"sampling_params": DEFAULT_SAMPLING_PARAMS,
},
}
# Ensure deterministic behavior across runs.
@@ -310,10 +310,12 @@ class TestTTSMethods:
speech_server.engine_client.stage_list = [mock_stage]
assert speech_server._is_tts_model() is True
def test_build_tts_prompt(self, speech_server):
"""Test TTS prompt format."""
prompt = speech_server._build_tts_prompt("Hello")
assert prompt == "<|im_start|>assistant\nHello<|im_end|>\n<|im_start|>assistant\n"
def test_estimate_prompt_len_fallback(self, speech_server):
"""Test prompt length estimation falls back to 2048 when model is unavailable."""
tts_params = {"text": ["Hello"], "task_type": ["CustomVoice"]}
result = speech_server._estimate_prompt_len(tts_params)
# Without a real model, it should fall back to 2048.
assert result == 2048
def test_validate_tts_request_basic(self, speech_server):
"""Test basic validation cases."""
@@ -69,6 +69,7 @@ def _make_runner(req_ids=("r1", "r2"), hidden_size=4):
runner.text_step = DummyBuffer(torch.zeros((bsz, hidden_size), dtype=torch.float32))
runner.talker_mtp = DummyTalkerMTP()
runner.model = SimpleNamespace(talker_mtp_output_key="code_predictor_codes")
runner.vllm_config = object()
# Provide a minimal implementation that returns the expected 4-tuple.
+16
View File
@@ -58,6 +58,7 @@ class OmniModelConfig(ModelConfig):
}
)
omni_kv_config: dict | None = None
codec_frame_rate_hz: float | None = None
@property
def registry(self):
@@ -130,6 +131,21 @@ class OmniModelConfig(ModelConfig):
video_pruning_rate=video_pruning_rate,
)
# Qwen3-TTS: infer codec frame rate from the model config for online serving.
if self.codec_frame_rate_hz is None and self.model_arch == "Qwen3TTSTalkerForConditionalGenerationARVLLM":
talker_cfg = getattr(self.hf_config, "talker_config", None)
if isinstance(talker_cfg, dict):
pos_per_sec = talker_cfg.get("position_id_per_seconds")
else:
pos_per_sec = getattr(talker_cfg, "position_id_per_seconds", None)
if pos_per_sec is not None:
try:
fps = float(pos_per_sec)
except Exception:
fps = None
if fps is not None and fps > 0:
self.codec_frame_rate_hz = fps
# Override hf_text_config with omni-specific logic for multi-stage models
# (e.g., thinker_config, talker_config)
new_hf_text_config = self.draw_hf_text_config()
@@ -62,7 +62,6 @@ class OmniGenerationScheduler(VLLMScheduler):
while req_index < len(self.running) and token_budget > 0:
request = self.running[req_index]
# OMNI: Skip requests that are not in self.requests
# This can happen when connector marks request as finished and it's removed from requests
if request.request_id not in self.requests or (
self.chunk_transfer_adapter is None and request.status == RequestStatus.FINISHED_STOPPED
):
@@ -71,7 +70,25 @@ class OmniGenerationScheduler(VLLMScheduler):
continue
num_computed_tokens = request.num_computed_tokens
required_tokens = max(len(request.prompt_token_ids) - num_computed_tokens, 1)
required_tokens = len(request.prompt_token_ids) - num_computed_tokens
# async_chunk: don't schedule placeholder tokens when no new chunk is available.
if required_tokens <= 0:
if (
self.chunk_transfer_adapter is not None
and request.request_id in self.chunk_transfer_adapter.finished_requests
):
request.status = RequestStatus.FINISHED_STOPPED
# Upstream may finish with no terminal tokens; append one pad token so we can emit FINISHED.
if len(request.prompt_token_ids) <= num_computed_tokens:
request.prompt_token_ids.append(0)
try:
request._all_token_ids.append(0) # type: ignore[attr-defined]
except Exception:
pass
required_tokens = len(request.prompt_token_ids) - num_computed_tokens
else:
req_index += 1
continue
num_new_tokens = min(required_tokens, token_budget)
new_blocks = self.kv_cache_manager.allocate_slots(
request,
@@ -109,6 +126,20 @@ class OmniGenerationScheduler(VLLMScheduler):
self.waiting.pop_request()
continue
# async_chunk: wait for the first upstream chunk (don't start with placeholders).
if self.chunk_transfer_adapter is not None and len(request.prompt_token_ids) == 0:
if request.request_id in self.chunk_transfer_adapter.finished_requests:
request.status = RequestStatus.FINISHED_STOPPED
request.prompt_token_ids.append(0)
try:
request._all_token_ids.append(0) # type: ignore[attr-defined]
except Exception:
pass
else:
self.waiting.pop_request()
skipped_waiting_requests.prepend_request(request)
continue
# Uniformly treat as diffusion. A feature flag can be added later
# via config or request tag.
@@ -145,11 +176,13 @@ class OmniGenerationScheduler(VLLMScheduler):
# If fast path scheduled none, fall back to the original scheduling
if not num_scheduled_tokens:
res = super().schedule()
if self.chunk_transfer_adapter:
# Don't fall back: base scheduler doesn't handle async_chunk
# requests with empty prompt_token_ids.
self.chunk_transfer_adapter.restore_queues(self.waiting, self.running)
self.chunk_transfer_adapter.postprocess_scheduler_output(res)
return res
else:
res = super().schedule()
return res
# Compute common prefix blocks (aligned with v1)
num_common_prefix_blocks = [0] * len(self.kv_cache_config.kv_cache_groups)
@@ -318,9 +351,8 @@ class OmniGenerationScheduler(VLLMScheduler):
continue
request = self.requests.get(req_id)
if request is None or request.is_finished():
# The request is already finished. This can happen if the
# request is aborted while the model is executing it (e.g.,
# in pipeline parallelism or async scheduling).
# Request may already be finished (e.g., aborted during
# execution / pipeline parallelism / async scheduling).
continue
req_index = model_runner_output.req_id_to_index[req_id]
@@ -360,8 +392,14 @@ class OmniGenerationScheduler(VLLMScheduler):
routed_experts = None
# Diffusion request: completes in one step; mark finished and free resources
if request.status == RequestStatus.FINISHED_STOPPED or (
self.chunk_transfer_adapter is None and request.num_computed_tokens >= request.num_prompt_tokens
if (
request.status == RequestStatus.FINISHED_STOPPED
or (self.chunk_transfer_adapter is None and request.num_computed_tokens >= request.num_prompt_tokens)
or (
self.chunk_transfer_adapter is not None
and request.request_id in self.chunk_transfer_adapter.finished_requests
and request.num_computed_tokens >= len(request.prompt_token_ids)
)
):
request.status = RequestStatus.FINISHED_STOPPED
# Optional: set a stop_reason for front-end clarity
@@ -375,15 +413,11 @@ class OmniGenerationScheduler(VLLMScheduler):
finished = self._handle_stopped_request(request)
if finished:
kv_transfer_params = self._free_request(request)
if status_before_stop == RequestStatus.RUNNING:
stopped_running_reqs.add(request)
elif status_before_stop == RequestStatus.WAITING_FOR_CHUNK:
# In async chunk mode, request may be in either queue.
# Remove from both to avoid stale queue entries.
if status_before_stop == RequestStatus.WAITING_FOR_CHUNK:
stopped_running_reqs.add(request)
stopped_preempted_reqs.add(request)
else:
stopped_preempted_reqs.add(request)
stopped_running_reqs.add(request)
# Extract sample logprobs if needed.
if request.sampling_params is not None and request.sampling_params.logprobs is not None and logprobs:
@@ -64,6 +64,7 @@ from vllm.v1.attention.backend import AttentionType
from vllm_omni.diffusion.attention.layer import Attention
from vllm_omni.diffusion.distributed.parallel_state import get_pp_group
from vllm_omni.diffusion.distributed.utils import get_local_device
from vllm_omni.diffusion.layers.rope import RotaryEmbedding
logger = logging.getLogger(__name__)
@@ -1737,6 +1738,7 @@ class HunyuanImage3Model(nn.Module):
lora_config = None
self.num_redundant_experts = 0
self.config = config
self.device = get_local_device()
self.quant_config = quant_config
self.padding_idx = config.pad_token_id
lora_vocab = (lora_config.lora_extra_vocab_size * (lora_config.max_loras or 1)) if lora_config else 0
@@ -2430,7 +2432,7 @@ class HunyuanImage3Text2ImagePipeline(DiffusionPipeline):
**model_kwargs,
)
with torch.autocast(device_type="cuda", dtype=torch.bfloat16, enabled=True):
with torch.autocast(device_type=self.device.type, dtype=torch.bfloat16, enabled=True):
model_output = self.model.forward_call(**model_inputs, first_step=(i == 0))
pred = model_output["diffusion_prediction"]
pred = pred.to(dtype=torch.float32)
@@ -2477,7 +2479,7 @@ class HunyuanImage3Text2ImagePipeline(DiffusionPipeline):
if hasattr(self.vae, "ffactor_temporal"):
latents = latents.unsqueeze(2)
with torch.autocast(device_type="cuda", dtype=torch.float16, enabled=True):
with torch.autocast(device_type=self.device.type, dtype=torch.float16, enabled=True):
image = self.vae.decode(latents, return_dict=False, generator=generator)[0]
if hasattr(self.vae, "ffactor_temporal"):
@@ -140,7 +140,7 @@ class HunyuanImage3Pipeline(HunyuanImage3PreTrainedModel, GenerationMixin):
if hasattr(self, prefix.split(".")[0]):
module = dict(self.named_modules()).get(prefix)
if module:
module.to(f"cuda:{tp_rank}")
module.to(f"{self.model.device.type}:{tp_rank}")
def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
self.pre_load()
@@ -369,7 +369,7 @@ class HunyuanImage3Pipeline(HunyuanImage3PreTrainedModel, GenerationMixin):
def vae_encode(self, image, cfg_factor=1):
config = self.vae.config
with torch.autocast(device_type="cuda", dtype=torch.float16, enabled=True):
with torch.autocast(device_type=self.model.device.type, dtype=torch.float16, enabled=True):
vae_encode_result = self.vae.encode(image)
if isinstance(vae_encode_result, torch.Tensor):
latents = vae_encode_result
@@ -564,14 +564,14 @@ class ZImagePipeline(nn.Module):
# Run CFG only if configured AND scale is non-zero
apply_cfg = self.do_classifier_free_guidance and current_guidance_scale > 0
latents_typed = latents.to(self.od_config.dtype)
if apply_cfg:
latents_typed = latents.to(self.transformer.dtype)
latent_model_input = latents_typed.repeat(2, 1, 1, 1)
prompt_embeds_model_input = prompt_embeds + negative_prompt_embeds
timestep_model_input = timestep.repeat(2)
else:
latent_model_input = latents.to(self.transformer.dtype)
latent_model_input = latents_typed
prompt_embeds_model_input = prompt_embeds
timestep_model_input = timestep
@@ -614,7 +614,6 @@ class ZImageTransformer2DModel(CachedTransformer):
quant_config: "QuantizationConfig | None" = None,
) -> None:
super().__init__()
self.dtype = torch.bfloat16
self.in_channels = in_channels
self.out_channels = in_channels
self.all_patch_size = all_patch_size
@@ -179,11 +179,17 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
else:
if payload_data.get("finished"):
self.finished_requests.add(req_id)
req.status = RequestStatus.FINISHED_STOPPED
req.prompt_token_ids = payload_data.get("code_predictor_codes", [])
# req.prompt_token_ids = payload_data.get("code_predictor_codes", [])
# req.num_computed_tokens = 0
new_ids = payload_data.get("code_predictor_codes", [])
req.prompt_token_ids = new_ids
req.num_computed_tokens = 0
# Empty chunk with more data expected: keep polling.
if not new_ids and not payload_data.get("finished"):
return
# Mark as finished for consumption
with self.lock:
self._finished_load_reqs.add(req_id)
@@ -308,7 +314,6 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase):
# of schedule, but have not scheduled
continue
if request.request_id in self.finished_requests:
request.additional_information = {}
continue
# Requests that waiting for chunk
self.load_async(request)
+2 -1
View File
@@ -390,7 +390,8 @@ class AsyncOmni(OmniBase):
submit_flag = False
prompt_token_ids = engine_outputs.prompt_token_ids
engine_input = copy.deepcopy(prompt)
engine_input["prompt_token_ids"] = [0] * compute_talker_prompt_ids_length(prompt_token_ids)
next_prompt_len = max(1, compute_talker_prompt_ids_length(prompt_token_ids))
engine_input["prompt_token_ids"] = [0] * next_prompt_len
engine_input["multi_modal_data"] = engine_input["mm_processor_kwargs"] = None
for i in range(1, len(self.stage_list)):
task = {
+107 -9
View File
@@ -1,6 +1,14 @@
import asyncio
import base64
import io
import ipaddress
import socket
from typing import Any
from urllib.parse import urlparse
from urllib.request import urlopen
import numpy as np
import soundfile as sf
from fastapi import Request
from fastapi.responses import Response
from vllm.entrypoints.openai.engine.serving import OpenAIServing
@@ -17,6 +25,19 @@ from vllm_omni.outputs import OmniRequestOutput
logger = init_logger(__name__)
_REF_AUDIO_TIMEOUT_S = 15
_REF_AUDIO_MAX_BYTES = 50 * 1024 * 1024 # 50 MB
_REF_AUDIO_BLOCKED_NETWORKS = [
ipaddress.ip_network("127.0.0.0/8"),
ipaddress.ip_network("10.0.0.0/8"),
ipaddress.ip_network("172.16.0.0/12"),
ipaddress.ip_network("192.168.0.0/16"),
ipaddress.ip_network("169.254.0.0/16"),
ipaddress.ip_network("::1/128"),
ipaddress.ip_network("fc00::/7"),
ipaddress.ip_network("fe80::/10"),
]
# TTS Configuration (currently supports Qwen3-TTS)
_TTS_MODEL_STAGES: set[str] = {"qwen3_tts"}
_TTS_LANGUAGES: set[str] = {
@@ -43,6 +64,7 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
# Load supported speakers
self.supported_speakers = self._load_supported_speakers()
logger.info(f"Loaded {len(self.supported_speakers)} supported speakers: {sorted(self.supported_speakers)}")
self._tts_tokenizer = None
def _load_supported_speakers(self) -> set[str]:
"""Load supported speakers (case-insensitive) from the model configuration."""
@@ -62,6 +84,36 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
return set()
def _estimate_prompt_len(self, tts_params: dict[str, Any]) -> int:
"""Estimate prompt length so the placeholder matches model-side embeddings."""
try:
from vllm_omni.model_executor.models.qwen3_tts.qwen3_tts_talker import (
Qwen3TTSTalkerForConditionalGeneration,
)
if self._tts_tokenizer is None:
from transformers import AutoTokenizer
model_name = self.engine_client.model_config.model
self._tts_tokenizer = AutoTokenizer.from_pretrained(
model_name,
trust_remote_code=True,
padding_side="left",
)
hf_config = self.engine_client.model_config.hf_config
talker_config = hf_config.talker_config
task_type = (tts_params.get("task_type") or ["CustomVoice"])[0]
return Qwen3TTSTalkerForConditionalGeneration.estimate_prompt_len_from_additional_information(
additional_information=tts_params,
task_type=task_type,
tokenize_prompt=lambda t: self._tts_tokenizer(t, padding=False)["input_ids"],
codec_language_id=getattr(talker_config, "codec_language_id", None),
spk_is_dialect=getattr(talker_config, "spk_is_dialect", None),
)
except Exception as e:
logger.warning("Failed to estimate TTS prompt length, using fallback 2048: %s", e)
return 2048
def _is_tts_model(self) -> bool:
"""Check if the current model is a supported TTS model."""
stage_list = getattr(self.engine_client, "stage_list", None)
@@ -125,9 +177,44 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
return None
def _build_tts_prompt(self, text: str) -> str:
"""Build TTS prompt from input text."""
return f"<|im_start|>assistant\n{text}<|im_end|>\n<|im_start|>assistant\n"
@staticmethod
async def _resolve_ref_audio(ref_audio_str: str) -> tuple[list[float], int]:
"""Resolve ref_audio URL/base64 to (wav_samples, sample_rate)."""
parsed = urlparse(ref_audio_str)
def _check_ssrf(url: str) -> None:
host = urlparse(url).hostname
if not host:
raise ValueError("ref_audio URL must include a hostname")
for info in socket.getaddrinfo(host, None):
ip_str = str(info[4][0]).split("%", 1)[0]
addr = ipaddress.ip_address(ip_str)
if any(addr in net for net in _REF_AUDIO_BLOCKED_NETWORKS):
raise ValueError(f"ref_audio URL resolves to blocked address: {addr}")
def _fetch_sync() -> tuple[np.ndarray, int]:
if parsed.scheme in ("http", "https"):
_check_ssrf(ref_audio_str)
with urlopen(ref_audio_str, timeout=_REF_AUDIO_TIMEOUT_S) as resp:
data = resp.read(_REF_AUDIO_MAX_BYTES + 1)
if len(data) > _REF_AUDIO_MAX_BYTES:
raise ValueError(f"ref_audio URL exceeds {_REF_AUDIO_MAX_BYTES} bytes")
buf = io.BytesIO(data)
elif ref_audio_str.startswith("data:"):
b64 = ref_audio_str
if "," in b64:
b64 = b64.split(",", 1)[1]
buf = io.BytesIO(base64.b64decode(b64))
else:
raise ValueError("ref_audio must be an http(s) URL or data: base64 URI")
audio, sr = sf.read(buf, dtype="float32", always_2d=False)
if isinstance(audio, np.ndarray) and audio.ndim > 1:
audio = np.mean(audio, axis=-1)
return np.asarray(audio, dtype=np.float32), int(sr)
loop = asyncio.get_running_loop()
wav_np, sr = await loop.run_in_executor(None, _fetch_sync)
return wav_np.tolist(), sr
def _build_tts_params(self, request: OpenAICreateSpeechRequest) -> dict[str, Any]:
"""Build TTS parameters from request.
@@ -164,9 +251,7 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
else:
params["instruct"] = [""]
# Voice clone parameters (used with Base task)
if request.ref_audio is not None:
params["ref_audio"] = [request.ref_audio]
# Voice clone: ref_audio resolved in create_speech(), not here.
if request.ref_text is not None:
params["ref_text"] = [request.ref_text]
if request.x_vector_only_mode is not None:
@@ -221,11 +306,19 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
if validation_error:
return self.create_error_response(validation_error)
# Build TTS parameters and prompt
# Must use prompt_token_ids (not text prompt): the AR Talker
# operates on codec tokens; text token IDs exceed codec vocab.
# model.preprocess replaces all embeddings, so placeholder value
# is irrelevant -- but length must match to avoid excess padding.
tts_params = self._build_tts_params(request)
prompt_text = self._build_tts_prompt(request.input)
if request.ref_audio is not None:
wav_list, sr = await self._resolve_ref_audio(request.ref_audio)
tts_params["ref_audio"] = [[wav_list, sr]]
ph_len = self._estimate_prompt_len(tts_params)
prompt = {
"prompt": prompt_text,
"prompt_token_ids": [1] * ph_len,
"additional_information": tts_params,
}
else:
@@ -282,6 +375,11 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin):
if hasattr(sample_rate, "item"):
sample_rate = sample_rate.item()
# Streaming accumulates chunks as a list; concat first.
if isinstance(audio_tensor, list):
import torch
audio_tensor = torch.cat(audio_tensor, dim=-1)
# Convert tensor to numpy
if hasattr(audio_tensor, "float"):
audio_tensor = audio_tensor.float().detach().cpu().numpy()
@@ -504,19 +504,39 @@ class Qwen3TTSConfig(PretrainedConfig):
self.tts_bos_token_id = tts_bos_token_id
self.tts_eos_token_id = tts_eos_token_id
# TODO: remove these dummy values after
self.image_token_id = 0 # dummy image token id
self.video_token_id = 0 # dummy video token id
self.vision_start_token_id = 0 # dummy vision start token id
# Dummy vision token IDs that must never collide with real codec tokens.
# mrope scans prompt_token_ids for these; using -1 ensures no false match.
self.image_token_id = -1
self.video_token_id = -1
self.vision_start_token_id = -1
self.vision_config = PretrainedConfig() # dummy vision config
self.vision_config.spatial_merge_size = 1
@property
def codec_frame_rate_hz(self) -> float | None:
pos_per_sec = getattr(self.talker_config, "position_id_per_seconds", None)
if pos_per_sec is None:
return None
try:
fps = float(pos_per_sec)
except (TypeError, ValueError):
return None
return fps if fps > 0 else None
def get_text_config(self, **kwargs):
# vLLM expects text config to expose hidden_size/num_attention_heads.
# For Qwen3 TTS, the talker config is the text model config.
config = self.talker_config
# if hasattr(config, "rope_parameters"):
# delattr(config, "rope_parameters")
# Code2Wav is a pure convolutional waveform decoder; it does NOT use
# rotary position embeddings. When hf_overrides sets architectures
# to [Qwen3TTSCode2Wav], strip rope_parameters so that the model
# runner sees uses_mrope == False and skips mrope position computation
# on codec tokens. Each stage loads its own config instance, so this
# in-place mutation does not affect the Talker stage.
archs = getattr(self, "architectures", []) or []
if any("Code2Wav" in str(a) for a in archs):
if hasattr(config, "rope_parameters"):
delattr(config, "rope_parameters")
return config
File diff suppressed because it is too large. Load diff
@@ -1,102 +0,0 @@
# Copyright 2026 The Qwen team, Alibaba Group and the HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from transformers.feature_extraction_utils import BatchFeature
from transformers.processing_utils import ProcessingKwargs, ProcessorMixin
class Qwen3TTSProcessorKwargs(ProcessingKwargs, total=False):
_defaults = {
"text_kwargs": {
"padding": False,
"padding_side": "left",
}
}
class Qwen3TTSProcessor(ProcessorMixin):
r"""
Constructs a Qwen3TTS processor.
Args:
tokenizer ([`Qwen2TokenizerFast`], *optional*):
The text tokenizer.
chat_template (`Optional[str]`, *optional*):
The Jinja template to use for formatting the conversation.
If not provided, the default chat template is used.
"""
attributes = ["tokenizer"]
tokenizer_class = ("Qwen2Tokenizer", "Qwen2TokenizerFast")
def __init__(self, tokenizer=None, chat_template=None):
super().__init__(tokenizer, chat_template=chat_template)
def __call__(self, text=None, **kwargs) -> BatchFeature:
"""
Main method to prepare for the model one or several sequences(s) and audio(s).
This method forwards the `text` and `kwargs` arguments to
Qwen2TokenizerFast's [`~Qwen2TokenizerFast.__call__`] if `text` is not `None`
to encode the text.
Args:
text (`str`, `List[str]`, `List[List[str]]`):
The sequence or batch of sequences to be encoded. Each sequence can be a string or a list of strings
(pretokenized string). If the sequences are provided as list of strings (pretokenized), you must set
`is_split_into_words=True` (to lift the ambiguity with a batch of sequences).
"""
if text is None:
raise ValueError("You need to specify either a `text` input to process.")
output_kwargs = self._merge_kwargs(
Qwen3TTSProcessorKwargs,
tokenizer_init_kwargs=self.tokenizer.init_kwargs,
**kwargs,
)
if not isinstance(text, list):
text = [text]
texts_inputs = self.tokenizer(text, **output_kwargs["text_kwargs"])
return BatchFeature(
data={**texts_inputs},
tensor_type=kwargs.get("return_tensors"),
)
def batch_decode(self, *args, **kwargs):
"""
This method forwards all its arguments to Qwen2TokenizerFast's [`~PreTrainedTokenizer.batch_decode`]. Please
refer to the docstring of this method for more information.
"""
return self.tokenizer.batch_decode(*args, **kwargs)
def decode(self, *args, **kwargs):
"""
This method forwards all its arguments to Qwen2TokenizerFast's [`~PreTrainedTokenizer.decode`]. Please refer to
the docstring of this method for more information.
"""
return self.tokenizer.decode(*args, **kwargs)
def apply_chat_template(self, conversations, chat_template=None, **kwargs):
if isinstance(conversations[0], dict):
conversations = [conversations]
return super().apply_chat_template(conversations, chat_template, **kwargs)
@property
def model_input_names(self):
tokenizer_input_names = self.tokenizer.model_input_names
return list(dict.fromkeys(tokenizer_input_names))
__all__ = ["Qwen3TTSProcessor"]
@@ -0,0 +1,239 @@
from __future__ import annotations
import os
from collections.abc import Iterable
from typing import Any
import numpy as np
import torch
import torch.nn as nn
from transformers.utils.hub import cached_file
from vllm.config import VllmConfig
from vllm.logger import init_logger
from vllm_omni.model_executor.models.output_templates import OmniOutput
from .qwen3_tts_tokenizer import Qwen3TTSTokenizer
logger = init_logger(__name__)
class Qwen3TTSCode2Wav(nn.Module):
"""Stage-1 code2wav model for Qwen3-TTS (GenerationModelRunner).
Consumes frame-aligned codec tokens from input_ids and decodes waveform via SpeechTokenizer."""
input_modalities = "audio"
def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
super().__init__()
self.vllm_config = vllm_config
self.model_path = vllm_config.model_config.model
self.have_multimodal_outputs = True
self.has_preprocess = False
self.has_postprocess = False
# Generation-only stage (no logits / sampling).
self.requires_raw_input_tokens = True
self._speech_tokenizer: Qwen3TTSTokenizer | None = None
self._num_quantizers: int | None = None
self._decode_upsample_rate: int | None = None
self._output_sample_rate: int | None = None
self._logged_codec_stats = False
@staticmethod
def _module_device(module: nn.Module) -> torch.device:
try:
return next(module.parameters()).device
except StopIteration:
for _, buf in module.named_buffers(recurse=True):
return buf.device
return torch.device("cpu")
def _ensure_speech_tokenizer_loaded(self) -> Qwen3TTSTokenizer:
if self._speech_tokenizer is not None:
return self._speech_tokenizer
# Locate speech_tokenizer dir from HF cache (or local path).
cfg_path = cached_file(self.model_path, "speech_tokenizer/config.json")
if cfg_path is None:
raise ValueError(f"{self.model_path}/speech_tokenizer/config.json not found")
speech_tokenizer_dir = os.path.dirname(cfg_path)
# Stage-1 only needs decode; skip HF feature extractor to avoid heavy optional deps.
# Still require preprocessor_config.json (use cached_file so online runs can fetch it).
prep_cfg = cached_file(self.model_path, "speech_tokenizer/preprocessor_config.json")
if prep_cfg is None:
raise ValueError(
f"{self.model_path}/speech_tokenizer/preprocessor_config.json not found. "
"Please make sure the checkpoint contains the required HF preprocessing files."
)
tok = Qwen3TTSTokenizer.from_pretrained(
speech_tokenizer_dir,
torch_dtype=torch.bfloat16,
load_feature_extractor=False,
)
# Align device with vLLM worker, then read back from module.
if tok.model is not None:
tok.model.to(device=self.vllm_config.device_config.device)
tok.device = self._module_device(tok.model)
# Derive codec group count and rates from tokenizer config.
dec_cfg = getattr(tok.model.config, "decoder_config", None)
num_q = getattr(dec_cfg, "num_quantizers", None) if dec_cfg is not None else None
if num_q is None:
raise ValueError("speech_tokenizer decoder_config.num_quantizers not found")
num_q = int(num_q)
if num_q <= 0:
raise ValueError(f"Invalid speech_tokenizer num_quantizers={num_q}")
try:
upsample = int(tok.get_decode_upsample_rate())
except Exception as e:
raise ValueError(f"Failed to get decode upsample rate: {e}") from e
if upsample <= 0:
raise ValueError(f"Invalid decode upsample rate: {upsample}")
try:
out_sr = int(tok.get_output_sample_rate())
except Exception as e:
raise ValueError(f"Failed to get output sample rate: {e}") from e
self._speech_tokenizer = tok
self._num_quantizers = num_q
self._decode_upsample_rate = upsample
self._output_sample_rate = out_sr
return tok
def embed_input_ids(self, input_ids: torch.Tensor, **_: Any) -> torch.Tensor:
# This stage ignores token embeddings. Keep a stable dummy embedding for vLLM runner.
if input_ids.numel() == 0:
return torch.empty((0, 1), device=input_ids.device, dtype=torch.float32)
return torch.zeros((input_ids.shape[0], 1), device=input_ids.device, dtype=torch.float32)
def compute_logits(self, hidden_states: torch.Tensor | OmniOutput, sampling_metadata: Any = None) -> None:
return None
@torch.no_grad()
def forward(
self,
input_ids: torch.Tensor | None = None,
positions: torch.Tensor | None = None,
intermediate_tensors: Any = None,
inputs_embeds: torch.Tensor | None = None,
**kwargs: Any,
) -> tuple[torch.Tensor, torch.Tensor]:
"""Decode codec codes into audio waveform.
input_ids layout: [codec_context_frames, *flat_codes]
where flat_codes is codebook-major [q*F].
"""
tok = self._ensure_speech_tokenizer_loaded()
assert self._num_quantizers is not None
assert self._output_sample_rate is not None
sr_val = self._output_sample_rate
empty_ret = (
torch.zeros((0,), dtype=torch.float32),
torch.tensor(sr_val, dtype=torch.int32),
)
if input_ids is None:
return empty_ret
q = int(self._num_quantizers)
ids = input_ids.reshape(-1).to(dtype=torch.long)
n_tokens = ids.numel()
if n_tokens == 0:
return empty_ret
# input_ids[0] = codec_context_frames (prepended by stage_input_processor).
ctx_frames = int(ids[0].item())
ids = ids[1:]
n_tokens = ids.numel()
if n_tokens == 0:
return empty_ret
# Warmup / dummy_run: not divisible by num_quantizers.
if n_tokens % q != 0:
logger.warning(
"Code2Wav input_ids length %d not divisible by num_quantizers %d, "
"likely a warmup run; returning empty audio.",
n_tokens,
q,
)
return empty_ret
total_frames = n_tokens // q
# Reshape codebook-major flat [q*F] -> [q, F] -> [F, q] for SpeechTokenizer.
codes_fq = ids.reshape(q, total_frames).transpose(0, 1).contiguous()
if not self._logged_codec_stats and total_frames > 1:
self._logged_codec_stats = True
try:
uniq = int(torch.unique(codes_fq).numel())
cmin = int(codes_fq.min().item())
cmax = int(codes_fq.max().item())
head = codes_fq[: min(2, total_frames), : min(8, q)].cpu().tolist()
logger.info(
"Code2Wav codec: frames=%d q=%d uniq=%d range=[%d,%d] head=%s",
total_frames,
q,
uniq,
cmin,
cmax,
head,
)
except Exception:
pass
wavs, sr = tok.decode({"audio_codes": codes_fq})
if not wavs:
raise ValueError("SpeechTokenizer code2wav produced empty waveform list.")
audio_np = wavs[0].astype(np.float32, copy=False)
# Trim left-context waveform samples (streaming sliding window).
if ctx_frames > 0:
upsample = self._decode_upsample_rate
if upsample is None or upsample <= 0:
raise ValueError(f"Invalid decode upsample rate: {upsample}")
cut = ctx_frames * upsample
if cut < audio_np.shape[0]:
audio_np = audio_np[cut:]
else:
logger.warning(
"Context trim %d >= decoded length %d; returning empty audio.",
cut,
audio_np.shape[0],
)
return empty_ret
audio_tensor = torch.from_numpy(audio_np).to(dtype=torch.float32).reshape(-1)
sr_tensor = torch.tensor(int(sr), dtype=torch.int32)
return audio_tensor, sr_tensor
def make_omni_output(self, model_outputs: torch.Tensor | OmniOutput, **kwargs: Any) -> OmniOutput:
if isinstance(model_outputs, OmniOutput):
return model_outputs
if not (isinstance(model_outputs, tuple) and len(model_outputs) == 2):
raise TypeError(f"Qwen3TTSCode2Wav expected (audio_tensor, sr) outputs, got {type(model_outputs)}")
audio_tensor, sr = model_outputs
return OmniOutput(
text_hidden_states=None,
multimodal_outputs={
"model_outputs": audio_tensor,
"sr": sr,
},
)
def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
# SpeechTokenizer weights live under `speech_tokenizer/` and are loaded
# lazily from that directory. Ignore main checkpoint weights.
return set()
@@ -0,0 +1,495 @@
from __future__ import annotations
from collections.abc import Iterable
from typing import Any
import torch
import torch.nn as nn
from vllm.config import VllmConfig
from vllm.config.vllm import set_current_vllm_config
from vllm.forward_context import set_forward_context
from vllm.model_executor.layers.layernorm import RMSNorm
from vllm.model_executor.model_loader.weight_utils import (
default_weight_loader,
maybe_remap_kv_scale_name,
)
from vllm.model_executor.models.qwen3 import Qwen3DecoderLayer
from vllm.model_executor.models.utils import is_pp_missing_parameter
from vllm.v1.kv_cache_interface import KVCacheConfig, KVCacheGroupSpec, KVCacheSpec, KVCacheTensor
from vllm.v1.worker.gpu import attn_utils
from .configuration_qwen3_tts import Qwen3TTSTalkerCodePredictorConfig, Qwen3TTSTalkerConfig
class _LocalPredictorKVCache:
"""Minimal local KV cache + attention metadata for running
code_predictor inside one worker (independent of engine KV)."""
def __init__(
self,
*,
vllm_config: VllmConfig,
max_seq_len: int,
max_batch_size: int,
device: torch.device,
) -> None:
self.vllm_config = vllm_config
self.device = device
# Collect attention layers registered in this vllm_config.
kv_cache_spec_by_layer = attn_utils.get_kv_cache_spec(vllm_config)
if not kv_cache_spec_by_layer:
raise RuntimeError("Local predictor KVCache requires vLLM Attention layers to be registered.")
# We only need enough blocks for a tiny per-frame sequence (<= max_seq_len).
any_spec = next(iter(kv_cache_spec_by_layer.values()))
block_size = int(any_spec.block_size)
blocks_per_seq = (int(max_seq_len) + block_size - 1) // block_size
num_blocks = max(1, int(max_batch_size) * int(blocks_per_seq))
# Allocate per-layer KV caches (small, independent).
kv_cache_tensors: list[KVCacheTensor] = []
for layer_name, spec in kv_cache_spec_by_layer.items():
kv_cache_tensors.append(KVCacheTensor(size=int(spec.page_size_bytes) * num_blocks, shared_by=[layer_name]))
merged_spec: KVCacheSpec = KVCacheSpec.merge(list(kv_cache_spec_by_layer.values()))
self.kv_cache_config = KVCacheConfig(
num_blocks=num_blocks,
kv_cache_tensors=kv_cache_tensors,
kv_cache_groups=[
KVCacheGroupSpec(layer_names=list(kv_cache_spec_by_layer.keys()), kv_cache_spec=merged_spec)
],
)
# Init backend + bind KV cache tensors to attention modules.
self.attn_backends, self.attn_metadata_builders = attn_utils.init_attn_backend(
self.kv_cache_config, vllm_config, device
)
self.runner_kv_caches: list[torch.Tensor] = []
attn_utils.init_kv_cache(
self.runner_kv_caches,
vllm_config.compilation_config.static_forward_context,
self.kv_cache_config,
self.attn_backends,
device,
)
# Precompute a fixed block table mapping for the maximum batch.
self.block_size = block_size
self.blocks_per_seq = blocks_per_seq
self.max_batch_size = int(max_batch_size)
bt = torch.full((self.max_batch_size, self.blocks_per_seq), -1, dtype=torch.int32, device=device)
for i in range(self.max_batch_size):
for j in range(self.blocks_per_seq):
bt[i, j] = i * self.blocks_per_seq + j
self._block_table = bt
def build_attn_metadata(
self,
*,
num_reqs: int,
query_lens: torch.Tensor, # (num_reqs,) int32 on cpu
seq_lens: torch.Tensor, # (num_reqs,) int32 on cpu
) -> tuple[dict[str, Any], torch.Tensor, dict[str, torch.Tensor]]:
"""Build attention metadata, positions, and slot_mapping dict.
Returns:
(attn_metadata, positions, slot_mappings_by_layer)
- attn_metadata: per-layer attention metadata for attn backends.
- positions: (num_tokens,) position IDs on device.
- slot_mappings_by_layer: {layer_name: slot_mapping_tensor} for
set_forward_context so that unified_kv_cache_update can write
the KV cache correctly.
"""
num_reqs = int(num_reqs)
if num_reqs <= 0:
return {}, torch.empty((0,), dtype=torch.int64, device=self.device), {}
if num_reqs > self.max_batch_size:
raise ValueError(f"num_reqs={num_reqs} exceeds local predictor max_batch_size={self.max_batch_size}")
query_lens_i32 = query_lens.to(dtype=torch.int32, device="cpu")
seq_lens_i32 = seq_lens.to(dtype=torch.int32, device="cpu")
# query_start_loc: prefix sums of query_lens.
qsl = torch.zeros((num_reqs + 1,), dtype=torch.int32, device="cpu")
qsl[1:] = torch.cumsum(query_lens_i32, dim=0)
num_tokens = int(qsl[-1].item())
if num_tokens <= 0:
return {}, torch.empty((0,), dtype=torch.int64, device=self.device), {}
# positions: for each request i, emit positions [seq_len-query_len .. seq_len-1]
pos_list: list[torch.Tensor] = []
for i in range(num_reqs):
ql = int(query_lens_i32[i].item())
sl = int(seq_lens_i32[i].item())
start = sl - ql
pos_list.append(torch.arange(start, sl, dtype=torch.int64))
positions_cpu = torch.cat(pos_list, dim=0)
# slot_mapping: map each query token to a physical slot in the paged KV cache.
# We allocate per-request contiguous blocks; slot = base + position.
slot_mapping = torch.empty((num_tokens,), dtype=torch.int64, device="cpu")
cursor = 0
for i in range(num_reqs):
ql = int(query_lens_i32[i].item())
sl = int(seq_lens_i32[i].item())
start = sl - ql
for p in range(start, sl):
block_idx = p // self.block_size
offset = p % self.block_size
block_id = int(self._block_table[i, block_idx].item())
slot_mapping[cursor] = block_id * self.block_size + offset
cursor += 1
max_seq_len = int(seq_lens_i32[:num_reqs].max().item())
query_start_loc_gpu = qsl.to(device=self.device)
seq_lens_gpu = seq_lens_i32.to(device=self.device)
block_table = self._block_table[:num_reqs].contiguous()
slot_mapping_gpu = slot_mapping.to(device=self.device)
attn_metadata = attn_utils.build_attn_metadata(
self.attn_metadata_builders,
num_reqs=num_reqs,
num_tokens=num_tokens,
query_start_loc_gpu=query_start_loc_gpu,
query_start_loc_cpu=qsl,
seq_lens=seq_lens_gpu,
max_seq_len=max_seq_len,
block_tables=[block_table],
slot_mappings=[slot_mapping_gpu],
kv_cache_config=self.kv_cache_config,
)
# Build slot_mappings_by_layer for set_forward_context.
# Fix for vllm 0.15.0
slot_mappings_by_layer: dict[str, torch.Tensor] = {}
for kv_cache_group in self.kv_cache_config.kv_cache_groups:
for layer_name in kv_cache_group.layer_names:
slot_mappings_by_layer[layer_name] = slot_mapping_gpu
return attn_metadata, positions_cpu.to(device=self.device), slot_mappings_by_layer
class Qwen3TTSTalkerCodePredictorModelVLLM(nn.Module):
def __init__(
self,
config: Qwen3TTSTalkerCodePredictorConfig,
*,
talker_hidden_size: int | None = None,
cache_config=None,
quant_config=None,
prefix: str = "",
) -> None:
super().__init__()
self.config = config
self.quant_config = quant_config
self.layers = nn.ModuleList(
[
Qwen3DecoderLayer(
config, cache_config=cache_config, quant_config=quant_config, prefix=f"{prefix}.layers.{i}"
)
for i in range(config.num_hidden_layers)
]
)
self.norm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps)
# Official code_predictor uses one embedding table per residual group.
# Some Qwen3-TTS checkpoints store codec embeddings in the talker hidden
# space, even when `code_predictor_config.hidden_size` is smaller.
# We keep the embedding dim aligned with the checkpoint and project down
# via `small_to_mtp_projection` in the wrapper module.
emb_dim = int(talker_hidden_size) if talker_hidden_size is not None else int(config.hidden_size)
self.codec_embedding = nn.ModuleList(
[nn.Embedding(config.vocab_size, emb_dim) for _ in range(config.num_code_groups - 1)]
)
def get_input_embeddings(self) -> nn.ModuleList:
return self.codec_embedding
def forward(self, positions: torch.Tensor, inputs_embeds: torch.Tensor) -> torch.Tensor:
# Token-major: [num_tokens, hidden]
hidden_states = inputs_embeds
residual = None
for layer in self.layers:
hidden_states, residual = layer(positions, hidden_states, residual)
hidden_states, _ = self.norm(hidden_states, residual)
return hidden_states
def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
# Match vLLM Qwen2/Qwen3 packing conventions: q_proj/k_proj/v_proj -> qkv_proj,
# gate_proj/up_proj -> gate_up_proj.
stacked_params_mapping = [
("qkv_proj", "q_proj", "q"),
("qkv_proj", "k_proj", "k"),
("qkv_proj", "v_proj", "v"),
("gate_up_proj", "gate_proj", 0),
("gate_up_proj", "up_proj", 1),
]
params_dict = dict(self.named_parameters(remove_duplicate=False))
loaded_params: set[str] = set()
for name, loaded_weight in weights:
if "rotary_emb.inv_freq" in name:
continue
if self.quant_config is not None and (scale_name := self.quant_config.get_cache_scale(name)):
param = params_dict[scale_name]
weight_loader = getattr(param, "weight_loader", default_weight_loader)
loaded_weight = loaded_weight if loaded_weight.dim() == 0 else loaded_weight[0]
weight_loader(param, loaded_weight)
loaded_params.add(scale_name)
continue
for param_name, weight_name, shard_id in stacked_params_mapping:
if weight_name not in name:
continue
mapped = name.replace(weight_name, param_name)
if mapped.endswith(".bias") and mapped not in params_dict:
continue
if is_pp_missing_parameter(mapped, self):
continue
if mapped.endswith("scale"):
mapped = maybe_remap_kv_scale_name(mapped, params_dict)
if mapped is None:
continue
param = params_dict.get(mapped)
if param is None:
continue
weight_loader = getattr(param, "weight_loader", default_weight_loader)
if weight_loader == default_weight_loader:
weight_loader(param, loaded_weight)
else:
weight_loader(param, loaded_weight, shard_id)
loaded_params.add(mapped)
break
else:
mapped = maybe_remap_kv_scale_name(name, params_dict)
if mapped is None:
continue
if name.endswith(".bias") and mapped not in params_dict:
continue
if is_pp_missing_parameter(mapped, self):
continue
param = params_dict.get(mapped)
if param is None:
continue
weight_loader = getattr(param, "weight_loader", default_weight_loader)
weight_loader(param, loaded_weight)
loaded_params.add(mapped)
return loaded_params
class Qwen3TTSTalkerCodePredictorForConditionalGenerationVLLM(nn.Module):
"""vLLM-native code_predictor used by the AR talker (residual codebooks)."""
def __init__(
self,
*,
vllm_config: VllmConfig,
config: Qwen3TTSTalkerCodePredictorConfig,
talker_config: Qwen3TTSTalkerConfig,
prefix: str = "code_predictor",
) -> None:
super().__init__()
self._vllm_config = vllm_config
self.config = config
self.talker_config = talker_config
# Keep module/weight names aligned with official checkpoint (talker.code_predictor.model.*).
self.model = Qwen3TTSTalkerCodePredictorModelVLLM(
config,
talker_hidden_size=int(talker_config.hidden_size),
cache_config=vllm_config.cache_config,
quant_config=vllm_config.quant_config,
prefix=f"{prefix}.model",
)
# One head per residual group.
self.lm_head = nn.ModuleList(
[nn.Linear(config.hidden_size, config.vocab_size, bias=False) for _ in range(config.num_code_groups - 1)]
)
if config.hidden_size != talker_config.hidden_size:
self.small_to_mtp_projection = nn.Linear(talker_config.hidden_size, config.hidden_size, bias=True)
else:
self.small_to_mtp_projection = nn.Identity()
self._kv_cache: _LocalPredictorKVCache | None = None
def get_input_embeddings(self) -> nn.ModuleList:
return self.model.get_input_embeddings()
def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
# Ensure all vLLM custom layers consult the predictor vllm_config
# (esp. for Attention static_forward_context).
with set_current_vllm_config(self._vllm_config):
loaded: set[str] = set()
model_weights: list[tuple[str, torch.Tensor]] = []
other_weights: list[tuple[str, torch.Tensor]] = []
for name, w in weights:
if name.startswith("model."):
model_weights.append((name[len("model.") :], w))
else:
other_weights.append((name, w))
loaded_model = self.model.load_weights(model_weights)
loaded |= {f"model.{n}" for n in loaded_model}
params = dict(self.named_parameters(remove_duplicate=False))
for name, w in other_weights:
if name not in params:
continue
default_weight_loader(params[name], w)
loaded.add(name)
return loaded
def _maybe_init_kv_cache(self, device: torch.device) -> None:
if self._kv_cache is not None:
return
max_seq_len = int(getattr(self.config, "num_code_groups", 16) or 16)
# Upper bound on batch size: vLLM scheduler max_num_seqs (fallback 8).
max_batch = int(getattr(self._vllm_config.scheduler_config, "max_num_seqs", 8) or 8)
max_batch = max(1, max_batch)
self._kv_cache = _LocalPredictorKVCache(
vllm_config=self._vllm_config,
max_seq_len=max_seq_len,
max_batch_size=max_batch,
device=device,
)
@torch.inference_mode()
def reset_cache(self) -> None:
# We reuse a fixed kv cache buffer and overwrite starting at slot 0.
# No action required here (seq_lens controls what is read).
return
@torch.inference_mode()
def prefill_logits(self, inputs_embeds: torch.Tensor) -> torch.Tensor:
"""Prefill with 2 tokens: [past_hidden, layer0_embed]. Returns logits for residual group 0."""
self._maybe_init_kv_cache(inputs_embeds.device)
assert self._kv_cache is not None
bsz = int(inputs_embeds.shape[0])
qlen = 2
# Flatten to token-major.
hs = inputs_embeds.to(dtype=torch.bfloat16).reshape(bsz * qlen, -1)
hs = self.small_to_mtp_projection(hs)
query_lens = torch.full((bsz,), qlen, dtype=torch.int32)
seq_lens = query_lens.clone()
attn_metadata, positions, slot_mappings = self._kv_cache.build_attn_metadata(
num_reqs=bsz, query_lens=query_lens, seq_lens=seq_lens
)
with (
set_current_vllm_config(self._vllm_config),
set_forward_context(
attn_metadata,
self._vllm_config,
num_tokens=int(hs.shape[0]),
slot_mapping=slot_mappings,
),
):
out = self.model(positions=positions, inputs_embeds=hs)
# Gather last token per request.
last_idx = torch.arange(qlen - 1, bsz * qlen, step=qlen, device=out.device, dtype=torch.long)
last_h = out.index_select(0, last_idx)
logits = self.lm_head[0](last_h)
return logits
@torch.inference_mode()
def decode_logits(self, input_ids: torch.Tensor, *, generation_step: int, past_seq_len: int) -> torch.Tensor:
"""Decode one new token for residual group `generation_step` (1..Q-1)."""
self._maybe_init_kv_cache(input_ids.device)
assert self._kv_cache is not None
bsz = int(input_ids.shape[0])
if generation_step <= 0:
raise ValueError("generation_step must be >= 1 for decode_logits")
embed_idx = generation_step - 1
hs = self.model.get_input_embeddings()[embed_idx](input_ids.to(dtype=torch.long).reshape(bsz, 1))
hs = self.small_to_mtp_projection(hs.reshape(bsz, -1))
query_lens = torch.ones((bsz,), dtype=torch.int32)
seq_lens = torch.full((bsz,), int(past_seq_len) + 1, dtype=torch.int32)
attn_metadata, positions, slot_mappings = self._kv_cache.build_attn_metadata(
num_reqs=bsz, query_lens=query_lens, seq_lens=seq_lens
)
with (
set_current_vllm_config(self._vllm_config),
set_forward_context(
attn_metadata,
self._vllm_config,
num_tokens=int(hs.shape[0]),
slot_mapping=slot_mappings,
),
):
out = self.model(positions=positions, inputs_embeds=hs)
logits = self.lm_head[generation_step](out)
return logits
@torch.inference_mode()
def forward(
self,
layer0_code: torch.Tensor,
layer0_embed: torch.Tensor,
last_talker_hidden: torch.Tensor,
do_sample: bool = True,
temperature: float = 1.0,
top_k: int = 50,
top_p: float = 1.0,
) -> torch.Tensor:
"""Full autoregressive prediction of residual codebooks 1..Q-1.
Args:
layer0_code: [B, 1] first-layer codec token ids.
layer0_embed: [B, 1, H] embedding of layer0_code.
last_talker_hidden: [B, 1, H] hidden state from the talker.
do_sample: whether to sample or take argmax.
temperature: sampling temperature.
top_k: top-k filtering.
top_p: top-p (nucleus) filtering.
Returns:
audio_codes: [B, Q] all codebook tokens (layer0 + residuals).
"""
bsz = int(layer0_code.shape[0])
num_groups = int(self.config.num_code_groups)
max_steps = num_groups - 1
# Reset KV cache for a fresh sequence.
self.reset_cache()
# Prefill: feed [last_talker_hidden, layer0_embed] → logits for group 1.
prefill_input = torch.cat([last_talker_hidden, layer0_embed], dim=1) # [B, 2, H]
logits = self.prefill_logits(prefill_input) # [B, vocab]
all_codes = [layer0_code.reshape(bsz, 1)]
past_seq_len = 2
for step in range(1, num_groups):
# Sample or argmax from logits.
if do_sample and temperature > 0:
scaled = logits / temperature
if top_k > 0:
topk_vals, _ = scaled.topk(top_k, dim=-1)
scaled = scaled.masked_fill(scaled < topk_vals[:, -1:], float("-inf"))
probs = torch.softmax(scaled, dim=-1)
next_ids = torch.multinomial(probs, num_samples=1) # [B, 1]
else:
next_ids = logits.argmax(dim=-1, keepdim=True) # [B, 1]
all_codes.append(next_ids)
# If not the last step, decode one more token.
if step < max_steps:
logits = self.decode_logits(
next_ids.reshape(bsz),
generation_step=step,
past_seq_len=past_seq_len,
)
past_seq_len += 1
return torch.cat(all_codes, dim=1) # [B, Q]
File diff suppressed because it is too large. Load diff
@@ -80,16 +80,21 @@ class Qwen3TTSTokenizer:
"""
inst = cls()
AutoConfig.register("qwen3_tts_tokenizer_25hz", Qwen3TTSTokenizerV1Config)
AutoModel.register(Qwen3TTSTokenizerV1Config, Qwen3TTSTokenizerV1Model)
load_feature_extractor = bool(kwargs.pop("load_feature_extractor", True))
AutoConfig.register("qwen3_tts_tokenizer_12hz", Qwen3TTSTokenizerV2Config)
AutoModel.register(Qwen3TTSTokenizerV2Config, Qwen3TTSTokenizerV2Model)
inst.feature_extractor = AutoFeatureExtractor.from_pretrained(pretrained_model_name_or_path)
AutoConfig.register("qwen3_tts_tokenizer_25hz", Qwen3TTSTokenizerV1Config)
AutoModel.register(Qwen3TTSTokenizerV1Config, Qwen3TTSTokenizerV1Model)
inst.model = AutoModel.from_pretrained(pretrained_model_name_or_path, **kwargs)
inst.config = inst.model.config
inst.feature_extractor = (
AutoFeatureExtractor.from_pretrained(pretrained_model_name_or_path) if load_feature_extractor else None
)
inst.device = getattr(inst.model, "device", None)
if inst.device is None:
# fallback: infer from first parameter device
@@ -212,12 +217,7 @@ class Qwen3TTSTokenizer:
audios: AudioInput,
sr: int | None = None,
return_dict: bool = True,
) -> (
Qwen3TTSTokenizerV1EncoderOutput
| Qwen3TTSTokenizerV2EncoderOutput
| tuple[list[torch.Tensor], list[torch.Tensor] | None, list[torch.Tensor] | None]
| tuple[list[torch.Tensor]]
):
) -> Qwen3TTSTokenizerV1EncoderOutput | Qwen3TTSTokenizerV2EncoderOutput | tuple:
"""
Batch-encode audio into discrete codes (and optional conditioning, depending on 25Hz/12Hz).
+11 -1
View File
@@ -50,8 +50,18 @@ _OMNI_MODELS = {
),
"Qwen3TTSForConditionalGeneration": (
"qwen3_tts",
"qwen3_tts_talker",
"Qwen3TTSTalkerForConditionalGeneration",
),
"Qwen3TTSTalkerForConditionalGeneration": (
"qwen3_tts",
"Qwen3TTSModelForGeneration",
"qwen3_tts_talker",
"Qwen3TTSTalkerForConditionalGeneration",
),
"Qwen3TTSCode2Wav": (
"qwen3_tts",
"qwen3_tts_code2wav",
"Qwen3TTSCode2Wav",
),
}
@@ -1,22 +1,101 @@
async_chunk: true
stage_args:
- stage_id: 0
stage_type: llm # Use llm stage type to launch OmniLLM
stage_type: llm
runtime:
devices: "0"
max_batch_size: 1
engine_args:
model_stage: qwen3_tts
model_arch: Qwen3TTSForConditionalGeneration
model_arch: Qwen3TTSTalkerForConditionalGeneration
# Force stage-specific registered architecture.
hf_overrides:
architectures: [Qwen3TTSTalkerForConditionalGeneration]
worker_type: ar
scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
enforce_eager: false
trust_remote_code: true
async_scheduling: false
enable_prefix_caching: false
engine_output_type: latent
gpu_memory_utilization: 0.3
distributed_executor_backend: "mp"
max_num_batched_tokens: 512
max_model_len: 4096
custom_process_next_stage_input_func: vllm_omni.model_executor.stage_input_processors.qwen3_tts.talker2code2wav_async_chunk
# Use named connector to apply runtime.connectors.extra.
output_connectors:
to_stage_1: connector_of_shared_memory
default_sampling_params:
temperature: 0.9
top_k: 50
max_tokens: 4096
seed: 42
detokenize: false
repetition_penalty: 1.05
stop_token_ids: [2150]
- stage_id: 1
stage_type: llm
runtime:
devices: "0"
max_batch_size: 1
engine_args:
model_stage: code2wav
model_arch: Qwen3TTSCode2Wav
# Force stage-specific registered architecture.
hf_overrides:
architectures: [Qwen3TTSCode2Wav]
worker_type: generation
scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler
enforce_eager: true
trust_remote_code: true
async_scheduling: false
enable_prefix_caching: false
engine_output_type: audio # Final output: audio waveform
gpu_memory_utilization: 0.1
engine_output_type: audio
gpu_memory_utilization: 0.2
distributed_executor_backend: "mp"
max_num_batched_tokens: 1000000
# Must be divisible by num_code_groups and cover (left_context + chunk).
max_num_batched_tokens: 8192
# async_chunk appends windows per step; max_model_len must cover accumulated stream.
max_model_len: 32768
engine_input_source: [0]
final_output: true
final_output_type: audio
# Distributed connector configuration
input_connectors:
from_stage_0: connector_of_shared_memory
default_sampling_params:
temperature: 0.0
top_p: 1.0
top_k: -1
max_tokens: 65536
seed: 42
detokenize: true
repetition_penalty: 1.0
runtime:
enabled: true
defaults:
window_size: -1
max_inflight: 1
connectors:
connector_of_shared_memory:
name: SharedMemoryConnector
extra:
shm_threshold_bytes: 65536
# Frame-aligned codec streaming transport.
codec_streaming: true
# Connector polling / timeout (unit: loop count, sleep interval in seconds).
connector_get_sleep_s: 0.01
connector_get_max_wait_first_chunk: 3000
connector_get_max_wait: 300
# Align with Omni: small chunks with sufficient context overlap.
codec_chunk_frames: 25
codec_left_context_frames: 25
edges:
- from: 0
to: 1
window_size: -1
@@ -0,0 +1,76 @@
"""Stage input processor for Qwen3-TTS: Talker -> Code2Wav."""
from typing import Any
import torch
def _extract_last_frame(pooling_output: dict[str, Any]) -> torch.Tensor | None:
audio_codes = pooling_output.get("audio_codes")
if not isinstance(audio_codes, torch.Tensor) or audio_codes.numel() == 0:
return None
if audio_codes.ndim == 2:
frame = audio_codes[-1]
if frame.numel() == 0 or not bool(frame.any().item()):
return None
return frame.to(torch.long).reshape(-1)
if audio_codes.ndim == 1:
return audio_codes.to(torch.long).reshape(-1)
raise ValueError(f"Invalid audio_codes shape for Qwen3-TTS async_chunk: {tuple(audio_codes.shape)}")
def talker2code2wav_async_chunk(
transfer_manager: Any,
pooling_output: dict[str, Any],
request: Any,
) -> dict[str, Any] | None:
if not isinstance(pooling_output, dict):
return None
request_id = request.external_req_id
connector = getattr(transfer_manager, "connector", None)
raw_cfg = getattr(connector, "config", {}) or {}
cfg = raw_cfg.get("extra", raw_cfg) if isinstance(raw_cfg, dict) else {}
chunk_size = int(cfg.get("codec_chunk_frames", 25))
left_context_size = int(cfg.get("codec_left_context_frames", 25))
if chunk_size <= 0 or left_context_size < 0:
raise ValueError(
f"Invalid codec chunk config: codec_chunk_frames={chunk_size}, "
f"codec_left_context_frames={left_context_size}"
)
finished = bool(request.is_finished())
frame = _extract_last_frame(pooling_output)
if frame is not None:
codec_codes = frame.cpu().tolist()
transfer_manager.code_prompt_token_ids[request_id].append(codec_codes)
length = len(transfer_manager.code_prompt_token_ids[request_id])
chunk_length = length % chunk_size
if chunk_length != 0 and not finished:
return None
context_length = chunk_length if chunk_length != 0 else chunk_size
if length <= 0:
return {
"code_predictor_codes": [],
"finished": torch.tensor(bool(finished), dtype=torch.bool),
}
end_index = min(length, left_context_size + context_length)
ctx_frames = max(0, int(end_index - context_length))
window_frames = transfer_manager.code_prompt_token_ids[request_id][-end_index:]
# Pack context + chunk into codebook-major flat codes for adapter.
code_predictor_codes = torch.tensor(window_frames).transpose(0, 1).reshape(-1).tolist()
# Build final prompt_token_ids with ctx_frames header for Qwen3-TTS Code2Wav.
# The model expects input_ids layout: [ctx_frames, *flat_codes].
return {
"code_predictor_codes": [int(ctx_frames)] + code_predictor_codes,
"finished": torch.tensor(bool(finished), dtype=torch.bool),
}
-2
View File
@@ -84,8 +84,6 @@ def xpu_omni_platform_plugin() -> str | None:
is_xpu = False
logger.debug("Checking if XPU OmniPlatform is available.")
try:
# installed IPEX if the machine has XPUs.
import intel_extension_for_pytorch # noqa: F401
import torch
if supports_xccl():
@@ -1,22 +1,92 @@
async_chunk: true
stage_args:
- stage_id: 0
stage_type: llm # Use llm stage type to launch OmniLLM
stage_type: llm
runtime:
devices: "0"
max_batch_size: 1
engine_args:
model_stage: qwen3_tts
model_arch: Qwen3TTSForConditionalGeneration
model_arch: Qwen3TTSTalkerForConditionalGeneration
hf_overrides:
architectures: [Qwen3TTSTalkerForConditionalGeneration]
worker_type: ar
scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
enforce_eager: true
trust_remote_code: true
async_scheduling: false
enable_prefix_caching: false
engine_output_type: latent
gpu_memory_utilization: 0.3
distributed_executor_backend: "mp"
max_num_batched_tokens: 512
max_model_len: 4096
custom_process_next_stage_input_func: vllm_omni.model_executor.stage_input_processors.qwen3_tts.talker2code2wav_async_chunk
output_connectors:
to_stage_1: connector_of_shared_memory
default_sampling_params:
temperature: 0.9
top_k: 50
max_tokens: 4096
seed: 42
detokenize: false
repetition_penalty: 1.05
stop_token_ids: [2150]
- stage_id: 1
stage_type: llm
runtime:
devices: "0"
max_batch_size: 1
engine_args:
model_stage: code2wav
model_arch: Qwen3TTSCode2Wav
hf_overrides:
architectures: [Qwen3TTSCode2Wav]
worker_type: generation
scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler
enforce_eager: true
trust_remote_code: true
async_scheduling: false
enable_prefix_caching: false
engine_output_type: audio # Final output: audio waveform
gpu_memory_utilization: 0.1
engine_output_type: audio
gpu_memory_utilization: 0.2
distributed_executor_backend: "mp"
max_num_batched_tokens: 1000000
max_num_batched_tokens: 8192
max_model_len: 32768
engine_input_source: [0]
final_output: true
final_output_type: audio
input_connectors:
from_stage_0: connector_of_shared_memory
default_sampling_params:
temperature: 0.0
top_p: 1.0
top_k: -1
max_tokens: 65536
seed: 42
detokenize: true
repetition_penalty: 1.0
runtime:
enabled: true
defaults:
window_size: -1
max_inflight: 1
connectors:
connector_of_shared_memory:
name: SharedMemoryConnector
extra:
shm_threshold_bytes: 65536
codec_streaming: true
connector_get_sleep_s: 0.01
connector_get_max_wait_first_chunk: 3000
connector_get_max_wait: 300
codec_chunk_frames: 25
codec_left_context_frames: 25
edges:
- from: 0
to: 1
window_size: -1
@@ -238,9 +238,12 @@ class NPUGenerationModelRunner(OmniNPUModelRunner):
pooler_output.append(mm_payload)
else:
raise RuntimeError("Unsupported diffusion output type")
# [Omni] Copy req_id mappings to avoid async scheduling mutation.
req_ids_output_copy = self.input_batch.req_ids.copy()
req_id_to_index_output_copy = self.input_batch.req_id_to_index.copy()
output = OmniModelRunnerOutput(
req_ids=self.input_batch.req_ids,
req_id_to_index=self.input_batch.req_id_to_index,
req_ids=req_ids_output_copy,
req_id_to_index=req_id_to_index_output_copy,
sampled_token_ids=[],
logprobs=None,
prompt_logprobs_dict={},
@@ -380,9 +380,12 @@ class GPUGenerationModelRunner(OmniGPUModelRunner):
pooler_output.append(mm_payload)
else:
raise RuntimeError("Unsupported diffusion output type")
# [Omni] Copy req_id mappings to avoid async scheduling mutation.
req_ids_output_copy = self.input_batch.req_ids.copy()
req_id_to_index_output_copy = self.input_batch.req_id_to_index.copy()
output = OmniModelRunnerOutput(
req_ids=self.input_batch.req_ids,
req_id_to_index=self.input_batch.req_id_to_index,
req_ids=req_ids_output_copy,
req_id_to_index=req_id_to_index_output_copy,
sampled_token_ids=[],
logprobs=None,
prompt_logprobs_dict={},
+41 -10
View File
@@ -70,16 +70,24 @@ class OmniGPUModelRunner(GPUModelRunner):
@instrument(span_name="Loading (GPU)")
def load_model(self, *args, **kwargs) -> None:
super().load_model(*args, **kwargs)
# TODO move this model specific logic to a separate class
if hasattr(self.model, "talker_mtp") and self.model.talker is not None:
self.talker_mtp = self.model.talker_mtp
# TTS model IS the talker (no .talker sub-attr); use getattr to support both Omni and TTS.
talker_mtp = getattr(self.model, "talker_mtp", None)
if talker_mtp is not None:
self.talker_mtp = talker_mtp # type: ignore[assignment]
cudagraph_mode = self.compilation_config.cudagraph_mode
assert cudagraph_mode is not None
if cudagraph_mode.has_full_cudagraphs():
self.talker_mtp = CUDAGraphWrapper(
self.model.talker_mtp, self.vllm_config, runtime_mode=CUDAGraphMode.FULL
)
hidden_size = self.model_config.hf_config.talker_config.text_config.hidden_size
# Only wrap talker_mtp in CUDAGraphWrapper for Omni models that
# have a separate .talker sub-module. TTS models' code predictor
# has internal AR loops / torch.multinomial — not graph-safe.
has_separate_talker = getattr(self.model, "talker", None) is not None
if cudagraph_mode.has_full_cudagraphs() and has_separate_talker:
self.talker_mtp = CUDAGraphWrapper(talker_mtp, self.vllm_config, runtime_mode=CUDAGraphMode.FULL)
# TTS exposes mtp_hidden_size; Omni uses hf_text_config.hidden_size.
hidden_size = int(
getattr(self.model, "mtp_hidden_size", 0) or getattr(self.model_config.hf_text_config, "hidden_size")
)
max_batch_size = max(self.max_num_reqs, self.compilation_config.max_cudagraph_capture_size)
self.talker_mtp_input_ids = self._make_buffer(max_batch_size, dtype=torch.int32)
self.talker_mtp_inputs_embeds = self._make_buffer(
@@ -644,6 +652,11 @@ class OmniGPUModelRunner(GPUModelRunner):
input_ids = None
inputs_embeds = self.inputs_embeds.gpu[:num_tokens_padded]
model_kwargs = self._init_model_kwargs()
elif getattr(getattr(self, "model", None), "has_preprocess", False):
# Capture CUDA graph with inputs_embeds path so replay reads
# from the same buffer that _preprocess writes into.
input_ids = self.input_ids.gpu[:num_tokens_padded]
inputs_embeds = self.inputs_embeds.gpu[:num_tokens_padded]
else:
input_ids = self.input_ids.gpu[:num_tokens_padded]
inputs_embeds = None
@@ -986,6 +999,11 @@ class OmniGPUModelRunner(GPUModelRunner):
inputs_embeds = self.inputs_embeds.gpu[:num_input_tokens]
model_kwargs = self._init_model_kwargs()
input_ids = self.input_ids.gpu[:num_input_tokens]
elif getattr(self.model, "has_preprocess", False):
# Use pre-allocated buffer for CUDA graph compatibility.
input_ids = self.input_ids.gpu[:num_input_tokens]
inputs_embeds = self.inputs_embeds.gpu[:num_input_tokens]
model_kwargs = self._init_model_kwargs()
else:
# For text-only models, we use token ids as input.
# While it is possible to use embeddings as input just like the
@@ -1047,9 +1065,16 @@ class OmniGPUModelRunner(GPUModelRunner):
span_len = int(e) - int(s)
# call the custom process function
embed_slice = inputs_embeds[s:e] if inputs_embeds is not None else None
req_input_ids, req_embeds, update_dict = self.model.preprocess(
input_ids=input_ids[s:e], input_embeds=inputs_embeds[s:e], **req_infos
input_ids=input_ids[s:e], input_embeds=embed_slice, **req_infos
)
if inputs_embeds is None:
inputs_embeds = torch.empty(
(input_ids.shape[0], req_embeds.shape[-1]),
device=req_embeds.device,
dtype=req_embeds.dtype,
)
if hasattr(self.model, "talker_mtp") and span_len == 1:
last_talker_hidden, text_step = update_dict.pop("mtp_inputs")
@@ -1093,6 +1118,9 @@ class OmniGPUModelRunner(GPUModelRunner):
max_num_scheduled_tokens=1,
use_cascade_attn=False,
)
# Force eager for unwrapped code predictors (AR loops / multinomial).
if not isinstance(self.talker_mtp, CUDAGraphWrapper):
_cudagraph_mode = CUDAGraphMode.NONE
num_tokens_padded = batch_desc.num_tokens
req_input_ids = self.talker_mtp_input_ids.gpu[:num_tokens_padded]
req_embeds = self.talker_mtp_inputs_embeds.gpu[:num_tokens_padded]
@@ -1104,11 +1132,12 @@ class OmniGPUModelRunner(GPUModelRunner):
req_embeds, code_predictor_codes = self.talker_mtp(req_input_ids, req_embeds, last_talker_hidden, text_step)
# update the inputs_embeds and code_predictor_codes
code_predictor_codes_cpu = code_predictor_codes.detach().to("cpu").contiguous()
out_key = getattr(self.model, "talker_mtp_output_key", "code_predictor_codes")
for idx, req_id in enumerate(decode_req_ids):
req_index = self.input_batch.req_ids.index(req_id)
start_offset = int(self.query_start_loc.cpu[req_index])
inputs_embeds[start_offset : start_offset + 1] = req_embeds[idx : idx + 1]
update_dict = {"code_predictor_codes": code_predictor_codes_cpu[idx : idx + 1]}
update_dict = {out_key: code_predictor_codes_cpu[idx : idx + 1]}
self._merge_additional_information_update(req_id, update_dict)
def _model_forward(
@@ -1142,7 +1171,9 @@ class OmniGPUModelRunner(GPUModelRunner):
self._omni_last_model_output = model_output
return model_output
def _merge_additional_information_update(self, req_id: str, upd: dict) -> None:
def _merge_additional_information_update(self, req_id: str, upd: dict | None) -> None:
if not isinstance(upd, dict):
return
req_state = self.requests.get(req_id)
if req_state is None:
return