unsloth/studio/backend/tests/test_audio_sampling_fill.py
Nilay 505568a7e2
Studio: record media API traffic in the monitor and add verbose_json transcriptions (#9217)
* Studio: record media API traffic in the monitor and add verbose_json transcriptions

* Studio: record a media client abort as cancelled, not an error

* Studio: keep host paths out of the image monitor row label

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: tighten the comments added by this PR

* Studio: monitor proxied STT transcriptions too

* Studio: keep verbose_json parseable by the OpenAI clients and close every monitor row

verbose_json emitted the sidecar's language and duration straight through, and both
can be null: language is an echo of the request, so any auto-detect transcription
(the common call) had none, and duration is null for a clip that decoded to no
samples. OpenAI types both as required, so the official clients raised a validation
error before the caller ever saw the transcript. Emit Whisper's default language
when the sidecar has none to report, and 0 seconds for an empty clip. segments stays
out: it is optional there and only returned for timestamp_granularities.

KeyboardInterrupt and SystemExit are not Exception, so they fell past every arm of
the monitor context manager and left the row at "running" for the life of the
process. fail_open closes it without being able to stamp an error onto a row an
earlier arm already settled.

Also give the transcription label the same public_model_id treatment the image route
already gets, so a local path can never reach a row that goes out over the tunnel.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Studio: refuse verbose_json rather than guess a language, and stop two more stuck rows

Reporting Whisper's default language when the engine detected nothing was the wrong
call: it is parseable but it labels a Japanese clip "en", and a caller cannot tell.
The sidecar only ever echoes back the language it was handed, so the request already
knows whether one exists, and this now refuses with a 501 up front, before the sidecar
runs and before a row is opened, the way the response_format check already does.
verbose_json still works whenever the caller names a language. The duration default
stays: unlike the language it is derived, not guessed, since duration is null only for
a clip that decoded to no samples and that clip really is zero seconds.

A dict detail whose "error" is not itself a dict called .get() on a string and raised
AttributeError out of the handler, which skipped finish() and stranded the row at
"running". Only openai_error_body nests the message that way, and plenty of raisers
pass a flat dict.

timestamp_granularities was accepted and dropped, so a caller asking for word timings
got a 200 with none and no way to tell that from audio that had none. The sidecar
reports no per-token timing, so it refuses there and forwards to a saved connection.

Content-Type is case-insensitive, so Application/JSON recorded the whole envelope in
the monitor instead of the transcript.

The image row took body.model verbatim and only sanitised it after a successful
generation, so a load or generation failure left the raw client string, which may be a
host path, on a row that goes out over the tunnel. Its size and stream validation also
ran inside the monitor, so a request refused before any work still produced a red
error row; that now happens before the row is opened, like the audio routes.

* Studio: redact the audio monitor labels when the row opens, not only on success

The image route already sanitizes its opening label because a failure before the
relabel strands the raw client string on a terminal row that goes out over the
tunnel. Both audio routes still echoed model verbatim, and the proxied
transcription arm never relabels at all, so its raw string is what the row keeps
for its whole life.

model is informational on these endpoints and is never validated, and a
checkpoint path is ordinary input for a local OpenAI-compatible server, so an
absolute or UNC path reached GET /monitor. Redacted at the start site now.
Provider ids are returned unchanged.

* Studio: trim the commentary on the media API monitor rows

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
2026-08-23 00:13:11 -07:00

155 lines
5.8 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Audio (TTS) generation applies recommended sampling + operator pins, like chat.
Regression guard for the fix that moved the sampling fill ahead of the audio generators: a
prior version resolved sampling only after the audio branches returned, so `unsloth run
--temperature` (UNSLOTH_SAMPLING_*) and per-model recommendations never reached audio
generation. These exercise the transformers TTS path of ``generate_audio`` (the direct
``/audio/generate`` route, which the chat-completions audio branches also delegate to).
"""
import asyncio
import json
import pytest
import routes.inference as inference_route
from fastapi import HTTPException
from models.inference import AudioSpeechRequest, ChatCompletionRequest
from starlette.requests import Request
from utils.inference import inference_config as ic
def _request(path = "/v1/audio/speech"):
"""/v1/audio/speech opens an API monitor row, so it needs a real request."""
return Request(
{
"type": "http",
"http_version": "1.1",
"method": "POST",
"scheme": "http",
"server": ("testserver", 80),
"path": path,
"raw_path": path.encode(),
"query_string": b"",
"root_path": "",
"headers": [],
}
)
class _FakeLlama:
# is_loaded False forces the transformers (non-GGUF) TTS branch in generate_audio.
is_loaded = False
_is_audio = False
class _FakeTransformersBackend:
def __init__(self, audio_type = "snac"):
self.active_model_name = "some/custom-tts"
self.models = {"some/custom-tts": {"is_audio": True, "audio_type": audio_type}}
self.captured = {}
def generate_audio_response(self, **kwargs):
self.captured.update(kwargs)
return (b"RIFFfake", 24000)
@pytest.fixture(autouse = True)
def _isolate(monkeypatch):
ic._recommended_sampling.cache_clear()
for field in ic.SAMPLING_FIELD_NAMES:
monkeypatch.delenv(ic._SAMPLING_FIELDS[field][0], raising = False)
yield
ic._recommended_sampling.cache_clear()
def _run_generate_audio(
monkeypatch,
*,
recommended = None,
temperature = None,
):
backend = _FakeTransformersBackend()
monkeypatch.setattr(inference_route, "get_llama_cpp_backend", lambda: _FakeLlama())
monkeypatch.setattr(inference_route, "get_inference_backend", lambda: backend)
async def _noop_switch(*a, **k):
return None
monkeypatch.setattr(inference_route, "_maybe_auto_switch_model", _noop_switch)
# Recommendation source == the Chat UI's .inference block.
monkeypatch.setattr(ic, "load_inference_config", lambda mid: dict(recommended or {}))
ic._recommended_sampling.cache_clear()
kwargs = {"model": "some/custom-tts", "messages": [{"role": "user", "content": "hi"}]}
if temperature is not None:
kwargs["temperature"] = temperature
payload = ChatCompletionRequest(**kwargs)
asyncio.run(inference_route.generate_audio(payload, request = None, current_subject = "t"))
return backend.captured
def test_audio_uses_recommended_sampling_when_omitted(monkeypatch):
captured = _run_generate_audio(monkeypatch, recommended = {"temperature": 1.0, "top_k": 64})
assert captured["temperature"] == 1.0
assert captured["top_k"] == 64
def test_audio_operator_pin_overrides_client(monkeypatch):
monkeypatch.setenv("UNSLOTH_SAMPLING_TEMPERATURE", "0.9")
captured = _run_generate_audio(monkeypatch, recommended = {"temperature": 1.0}, temperature = 0.2)
assert captured["temperature"] == 0.9 # operator pin wins even over an explicit client value
def test_audio_client_explicit_preserved(monkeypatch):
captured = _run_generate_audio(monkeypatch, recommended = {"temperature": 1.0}, temperature = 0.2)
assert captured["temperature"] == 0.2 # explicit client value preserved over recommendation
def test_audio_generate_returns_the_exact_persisted_clip_id(monkeypatch):
backend = _FakeTransformersBackend()
monkeypatch.setattr(inference_route, "get_llama_cpp_backend", lambda: _FakeLlama())
monkeypatch.setattr(inference_route, "get_inference_backend", lambda: backend)
async def _noop_switch(*a, **k):
return None
monkeypatch.setattr(inference_route, "_maybe_auto_switch_model", _noop_switch)
payload = ChatCompletionRequest(
model = "some/custom-tts", messages = [{"role": "user", "content": "hi"}]
)
response = asyncio.run(
inference_route.generate_audio(payload, request = None, current_subject = "t")
)
body = json.loads(response.body)
assert body["clip_id"]
assert len(body["clip_id"]) == 32
def test_whisper_is_rejected_cleanly_by_both_tts_endpoints(monkeypatch):
backend = _FakeTransformersBackend(audio_type = "whisper")
monkeypatch.setattr(inference_route, "get_llama_cpp_backend", lambda: _FakeLlama())
monkeypatch.setattr(inference_route, "get_inference_backend", lambda: backend)
async def _noop_switch(*a, **k):
return None
monkeypatch.setattr(inference_route, "_maybe_auto_switch_model", _noop_switch)
payload = ChatCompletionRequest(
model = "some/custom-tts", messages = [{"role": "user", "content": "hi"}]
)
speech = AudioSpeechRequest(input = "hi", model = "some/custom-tts")
for request in (
inference_route.generate_audio(payload, request = None, current_subject = "t"),
inference_route.openai_audio_speech(speech, request = _request(), current_subject = "t"),
):
with pytest.raises(HTTPException) as exc:
asyncio.run(request)
assert exc.value.status_code == 400
assert "does not support text-to-speech" in exc.value.detail
assert backend.captured == {}