Files
wehub-resource-sync 94057c3d3e
PR Test (NPU) / check-changes (push) Has been cancelled
PR Test (NPU) / pr-gate (push) Has been cancelled
PR Test (NPU) / set-image-config (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-4-npu-a3 (push) Has been cancelled
PR Test (NPU) / stage-b-test-16-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-1-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-2-npu-a3 (push) Has been cancelled
PR Test (Arm64) / pr-gate (push) Has been cancelled
PR Test (Arm64) / check-changes (push) Has been cancelled
PR Test (Arm64) / build-test (push) Has been cancelled
PR Test (sgl-router) / gate (push) Has been cancelled
PR Test (sgl-router) / tier-1 — lint (push) Has been cancelled
PR Test (sgl-router) / tier-2 — build + test (push) Has been cancelled
PR Test (sgl-router) / tier-3 — docker (placeholder) (push) Has been cancelled
PR Test (sgl-router) / tier-3 — k8s integration (push) Has been cancelled
PR Test (sgl-router) / tier-3 — e2e (push) Has been cancelled
PR Test (sgl-router) / finish (push) Has been cancelled
PR Test (NPU) / single-node-poc (map[name:qwen3_6_27b_w8a8_1p_in64k_out1k_50ms runner:linux-aarch64-a3-2 test_case:test/registered/ascend/performance/qwen3_6_27b/test_npu_qwen3_6_27b_w8a8_1p_in64k_out1k_50ms.py test_type:perf]) (push) Has been cancelled
PR Test (NPU) / pr-test-npu-finish (push) Has been cancelled
PR Test (Xeon) / pr-gate (push) Has been cancelled
PR Test (Xeon) / check-changes (push) Has been cancelled
PR Test (Xeon) / build-test (, xeon-gnr, base-b-test-cpu) (push) Has been cancelled
PR Test (XPU) / check-changes (push) Has been cancelled
PR Test (XPU) / pr-gate (push) Has been cancelled
PR Test (XPU) / stage-a-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / wait-for-stage-a (push) Has been cancelled
PR Test (XPU) / stage-b-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / finish (push) Has been cancelled
CI Model Inventory / build-inventory (push) Has been cancelled
Lint / lint (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Compilation Check (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Manual Policy (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Request Processing (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Summary (push) Has been cancelled
PR Test (SMG) / build-wheel (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on windows (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (x86_64 - auto) (push) Has been cancelled
PR Test (SMG) / python-unit-tests (push) Has been cancelled
PR Test (SMG) / unit-tests (push) Has been cancelled
PR Test (SMG) / benchmarks (push) Has been cancelled
PR Test (SMG) / chat-completions (push) Has been cancelled
PR Test (SMG) / chat-completions-4gpu (push) Has been cancelled
PR Test (SMG) / e2e (push) Has been cancelled
PR Test (SMG) / docker-build-test (push) Has been cancelled
PR Test (SMG) / k8s-integration (push) Has been cancelled
PR Test (SMG) / finish (push) Has been cancelled
PR Test (SMG) / summarize-benchmarks (push) Has been cancelled
Release SGLang Model Gateway Docker Image / publish (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Build SDist (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Upload to PyPI (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (aarch64, 12.9, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (x86_64, 12.9, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu129 (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (aarch64, 13.0, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (x86_64, 13.0, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu130 (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 700) (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 720) (push) Has been cancelled
Release SGLang Kernels / release-rocm700 (push) Has been cancelled
Release SGLang Kernels / release-rocm720 (push) Has been cancelled
Release SGLang Kernels / build-musa43 (43, 3.10) (push) Has been cancelled
Release SGLang Kernels / release-musa43 (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:38:16 +08:00

467 lines
19 KiB
Python

# Copyright 2025 SGLang Team
# 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.
# ==============================================================================
"""
OpenAI-compatible transcription endpoint handler for audio ASR models.
New ASR models are supported by subclassing ``TranscriptionAdapter`` and
registering via the ``@register_transcription_adapter`` decorator.
See ``transcription_adapters/`` for built-in implementations.
"""
from __future__ import annotations
import asyncio
import io
import logging
import math
import time
import uuid
from typing import TYPE_CHECKING, AsyncGenerator, List, Optional, Union
from fastapi import Request, WebSocket
from fastapi.responses import ORJSONResponse, Response, StreamingResponse
from sglang.srt.entrypoints.openai.protocol import (
DeltaMessage,
ErrorResponse,
TranscriptionRequest,
TranscriptionResponse,
TranscriptionStreamChoice,
TranscriptionStreamResponse,
TranscriptionUsage,
TranscriptionVerboseResponse,
)
from sglang.srt.entrypoints.openai.realtime import (
handle_realtime_transcription,
)
from sglang.srt.entrypoints.openai.serving_base import OpenAIServingBase
from sglang.srt.entrypoints.openai.streaming_asr import (
StreamingASRState,
needs_space,
process_asr_chunk,
split_audio_chunks,
)
from sglang.srt.entrypoints.openai.transcription_adapters import resolve_adapter
from sglang.srt.managers.io_struct import GenerateReqInput
if TYPE_CHECKING:
from sglang.srt.managers.tokenizer_manager import TokenizerManager
logger = logging.getLogger(__name__)
class OpenAIServingTranscription(OpenAIServingBase):
"""Handler for /v1/audio/transcriptions requests"""
def __init__(self, tokenizer_manager: TokenizerManager):
super().__init__(tokenizer_manager)
model_config = tokenizer_manager.model_config
self._adapter = resolve_adapter(
getattr(model_config.hf_config, "architectures", [])
)
# Cap concurrent /v1/realtime sessions. The Semaphore is bound to the
# event loop on first acquire (uvicorn's loop in normal serving).
self._session_semaphore = asyncio.Semaphore(
tokenizer_manager.server_args.asr_max_concurrent_sessions
)
def _request_id_prefix(self) -> str:
return "trsc-"
def _validate_request(self, request: TranscriptionRequest) -> Optional[str]:
"""Validate transcription request."""
# Validation is done in the route handler for form data
return None
def _convert_to_internal_request(
self,
request: TranscriptionRequest,
raw_request: Request = None,
) -> tuple[GenerateReqInput, TranscriptionRequest]:
"""Convert transcription request to internal format."""
if getattr(request, "_fused_autodetect", False):
sampling_params = self._adapter.build_fused_autodetect_params(request)
else:
sampling_params = self._adapter.build_sampling_params(request)
adapted_request = GenerateReqInput(
text="", # Empty text — the multimodal processor sets proper decoder/prompt tokens
audio_data=request.audio_data,
sampling_params=sampling_params,
stream=request.stream,
modalities=["audio"],
routing_key=self.extract_routing_key(raw_request),
)
return adapted_request, request
@staticmethod
def _get_audio_duration(audio_data: bytes) -> float:
"""Calculate audio duration in seconds."""
try:
import soundfile as sf
info = sf.info(io.BytesIO(audio_data))
return info.duration
except Exception as e:
logger.warning(f"Could not calculate audio duration: {e}")
return 0.0
async def create_transcription(
self,
audio_data: bytes,
model: str,
language: Optional[str],
response_format: str,
temperature: float,
stream: bool,
raw_request: Request,
timestamp_granularities: Optional[List[str]] = None,
) -> Union[
TranscriptionResponse,
TranscriptionVerboseResponse,
StreamingResponse,
Response,
ORJSONResponse,
]:
"""Main entry point for transcription requests."""
# Calculate audio duration for usage reporting
audio_duration_s = self._get_audio_duration(audio_data)
# When language is not specified and the adapter supports detection,
# use a single fused request: SGLang's structured generation (regex)
# constrains the first 3 decode tokens to the forced prefix while
# allowing free transcription afterwards — one encoder pass, no
# extra round-trip. The adapter picks the regex variant based on
# whether timestamps were requested, so fused covers all four
# combinations of (stream, timestamp_granularities):
# * non-streaming: parse_fused_output strips the prefix and
# scrubs trailing/embedded special tokens.
# * streaming: the handler buffers until the sentinel,
# re-anchors, and scrubs each delta via
# adapter.strip_special_tokens.
# verbose_json segment timing still comes from _parse_segments
# over output_ids, which is unaffected by the string-level scrub.
use_fused = language is None and self._adapter.supports_language_detection
# Build request
request = TranscriptionRequest(
audio_data=audio_data,
model=model,
language=language,
response_format=response_format,
temperature=temperature,
timestamp_granularities=timestamp_granularities,
stream=stream,
audio_duration_s=audio_duration_s,
)
if use_fused:
request._fused_autodetect = True
# Stash the variant alongside the flag so the adapter dispatch in
# parse_fused_output and the build_fused_autodetect_params regex
# selection see the same boolean — and we don't recompute it on
# every cumulative-text snapshot in streaming.
request._fused_ts_variant = bool(timestamp_granularities)
# Use the base class handle_request pattern
return await self.handle_request(request, raw_request)
async def _handle_non_streaming_request(
self,
adapted_request: GenerateReqInput,
request: TranscriptionRequest,
raw_request: Request,
) -> Union[
TranscriptionResponse,
TranscriptionVerboseResponse,
ErrorResponse,
ORJSONResponse,
Response,
]:
"""Handle non-streaming transcription request."""
try:
ret = await self.tokenizer_manager.generate_request(
adapted_request, raw_request
).__anext__()
except ValueError as e:
return self.create_error_response(str(e))
text = self._adapter.postprocess_text(ret.get("text", ""))
# For fused auto-detect, parse_fused_output returns the scrubbed
# user-visible text. On parse failure (FSM abort, truncation) it
# returns (None, None) and we fall back to strip_special_tokens —
# the language stays unset rather than reporting a bogus detection.
if getattr(request, "_fused_autodetect", False):
lang, visible = self._adapter.parse_fused_output(
text, ts_variant=getattr(request, "_fused_ts_variant", False)
)
if visible is None:
logger.warning(
"Fused auto-detect parse failed on non-streaming response; "
"falling back to raw-text scrub."
)
text = self._adapter.strip_special_tokens(text)
else:
text = visible
if lang is not None:
request.language = lang
logger.info("Auto-detected language: '%s'", lang)
usage = TranscriptionUsage(seconds=int(math.ceil(request.audio_duration_s)))
# Build response based on format
if request.response_format == "text":
return Response(content=text, media_type="text/plain")
if request.response_format == "verbose_json":
tokenizer = self.tokenizer_manager.tokenizer
return self._adapter.build_verbose_response(
request, text, ret, tokenizer, usage
)
# Default JSON format
return TranscriptionResponse(text=text, usage=usage)
async def _handle_streaming_request(
self,
adapted_request: GenerateReqInput,
request: TranscriptionRequest,
raw_request: Request,
) -> StreamingResponse:
"""Handle streaming transcription request."""
if self._adapter.supports_chunked_streaming:
# No background abort_task: each chunk is a separate request;
# client disconnection is detected via is_disconnected() in the loop.
return StreamingResponse(
self._generate_chunked_asr_stream(
adapted_request, request, raw_request
),
media_type="text/event-stream",
)
return StreamingResponse(
self._generate_transcription_stream(adapted_request, request, raw_request),
media_type="text/event-stream",
background=self.tokenizer_manager.create_abort_task(adapted_request),
)
async def _generate_transcription_stream(
self,
adapted_request: GenerateReqInput,
request: TranscriptionRequest,
raw_request: Request,
) -> AsyncGenerator[str, None]:
"""Generate streaming transcription response.
In fused auto-detect mode, each cumulative-text snapshot is passed
through ``parse_fused_output`` — which returns ``(None, None)``
while the forced prefix is still arriving and ``(lang, visible)``
once it's in. ``visible`` is already stripped of the prefix and
scrubbed of embedded special tokens, and it grows monotonically
across snapshots, so deltas are a plain suffix slice.
"""
created_time = int(time.time())
request_id = f"{self._request_id_prefix()}{uuid.uuid4().hex}"
model = request.model
visible_buffer = ""
fused_mode = getattr(request, "_fused_autodetect", False)
ts_variant = getattr(request, "_fused_ts_variant", False)
# When ``incremental_streaming_output`` is enabled, each chunk's
# ``content["text"]`` is the new delta from the detokenizer, not
# the cumulative text. Always reconstruct cumulative text locally
# so the rest of the loop (prefix parse + visible-buffer slice)
# works uniformly under either mode.
incremental = getattr(
self.tokenizer_manager.server_args,
"incremental_streaming_output",
False,
)
cumulative_text = ""
try:
async for content in self.tokenizer_manager.generate_request(
adapted_request, raw_request
):
finish_reason = content["meta_info"]["finish_reason"]
finish_reason_type = finish_reason["type"] if finish_reason else None
chunk_text = content.get("text", "")
if incremental:
cumulative_text += chunk_text
else:
cumulative_text = chunk_text
if fused_mode:
lang, visible = self._adapter.parse_fused_output(
cumulative_text, ts_variant=ts_variant
)
if visible is None:
# Prefix not yet locatable. Keep buffering until the
# stream ends.
if not finish_reason_type:
continue
# Stream ended before the forced prefix was parseable —
# emit an SSE error frame so the client can distinguish
# this from "silent audio, zero transcription" and raise
# a real error instead of quietly succeeding.
logger.warning(
"Fused auto-detect stream finished before prefix "
"was parseable; returning detection-failed error."
)
error = self.create_streaming_error_response(
"language auto-detect failed: forced-prefix sentinel "
"was not produced before stream end"
)
yield f"data: {error}\n\n"
yield "data: [DONE]\n\n"
return
if lang is not None and request.language is None:
request.language = lang
logger.info("Auto-detected language: '%s'", lang)
else:
visible = cumulative_text
delta = visible[len(visible_buffer) :]
visible_buffer = visible
# Send content delta if there's new text
if delta:
choice_data = TranscriptionStreamChoice(
delta=DeltaMessage(content=delta),
finish_reason=None,
)
chunk = TranscriptionStreamResponse(
id=request_id,
created=created_time,
model=model,
choices=[choice_data],
)
yield f"data: {chunk.model_dump_json()}\n\n"
# Send finish reason when done
if finish_reason_type:
choice_data = TranscriptionStreamChoice(
delta=DeltaMessage(),
finish_reason=finish_reason_type,
)
chunk = TranscriptionStreamResponse(
id=request_id,
created=created_time,
model=model,
choices=[choice_data],
)
yield f"data: {chunk.model_dump_json()}\n\n"
except ValueError as e:
error = self.create_streaming_error_response(str(e))
yield f"data: {error}\n\n"
yield "data: [DONE]\n\n"
async def _generate_chunked_asr_stream(
self,
adapted_request: GenerateReqInput,
request: TranscriptionRequest,
raw_request: Request,
) -> AsyncGenerator[str, None]:
"""Chunk-based streaming for ASR with prefix rollback.
Audio is split into chunks and each chunk is processed as an
independent request. Partial transcripts are emitted via SSE
with prefix rollback to reduce boundary jitter.
TODO:
- Token-level streaming within chunks (stream=True)
- Encoder window caching across chunks
- Cross-chunk KV cache reuse
"""
created_time = int(time.time())
request_id = f"{self._request_id_prefix()}{uuid.uuid4().hex}"
model = request.model
state = StreamingASRState(**self._adapter.chunked_streaming_config)
# Track only the trailing char of the cumulative emit; `needs_space`
# uses prev[-1] / cur[0] so we don't need to keep the full buffer.
last_char = ""
try:
chunks = split_audio_chunks(request.audio_data, state.chunk_size_sec)
for i, chunk_audio in enumerate(chunks):
if await raw_request.is_disconnected():
logger.info("[streaming_asr] client disconnected, stopping")
break
is_last = i == len(chunks) - 1
delta = await process_asr_chunk(
tokenizer_manager=self.tokenizer_manager,
adapter=self._adapter,
state=state,
audio_data=chunk_audio,
sampling_params=adapted_request.sampling_params,
is_last=is_last,
raw_request=raw_request,
routing_key=self.extract_routing_key(raw_request),
)
if delta:
for word in delta.split(" "):
if not word:
continue
content = f" {word}" if needs_space(last_char, word) else word
last_char = content[-1]
chunk_resp = TranscriptionStreamResponse(
id=request_id,
created=created_time,
model=model,
choices=[
TranscriptionStreamChoice(
delta=DeltaMessage(content=content),
finish_reason=None,
)
],
)
yield f"data: {chunk_resp.model_dump_json()}\n\n"
# Send final stop
chunk_resp = TranscriptionStreamResponse(
id=request_id,
created=created_time,
model=model,
choices=[
TranscriptionStreamChoice(
delta=DeltaMessage(),
finish_reason="stop",
)
],
)
yield f"data: {chunk_resp.model_dump_json()}\n\n"
except asyncio.CancelledError:
raise
except Exception as e:
logger.exception("[streaming_asr] unrecoverable error")
error = self.create_streaming_error_response(str(e))
yield f"data: {error}\n\n"
yield "data: [DONE]\n\n"
async def handle_websocket(self, websocket: WebSocket) -> None:
await handle_realtime_transcription(
websocket,
tokenizer_manager=self.tokenizer_manager,
adapter=self._adapter,
server_args=self.tokenizer_manager.server_args,
session_semaphore=self._session_semaphore,
)