mirror of
https://github.com/unslothai/unsloth.git
synced 2026-08-25 08:42:25 +00:00
* 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>
155 lines
5.8 KiB
Python
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 == {}
|