[Whisper] Automatic language detection via structured generation (#22997)

Co-authored-by: Xinyuan Tong <xinyuantong.cs@gmail.com>
This commit is contained in:
Shenxiu Liu
2026-04-27 15:54:41 +08:00
committed by GitHub
co-authored by Xinyuan Tong
parent c2ec64f243
commit a3fc982ba7
9 changed files with 1285 additions and 52 deletions
@@ -6,7 +6,9 @@ Usage:
"""
import io
import json
import unittest
from typing import List, Optional
import requests
@@ -19,7 +21,7 @@ from sglang.test.test_utils import (
popen_launch_server,
)
register_cuda_ci(est_time=51, suite="stage-b-test-1-gpu-small")
register_cuda_ci(est_time=60, suite="stage-b-test-1-gpu-small")
WHISPER_MODEL = "openai/whisper-large-v3"
AUDIO_URL = "https://raw.githubusercontent.com/sgl-project/sgl-test-files/refs/heads/main/audios/Trump_WEF_2018_10s.mp3"
@@ -54,20 +56,65 @@ class TestServingTranscription(CustomTestCase):
if hasattr(cls, "process") and cls.process:
kill_process_tree(cls.process.pid)
def _transcribe(self, language="en"):
"""Send a transcription request and return the JSON response."""
def _transcribe(
self,
language: Optional[str] = "en",
response_format: Optional[str] = None,
timestamp_granularities: Optional[List[str]] = None,
):
"""Send a non-streaming transcription request and return the JSON response.
Passing ``language=None`` omits the field entirely, which exercises
the fused auto-detect path.
"""
audio_bytes = download_audio_bytes()
data = {"model": "whisper"}
if language is not None:
data["language"] = language
if response_format is not None:
data["response_format"] = response_format
if timestamp_granularities is not None:
# Form-encoded list fields repeat the key
data["timestamp_granularities[]"] = timestamp_granularities
response = requests.post(
self.base_url + "/v1/audio/transcriptions",
files={"file": ("audio.mp3", io.BytesIO(audio_bytes), "audio/mpeg")},
data={
"model": "whisper",
"language": language,
},
data=data,
)
self.assertEqual(response.status_code, 200, response.text)
return response.json()
def _transcribe_stream(self, language: Optional[str] = None) -> List[str]:
"""Send a streaming transcription request and return the delta strings."""
audio_bytes = download_audio_bytes()
data = {"model": "whisper", "stream": "true"}
if language is not None:
data["language"] = language
with requests.post(
self.base_url + "/v1/audio/transcriptions",
files={"file": ("audio.mp3", io.BytesIO(audio_bytes), "audio/mpeg")},
data=data,
stream=True,
timeout=120,
) as response:
self.assertEqual(response.status_code, 200, response.text)
deltas: List[str] = []
for raw in response.iter_lines():
if not raw:
continue
line = raw.decode("utf-8")
if not line.startswith("data: "):
continue
payload = line[len("data: ") :].strip()
if payload == "[DONE]":
break
obj = json.loads(payload)
for choice in obj.get("choices", []):
content = (choice.get("delta") or {}).get("content")
if content:
deltas.append(content)
return deltas
def test_basic_transcription(self):
"""Test that transcription returns a valid non-empty response."""
result = self._transcribe()
@@ -103,6 +150,81 @@ class TestServingTranscription(CustomTestCase):
f"Transcription {i + 1} differs from first transcription",
)
# -- fused auto-detect (language=None) ---------------------------------
# The clip is English, so the fused path must both produce a valid
# transcription AND expose "en" as the detected language. None of the
# deltas / text fields should leak Whisper special tokens.
def test_auto_detect_language_verbose_json(self):
"""language omitted + verbose_json returns detected language + clean text."""
result = self._transcribe(language=None, response_format="verbose_json")
self.assertEqual(result.get("language"), "en")
text = result.get("text", "")
self.assertTrue(len(text) > 0, "Transcription should not be empty")
self.assertNotIn("<|", text, f"Special token leaked into text: {text!r}")
# Sanity-check content against the same keywords the English test uses.
keywords = ["privilege", "leader", "science", "art"]
matches = [kw for kw in keywords if kw in text.lower()]
self.assertGreaterEqual(
len(matches),
2,
f"Expected at least 2 of {keywords} in auto-detected transcription, "
f"found {matches}. Full text: {text!r}",
)
def test_auto_detect_matches_explicit_english(self):
"""Auto-detected (language=None) text should match explicit language=en."""
auto = self._transcribe(language=None).get("text", "")
explicit = self._transcribe(language="en").get("text", "")
self.assertEqual(
auto.strip(),
explicit.strip(),
"Auto-detect should produce the same transcription as language=en "
"on an English clip.",
)
self.assertNotIn("<|", auto)
def test_auto_detect_with_segment_timestamps(self):
"""language=None + timestamp_granularities uses the timestamps fused regex."""
result = self._transcribe(
language=None,
response_format="verbose_json",
timestamp_granularities=["segment"],
)
self.assertEqual(result.get("language"), "en")
segments = result.get("segments") or []
self.assertGreater(len(segments), 0, "Expected at least one segment")
for seg in segments:
self.assertIn("start", seg)
self.assertIn("end", seg)
self.assertIn("text", seg)
self.assertGreaterEqual(seg["end"], seg["start"])
self.assertNotIn(
"<|", seg["text"], f"Special token leaked into segment: {seg!r}"
)
def test_auto_detect_streaming(self):
"""language=None + stream=True: deltas scrubbed, concat matches non-streaming.
Verified against a real server: sglang's streaming path for Whisper
produces clean deltas (complete words, no BPE fragmentation), so the
fused path only needs to hide the forced prefix — which this PR
does. Asserts both the prefix-leak guard and text equivalence.
"""
deltas = self._transcribe_stream(language=None)
self.assertTrue(len(deltas) > 0, "Expected at least one streamed delta")
for d in deltas:
self.assertNotIn(
"<|", d, f"Special token leaked into streaming delta: {d!r}"
)
streamed = "".join(deltas).strip()
reference = self._transcribe(language=None).get("text", "").strip()
self.assertEqual(
streamed,
reference,
"Streamed auto-detect text should match the non-streaming result.",
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,303 @@
"""Unit tests for OpenAIServingTranscription's streaming fused-autodetect path.
Exercises the streaming handler: buffer deltas until the forced-prefix
sentinel lands, emit the scrubbed user-visible text, and never leak
Whisper special tokens. Covers both streaming modes — cumulative
(``incremental_streaming_output=False``, the default) and incremental
(``incremental_streaming_output=True``).
The tests mock ``TokenizerManager.generate_request`` to yield synthetic
``text`` chunks for each of the happy, abort, and boundary cases.
"""
from sglang.test.test_utils import maybe_stub_sgl_kernel
maybe_stub_sgl_kernel() # must precede any import that pulls in sgl_kernel
import json
import unittest
from typing import List
from unittest.mock import Mock
from sglang.srt.entrypoints.openai.protocol import TranscriptionRequest
from sglang.srt.entrypoints.openai.serving_transcription import (
OpenAIServingTranscription,
)
from sglang.srt.managers.io_struct import GenerateReqInput
from sglang.srt.utils import get_or_create_event_loop
from sglang.test.ci.ci_register import register_cpu_ci
from sglang.test.test_utils import CustomTestCase
register_cpu_ci(est_time=4, suite="stage-a-test-cpu")
def _chunk(text: str, finish: str = None) -> dict:
"""Shape of what TokenizerManager.generate_request yields per step."""
return {
"text": text,
"meta_info": {
"finish_reason": {"type": finish} if finish else None,
},
}
class _MockTokenizerManager:
"""Minimal mock satisfying OpenAIServingTranscription.__init__ and stream loop."""
def __init__(self, stream_chunks: List[dict]):
self.model_config = Mock()
self.model_config.hf_config = Mock()
self.model_config.hf_config.architectures = ["WhisperForConditionalGeneration"]
# Not a real ServerArgs, so base class sets allowed_custom_labels=None.
# Default tests assume cumulative-text streaming (the sglang upstream
# default); tests for incremental_streaming_output=True override this.
self.server_args = Mock(incremental_streaming_output=False)
self.tokenizer = Mock()
self._stream_chunks = stream_chunks
def generate_request(self, adapted_request, raw_request):
chunks = self._stream_chunks
async def gen():
for c in chunks:
yield c
return gen()
def create_abort_task(self, adapted_request):
return None
def _deltas_from_sse(sse_lines: List[str]) -> List[str]:
"""Extract ``choices[0].delta.content`` strings from a list of SSE frames."""
out = []
for line in sse_lines:
if not line.startswith("data: "):
continue
payload = line[len("data: ") :].strip()
if payload == "[DONE]":
continue
try:
obj = json.loads(payload)
except json.JSONDecodeError:
continue
for choice in obj.get("choices", []):
content = (choice.get("delta") or {}).get("content")
if content:
out.append(content)
return out
class TestStreamingFusedAutodetect(CustomTestCase):
"""_generate_transcription_stream with _fused_autodetect=True."""
def _run_stream(
self, chunks: List[dict], fused: bool = True, ts_variant: bool = False
):
tm = _MockTokenizerManager(chunks)
serving = OpenAIServingTranscription(tm)
kwargs = {"model": "whisper", "stream": True}
if ts_variant:
kwargs["timestamp_granularities"] = ["segment"]
request = TranscriptionRequest(**kwargs)
if fused:
request._fused_autodetect = True
request._fused_ts_variant = ts_variant
adapted = GenerateReqInput(text="", modalities=["audio"])
raw_request = Mock()
async def drive():
frames = []
async for frame in serving._generate_transcription_stream(
adapted, request, raw_request
):
frames.append(frame)
return frames
loop = get_or_create_event_loop()
frames = loop.run_until_complete(drive())
return request, frames
def test_prefix_stripped_and_language_extracted(self):
chunks = [
_chunk("<|en|>"),
_chunk("<|en|><|transcribe|>"),
_chunk("<|en|><|transcribe|><|notimestamps|>"),
_chunk("<|en|><|transcribe|><|notimestamps|> Hello"),
_chunk("<|en|><|transcribe|><|notimestamps|> Hello world", finish="stop"),
]
request, frames = self._run_stream(chunks)
deltas = _deltas_from_sse(frames)
self.assertEqual(deltas, ["Hello", " world"])
self.assertEqual(request.language, "en")
# No delta ever starts with the forced prefix or leading whitespace.
self.assertFalse(any("<|" in d for d in deltas))
self.assertFalse(deltas[0].startswith(" "))
def test_non_english_language_extracted(self):
chunks = [
_chunk("<|zh|><|transcribe|><|notimestamps|>你好"),
_chunk("<|zh|><|transcribe|><|notimestamps|>你好世界", finish="stop"),
]
request, frames = self._run_stream(chunks)
self.assertEqual(request.language, "zh")
self.assertEqual(_deltas_from_sse(frames), ["你好", "世界"])
def test_fsm_abort_before_sentinel_emits_error_frame(self):
# Sentinel never arrives; stream terminates on finish_reason. The
# handler must surface this as a real SSE error frame so the client
# can distinguish "detection failed" from "silent audio with zero
# transcription". language stays unset.
chunks = [
_chunk("<|en|>"),
_chunk("<|en|><|transcribe|>", finish="length"),
]
request, frames = self._run_stream(chunks)
self.assertEqual(_deltas_from_sse(frames), [])
error_frames = [f for f in frames if f.startswith("data: ") and '"error"' in f]
self.assertTrue(
error_frames, f"expected an SSE error frame, got frames={frames!r}"
)
self.assertIn("language auto-detect failed", error_frames[0])
self.assertIsNone(request.language)
def test_non_fused_stream_passes_through(self):
# When _fused_autodetect is False, no buffering or anchoring happens.
chunks = [
_chunk("Hello"),
_chunk("Hello world", finish="stop"),
]
request, frames = self._run_stream(chunks, fused=False)
self.assertEqual(_deltas_from_sse(frames), ["Hello", " world"])
def test_streaming_ts_variant_sentinel_at_chunk_boundary(self):
# The <|0.00|> sentinel can land in its own chunk ahead of any
# transcription text, and the trailing-space arrives later. The
# handler must buffer silently until a non-whitespace char shows
# up (so the first delta doesn't leak a leading space) and then
# scrub subsequent embedded timestamp tokens.
chunks = [
_chunk("<|en|>"),
_chunk("<|en|><|transcribe|>"),
_chunk("<|en|><|transcribe|><|0.00|>"), # sentinel alone
_chunk("<|en|><|transcribe|><|0.00|> "), # + whitespace only
_chunk("<|en|><|transcribe|><|0.00|> Hello"), # first word
_chunk("<|en|><|transcribe|><|0.00|> Hello<|5.00|> World"),
_chunk(
"<|en|><|transcribe|><|0.00|> Hello<|5.00|> World<|endoftext|>",
finish="stop",
),
]
request, frames = self._run_stream(chunks, ts_variant=True)
deltas = _deltas_from_sse(frames)
self.assertEqual(request.language, "en")
self.assertFalse(any("<|" in d for d in deltas))
# No delta starts with a leading space (the one Whisper emits
# between <|0.00|> and "Hello" was consumed by the defer-on-
# whitespace path).
self.assertFalse(deltas[0].startswith(" "))
self.assertEqual("".join(deltas), "Hello World")
def test_streaming_timestamps_variant_scrubs_embedded_segment_tokens(self):
# Streaming + timestamp_granularities + language=None uses the fused
# timestamps variant (<|0.00|> sentinel). Segment-boundary tokens
# <|5.00|>, <|10.00|> land mid-stream; each delta must have them
# scrubbed before reaching the client. Auto-detection still works
# — the SSE stream carries clean text, and callers who want
# segment timing can use response_format=verbose_json which builds
# segments from output_ids on a separate path.
chunks = [
_chunk("<|en|><|transcribe|><|0.00|> Hello"),
_chunk("<|en|><|transcribe|><|0.00|> Hello<|5.00|> World"),
_chunk(
"<|en|><|transcribe|><|0.00|> Hello<|5.00|> World<|10.00|><|endoftext|>",
finish="stop",
),
]
request, frames = self._run_stream(chunks, ts_variant=True)
deltas = _deltas_from_sse(frames)
self.assertEqual(request.language, "en")
self.assertFalse(any("<|" in d for d in deltas))
self.assertEqual("".join(deltas), "Hello World")
def test_trailing_endoftext_scrubbed_from_last_delta(self):
# skip_special_tokens=False means the detokenizer may emit
# <|endoftext|> at the tail. The fused streaming path must scrub it
# per-delta so clients never see special tokens in SSE chunks.
chunks = [
_chunk("<|en|><|transcribe|><|notimestamps|> Hello"),
_chunk(
"<|en|><|transcribe|><|notimestamps|> Hello world<|endoftext|>",
finish="stop",
),
]
_, frames = self._run_stream(chunks)
deltas = _deltas_from_sse(frames)
self.assertEqual(deltas, ["Hello", " world"])
self.assertFalse(any("<|" in d for d in deltas))
class TestStreamingIncrementalOutputMode(CustomTestCase):
"""Server runs with ``incremental_streaming_output=True``.
In that mode each chunk's ``content["text"]`` is the new delta from the
detokenizer, not the cumulative text. The handler must accumulate
locally into ``cumulative_text`` — otherwise the subsequent
``visible[len(visible_buffer):]`` slice would strip characters the
server already sent as a delta.
"""
def _run_incremental_stream(self, chunk_deltas, fused=False):
"""Server in incremental mode: yield per-chunk delta, not cumulative."""
chunks = [
_chunk(d, finish=("stop" if i == len(chunk_deltas) - 1 else None))
for i, d in enumerate(chunk_deltas)
]
tm = _MockTokenizerManager(chunks)
tm.server_args = Mock(incremental_streaming_output=True)
serving = OpenAIServingTranscription(tm)
request = TranscriptionRequest(model="whisper", stream=True)
if fused:
request._fused_autodetect = True
adapted = GenerateReqInput(text="", modalities=["audio"])
async def drive():
frames = []
async for f in serving._generate_transcription_stream(
adapted, request, Mock()
):
frames.append(f)
return frames
return request, get_or_create_event_loop().run_until_complete(drive())
def test_incremental_non_fused_emits_each_delta_verbatim(self):
# sglang.private default: each content["text"] IS the new delta, so
# the handler should NOT slice it. Client should see exactly what
# the detokenizer emitted.
deltas_in = [" The", " President", ":", " Thank", " you"]
_, frames = self._run_incremental_stream(deltas_in, fused=False)
self.assertEqual(_deltas_from_sse(frames), deltas_in)
def test_incremental_fused_autodetect_still_strips_prefix(self):
# Incremental + fused: the handler must accumulate to find the
# sentinel, then emit only the post-prefix portion per chunk.
deltas_in = [
"<|en|>",
"<|transcribe|>",
"<|notimestamps|>",
" Hello",
" world",
]
request, frames = self._run_incremental_stream(deltas_in, fused=True)
emitted = _deltas_from_sse(frames)
# Prefix never leaks, and concat matches the expected transcription.
self.assertFalse(any("<|" in d for d in emitted))
self.assertEqual("".join(emitted), "Hello world")
self.assertEqual(request.language, "en")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,318 @@
"""Unit tests for the Whisper transcription adapter.
Focused on ``WhisperAdapter.parse_fused_output`` — a pure static method
that parses the fused auto-detect output into ``(language, user_visible_text)``.
``visible=None`` means "forced prefix not yet locatable; streaming callers
should keep buffering, non-streaming callers should fall back to a
best-effort scrub".
"""
import re
import unittest
from typing import Any
from sglang.srt.entrypoints.openai.protocol import TranscriptionRequest
from sglang.srt.entrypoints.openai.transcription_adapters.whisper import (
WHISPER_AUTODETECT_REGEX,
WHISPER_AUTODETECT_TS_REGEX,
WHISPER_LANG_TOKEN_CODES,
WhisperAdapter,
)
from sglang.test.ci.ci_register import register_cpu_ci
from sglang.test.test_utils import CustomTestCase
register_cpu_ci(est_time=2, suite="stage-a-test-cpu")
class TestWhisperParseFusedOutput(CustomTestCase):
"""parse_fused_output: (language, visible) where visible=None means defer."""
def test_happy_english(self):
lang, visible = WhisperAdapter.parse_fused_output(
"<|en|><|transcribe|><|notimestamps|> Hello world"
)
self.assertEqual((lang, visible), ("en", "Hello world"))
def test_happy_non_english(self):
lang, visible = WhisperAdapter.parse_fused_output(
"<|zh|><|transcribe|><|notimestamps|>你好世界"
)
self.assertEqual((lang, visible), ("zh", "你好世界"))
def test_missing_language_prefix_defers(self):
# Partial prefix or raw untagged text — streaming callers should
# keep buffering; non-streaming callers fall back to best-effort.
self.assertEqual(
WhisperAdapter.parse_fused_output("raw untagged output"), (None, None)
)
self.assertEqual(WhisperAdapter.parse_fused_output(""), (None, None))
def test_missing_sentinel_defers(self):
# Reviewer's repro: <|zh|> Hi — language tag in but no sentinel.
self.assertEqual(WhisperAdapter.parse_fused_output("<|zh|> Hi"), (None, None))
def test_truncated_after_transcribe_defers(self):
self.assertEqual(
WhisperAdapter.parse_fused_output("<|en|><|transcribe|>"), (None, None)
)
def test_unsupported_language_code_defers(self):
# FSM regex only allows ISO639_1_SUPPORTED_LANGS. A bypassed-FSM
# <|xx|> must not leak through as a valid detection.
self.assertEqual(
WhisperAdapter.parse_fused_output("<|xx|><|transcribe|><|notimestamps|>hi"),
(None, None),
)
def test_malformed_prefix_without_transcribe_defers(self):
# The parse must match the exact 3-token forced prefix, not
# "lang tag + sentinel somewhere". A bypassed-FSM string that
# skips <|transcribe|> must not parse as a valid detection.
self.assertEqual(
WhisperAdapter.parse_fused_output("<|en|>junk<|notimestamps|>text"),
(None, None),
)
self.assertEqual(
WhisperAdapter.parse_fused_output("<|en|><|0.00|> text"),
(None, None),
)
def test_sentinel_in_but_whitespace_only_returns_empty_visible(self):
# Prefix arrived at a chunk boundary before the first word. The
# .strip() collapses to "" so streaming callers see no delta yet;
# the language is still reported as soon as the sentinel lands.
self.assertEqual(
WhisperAdapter.parse_fused_output("<|en|><|transcribe|><|notimestamps|>"),
("en", ""),
)
self.assertEqual(
WhisperAdapter.parse_fused_output("<|en|><|transcribe|><|notimestamps|> "),
("en", ""),
)
def test_trailing_endoftext_scrubbed(self):
lang, visible = WhisperAdapter.parse_fused_output(
"<|en|><|transcribe|><|notimestamps|> Hello world<|endoftext|>"
)
self.assertEqual((lang, visible), ("en", "Hello world"))
def test_embedded_timestamp_tokens_scrubbed(self):
# Defensive: in the ts variant Whisper's tokenizer normally
# decodes <|X.XX|> tokens to "" so they never reach this path,
# but if a future tokenizer leaks them through they must be
# scrubbed from the user-visible text. verbose_json segment
# timing comes from _parse_segments over output_ids on a
# separate path.
lang, visible = WhisperAdapter.parse_fused_output(
"<|en|><|transcribe|><|0.00|> Hello<|5.00|> world<|10.00|><|endoftext|>",
ts_variant=True,
)
self.assertEqual((lang, visible), ("en", "Hello world"))
def test_ts_variant_realistic_decoded_text(self):
# Real Whisper tokenizer decodes every <|X.XX|> timestamp token
# (id 50365+) to "" even with skip_special_tokens=False, so for
# the ts variant the cumulative text is just <|en|><|transcribe|>
# followed directly by the BPE-decoded transcription. Asserts
# that the parser handles this shape — without ts_variant=True
# it would (correctly) defer because <|notimestamps|> is missing.
lang, visible = WhisperAdapter.parse_fused_output(
"<|en|><|transcribe|> Hello world<|endoftext|>", ts_variant=True
)
self.assertEqual((lang, visible), ("en", "Hello world"))
# Same input under non-ts contract correctly defers.
self.assertEqual(
WhisperAdapter.parse_fused_output(
"<|en|><|transcribe|> Hello world<|endoftext|>"
),
(None, None),
)
def test_visible_grows_monotonically_across_snapshots(self):
# Streaming property: cumulative text produces cumulative visible.
snapshots = [
"<|en|><|transcribe|>",
"<|en|><|transcribe|><|notimestamps|>",
"<|en|><|transcribe|><|notimestamps|> Hello",
"<|en|><|transcribe|><|notimestamps|> Hello world",
"<|en|><|transcribe|><|notimestamps|> Hello world<|endoftext|>",
]
visibles = [WhisperAdapter.parse_fused_output(s)[1] for s in snapshots]
# (None, "", "Hello", "Hello world", "Hello world")
self.assertEqual(visibles, [None, "", "Hello", "Hello world", "Hello world"])
# Every non-None entry is a prefix of the next non-None entry.
real = [v for v in visibles if v is not None]
for a, b in zip(real, real[1:]):
self.assertTrue(b.startswith(a), f"monotonicity broken: {a!r} -> {b!r}")
class TestWhisperLangTokenCoverage(CustomTestCase):
"""The FSM regex must cover every Whisper language token, not just the
narrower ISO639_1_SUPPORTED_LANGS set used for input validation."""
def test_three_letter_codes_parse(self):
# yue (Cantonese, v3), haw (Hawaiian), jw (Javanese, two-letter but
# missing from ISO639_1_SUPPORTED_LANGS) — reviewer's flagged examples.
for code in ("yue", "haw", "jw"):
with self.subTest(lang=code):
lang, visible = WhisperAdapter.parse_fused_output(
f"<|{code}|><|transcribe|><|notimestamps|> Hi"
)
self.assertEqual(lang, code)
self.assertEqual(visible, "Hi")
def test_known_whisper_langs_in_allowlist(self):
# Spot-check: codes the reviewer named + common 3-letter tokens.
for code in ("yue", "haw", "jw", "su", "ba", "tt", "ln", "lo"):
self.assertIn(code, WHISPER_LANG_TOKEN_CODES)
def test_fsm_regex_includes_three_letter_alternatives(self):
# Defensive: the regex alternation must spell out the 3-letter codes
# so xgrammar's FSM admits the <|yue|> / <|haw|> single-token path.
for code in ("yue", "haw"):
self.assertIn(re.escape(code), WHISPER_AUTODETECT_REGEX)
self.assertIn(re.escape(code), WHISPER_AUTODETECT_TS_REGEX)
def test_autodetect_codes_round_trip_through_input_validator(self):
# A code returned by fused autodetect must be accepted as
# ``language=`` on a follow-up request. Before the fix,
# ``normalize_language_to_code("yue")`` raised ValueError even
# though verbose_json could report ``"yue"`` from the same server.
from sglang.srt.multimodal.processors.whisper import (
normalize_language_to_code,
)
for code in ("yue", "haw", "jw", "ba", "su", "tt"):
with self.subTest(lang=code):
self.assertEqual(normalize_language_to_code(code), code)
def test_unknown_language_token_id_raises_clean_error(self):
# Some Whisper codes (yue, v3-only) aren't in older checkpoints'
# vocabs. The explicit-language path must raise a clean ValueError
# in that case instead of silently feeding the unk token into the
# decoder and producing garbage. Mocks cover both "returns None"
# and "returns unk_token_id" tokenizer behaviors.
from unittest.mock import Mock
from sglang.srt.multimodal.processors.whisper import WhisperProcessor
proc = WhisperProcessor.__new__(WhisperProcessor)
# Tokenizer where <|yue|> is not in the vocab → returns unk_id.
tok = Mock()
tok.convert_tokens_to_ids = Mock(return_value=100) # arbitrary unk
tok.unk_token_id = 100
proc._tokenizer = tok
with self.assertRaises(ValueError) as ctx:
proc._get_language_token_id("yue")
self.assertIn("yue", str(ctx.exception))
# Known code (English) on the same tokenizer still works.
tok.convert_tokens_to_ids = Mock(return_value=50259) # <|en|>
self.assertEqual(proc._get_language_token_id("en"), 50259)
# Some tokenizers return None for unknown tokens instead of unk_id.
tok2 = Mock()
tok2.convert_tokens_to_ids = Mock(return_value=None)
tok2.unk_token_id = 100
proc._tokenizer = tok2
with self.assertRaises(ValueError):
proc._get_language_token_id("yue")
class TestWhisperStripSpecialTokens(CustomTestCase):
"""Fallback scrub used when parse_fused_output defers."""
def test_strips_all_whisper_specials(self):
self.assertEqual(
WhisperAdapter.strip_special_tokens(
"<|en|><|transcribe|><|0.00|>hi<|5.00|>world<|endoftext|>"
),
"hiworld",
)
def test_identity_on_plain_text(self):
self.assertEqual(
WhisperAdapter.strip_special_tokens("plain text"), "plain text"
)
self.assertEqual(WhisperAdapter.strip_special_tokens(""), "")
def test_preserves_spoken_angle_bracket_sequences(self):
# The scrub must only remove actual Whisper special-token literals
# (lang / control / <|X.XX|> timestamps), not arbitrary ``<|...|>``
# patterns that can appear in transcribed speech (someone reading a
# token name aloud, an AI-safety demo, code dictation, etc.).
self.assertEqual(
WhisperAdapter.strip_special_tokens("the token <|foo|> is unused"),
"the token <|foo|> is unused",
)
# Real specials still scrubbed even when interleaved with bogus ones.
self.assertEqual(
WhisperAdapter.strip_special_tokens(
"<|en|>hello <|foo|> world<|endoftext|>"
),
"hello <|foo|> world",
)
def test_parse_preserves_spoken_angle_bracket_sequences(self):
# Same for the per-chunk scrub inside parse_fused_output.
lang, visible = WhisperAdapter.parse_fused_output(
"<|en|><|transcribe|><|notimestamps|> look at <|foo|><|endoftext|>"
)
self.assertEqual((lang, visible), ("en", "look at <|foo|>"))
class TestWhisperBuildFusedAutodetectParams(CustomTestCase):
"""build_fused_autodetect_params picks the right regex + propagates ts param."""
def _request(self, **kwargs: Any) -> TranscriptionRequest:
base: dict[str, Any] = dict(model="whisper", temperature=0.0)
base.update(kwargs)
return TranscriptionRequest(**base)
def test_no_timestamps_uses_notimestamps_regex(self):
params = WhisperAdapter().build_fused_autodetect_params(self._request())
self.assertEqual(params["regex"], WHISPER_AUTODETECT_REGEX)
self.assertNotIn("timestamp_granularities", params)
def test_timestamps_uses_ts_regex_and_propagates_granularities(self):
req = self._request(timestamp_granularities=["segment"])
params = WhisperAdapter().build_fused_autodetect_params(req)
self.assertEqual(params["regex"], WHISPER_AUTODETECT_TS_REGEX)
self.assertEqual(params["timestamp_granularities"], ["segment"])
def test_empty_timestamps_list_uses_notimestamps_regex(self):
# Empty list is falsy — treat as "no timestamps requested".
req = self._request(timestamp_granularities=[])
params = WhisperAdapter().build_fused_autodetect_params(req)
self.assertEqual(params["regex"], WHISPER_AUTODETECT_REGEX)
self.assertNotIn("timestamp_granularities", params)
def test_spaces_between_special_tokens_is_false(self):
# parse_fused_output assumes a zero-space forced prefix. Slow
# Whisper tokenizers otherwise insert a space between adjacent
# special tokens, which would silently break the parse path.
for req in (
self._request(),
self._request(timestamp_granularities=["segment"]),
):
params = WhisperAdapter().build_fused_autodetect_params(req)
self.assertIs(params["spaces_between_special_tokens"], False)
def test_fused_params_survive_sampling_params_construction(self):
# Regression: the multimodal processor's fused branch used to skip
# popping `timestamp_granularities`, leaking the key into
# SamplingParams(**kwargs) → TypeError on any language=None +
# timestamp_granularities request. Mirrors what the processor does
# before constructing SamplingParams.
from sglang.srt.sampling.sampling_params import SamplingParams
req = self._request(timestamp_granularities=["segment"])
params = WhisperAdapter().build_fused_autodetect_params(req)
# Fields the processor pops before SamplingParams(**kwargs).
params.pop("_detect_language", None)
params.pop("timestamp_granularities", None)
SamplingParams(**params)
if __name__ == "__main__":
unittest.main()