335 lines
11 KiB
Python
335 lines
11 KiB
Python
"""Tests for the shared LLM speech adapter and speech job handlers."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import base64
|
|
import json
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
import httpx
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
BACKEND_DIR = Path(__file__).resolve().parents[1]
|
|
if str(BACKEND_DIR) not in __import__("sys").path:
|
|
__import__("sys").path.insert(0, str(BACKEND_DIR))
|
|
|
|
import job_handlers # noqa: E402
|
|
import tts_asr # noqa: E402
|
|
from audit_store import BaseAuditStore # noqa: E402
|
|
|
|
|
|
def _wav_bytes(duration_ms: int = 100) -> bytes:
|
|
sample_rate = 16000
|
|
frames = max(1, int(sample_rate * duration_ms / 1000))
|
|
data = b"".join((i % 32768).to_bytes(2, "little", signed=False) for i in range(frames))
|
|
data_size = len(data)
|
|
return (
|
|
b"RIFF" + (36 + data_size).to_bytes(4, "little")
|
|
+ b"WAVE"
|
|
+ b"fmt " + (16).to_bytes(4, "little")
|
|
+ (1).to_bytes(2, "little")
|
|
+ (1).to_bytes(2, "little")
|
|
+ sample_rate.to_bytes(4, "little")
|
|
+ sample_rate.to_bytes(4, "little")
|
|
+ (2).to_bytes(2, "little")
|
|
+ (16).to_bytes(2, "little")
|
|
+ b"data" + data_size.to_bytes(4, "little")
|
|
+ data
|
|
)
|
|
|
|
|
|
def _run_async(coro):
|
|
return asyncio.run(coro)
|
|
|
|
|
|
class _CaptureAuditStore(BaseAuditStore):
|
|
def __init__(self) -> None:
|
|
self.llm_calls: list[dict] = []
|
|
|
|
def record_llm_call(self, payload: dict) -> None:
|
|
self.llm_calls.append(payload)
|
|
|
|
|
|
def test_tts_calls_shared_llm_speech_endpoint(monkeypatch):
|
|
captured: dict[str, object] = {}
|
|
monkeypatch.setattr(tts_asr, "LLM_API_KEY", "test-api-key")
|
|
monkeypatch.setattr(tts_asr, "TTS_MODEL_ID", "Qwen3-TTS-12Hz-1.7B-VoiceDesign-8bit")
|
|
|
|
def transport(request: httpx.Request):
|
|
captured["url"] = str(request.url)
|
|
captured["headers"] = dict(request.headers)
|
|
captured["json"] = json.loads(request.read().decode("utf-8"))
|
|
return httpx.Response(200, content=b"speech-ok", headers={"x-request-id": "tts-req-1"})
|
|
|
|
async def run():
|
|
client = httpx.AsyncClient(
|
|
base_url="https://speech.example/v1",
|
|
transport=httpx.MockTransport(transport),
|
|
)
|
|
try:
|
|
tts_asr._httpx_client = client
|
|
return await tts_asr.generate_tts_response(
|
|
"你好世界",
|
|
instruct="A warm Mandarin voice.",
|
|
speaker="Vivian",
|
|
output_format="wav",
|
|
)
|
|
finally:
|
|
await client.aclose()
|
|
tts_asr._httpx_client = None
|
|
|
|
result = _run_async(run())
|
|
|
|
assert result["format"] == "wav"
|
|
assert result["speaker"] == "Vivian"
|
|
assert result["model"] == "Qwen3-TTS-12Hz-1.7B-VoiceDesign-8bit"
|
|
assert result["upstream_request_id"] == "tts-req-1"
|
|
assert base64.b64decode(result["audio_base64"]) == b"speech-ok"
|
|
assert captured["url"] == "https://speech.example/v1/audio/speech"
|
|
assert captured["headers"]["authorization"] == "Bearer test-api-key"
|
|
payload = captured["json"]
|
|
assert payload["model"] == "Qwen3-TTS-12Hz-1.7B-VoiceDesign-8bit"
|
|
assert payload["voice"] == "Vivian"
|
|
assert payload["input"] == "你好世界"
|
|
assert payload["instructions"] == "A warm Mandarin voice."
|
|
assert "instruction" not in payload
|
|
|
|
|
|
def test_tts_uses_nonempty_default_instructions(monkeypatch):
|
|
captured: dict[str, object] = {}
|
|
|
|
def transport(request: httpx.Request):
|
|
captured["json"] = json.loads(request.read().decode("utf-8"))
|
|
return httpx.Response(200, content=b"speech-ok")
|
|
|
|
async def run():
|
|
client = httpx.AsyncClient(
|
|
base_url="https://speech.example/v1",
|
|
transport=httpx.MockTransport(transport),
|
|
)
|
|
try:
|
|
tts_asr._httpx_client = client
|
|
return await tts_asr.generate_tts_response("你好世界")
|
|
finally:
|
|
await client.aclose()
|
|
tts_asr._httpx_client = None
|
|
|
|
_run_async(run())
|
|
|
|
payload = captured["json"]
|
|
assert payload["instructions"] == tts_asr.DEFAULT_TTS_INSTRUCTIONS
|
|
assert payload["instructions"].strip()
|
|
|
|
|
|
def test_asr_calls_shared_llm_transcriptions_endpoint(monkeypatch):
|
|
captured: dict[str, object] = {}
|
|
monkeypatch.setattr(tts_asr, "LLM_API_KEY", "test-api-key")
|
|
monkeypatch.setattr(tts_asr, "ASR_MODEL_ID", "Qwen3-ASR-0.6B-8bit")
|
|
|
|
def transport(request: httpx.Request):
|
|
captured["url"] = str(request.url)
|
|
captured["headers"] = dict(request.headers)
|
|
captured["content"] = request.read()
|
|
return httpx.Response(200, json={"text": "hello world", "language": "zh"}, headers={"x-request-id": "asr-req-1"})
|
|
|
|
async def run():
|
|
client = httpx.AsyncClient(
|
|
base_url="https://speech.example/v1",
|
|
transport=httpx.MockTransport(transport),
|
|
)
|
|
try:
|
|
tts_asr._httpx_client = client
|
|
return await tts_asr.generate_asr_response(_wav_bytes(), language="zh-CN")
|
|
finally:
|
|
await client.aclose()
|
|
tts_asr._httpx_client = None
|
|
|
|
result = _run_async(run())
|
|
|
|
assert result["text"] == "hello world"
|
|
assert result["language"] == "zh"
|
|
assert result["model"] == "Qwen3-ASR-0.6B-8bit"
|
|
assert result["upstream_request_id"] == "asr-req-1"
|
|
assert captured["url"] == "https://speech.example/v1/audio/transcriptions"
|
|
assert captured["headers"]["authorization"] == "Bearer test-api-key"
|
|
content = captured["content"]
|
|
assert b'name="model"' in content
|
|
assert b"Qwen3-ASR-0.6B-8bit" in content
|
|
assert b'name="language"' in content
|
|
assert b"zh" in content
|
|
|
|
|
|
def test_invalid_tts_text_returns_http_exception():
|
|
with pytest.raises(tts_asr.HTTPException) as exc:
|
|
_run_async(tts_asr._call_tts_api("", speaker="Vivian"))
|
|
assert exc.value.status_code == 400
|
|
|
|
|
|
def test_invalid_asr_audio_returns_http_exception():
|
|
with pytest.raises(tts_asr.HTTPException) as exc:
|
|
_run_async(tts_asr._call_asr_api(b"", language="zh-CN"))
|
|
assert exc.value.status_code == 400
|
|
|
|
|
|
def test_status_config_routes(monkeypatch):
|
|
app = FastAPI()
|
|
app.include_router(tts_asr.meta_router)
|
|
monkeypatch.setattr(tts_asr, "LLM_BASE_URL", "https://speech.example/v1")
|
|
monkeypatch.setattr(tts_asr, "LLM_API_KEY", "")
|
|
monkeypatch.setattr(tts_asr, "TTS_MODEL_ID", "Qwen3-TTS-12Hz-1.7B-VoiceDesign-8bit")
|
|
monkeypatch.setattr(tts_asr, "ASR_MODEL_ID", "Qwen3-ASR-0.6B-8bit")
|
|
|
|
with TestClient(app) as client:
|
|
status = client.get("/status")
|
|
config = client.get("/config")
|
|
|
|
assert status.status_code == 200
|
|
assert config.status_code == 200
|
|
assert status.json()["llm_url"] == "https://speech.example/v1"
|
|
assert status.json()["tts_model"] == "Qwen3-TTS-12Hz-1.7B-VoiceDesign-8bit"
|
|
assert status.json()["asr_model"] == "Qwen3-ASR-0.6B-8bit"
|
|
assert status.json()["status"]["api_key_configured"] is False
|
|
assert status.json()["status"]["max_connections"] == tts_asr.SPEECH_MAX_CONNECTIONS
|
|
|
|
|
|
def test_tts_concurrent_requests_respect_connection_limit(monkeypatch):
|
|
monkeypatch.setattr(tts_asr, "SPEECH_MAX_CONNECTIONS", 4)
|
|
monkeypatch.setattr(tts_asr, "SPEECH_MAX_KEEPALIVE_CONNECTIONS", 1)
|
|
|
|
class LimitedClient:
|
|
def __init__(self):
|
|
self.semaphore = asyncio.Semaphore(4)
|
|
self.active = 0
|
|
self.max_active = 0
|
|
|
|
async def post(self, url: str, **kwargs):
|
|
async with self.semaphore:
|
|
self.active += 1
|
|
self.max_active = max(self.max_active, self.active)
|
|
await asyncio.sleep(0.01)
|
|
self.active -= 1
|
|
return httpx.Response(200, content=b"speech-ok", request=httpx.Request("POST", f"https://speech.example{url}"))
|
|
|
|
async def run():
|
|
client = LimitedClient()
|
|
|
|
async def get_client():
|
|
return client
|
|
|
|
monkeypatch.setattr(tts_asr, "_get_speech_client", get_client)
|
|
await asyncio.gather(*(tts_asr.generate_tts_response(f"文本 {index}") for index in range(20)))
|
|
return client
|
|
|
|
client = _run_async(run())
|
|
assert client.max_active <= 4
|
|
|
|
|
|
def test_tts_asr_handlers_record_audit(monkeypatch):
|
|
audit_store = _CaptureAuditStore()
|
|
|
|
async def fake_tts(*args, **kwargs):
|
|
return {
|
|
"audio_base64": base64.b64encode(b"ok").decode("utf-8"),
|
|
"format": "wav",
|
|
"duration_ms": 1200,
|
|
"audio_bytes": 2,
|
|
"text_chars": 2,
|
|
"speaker": "Vivian",
|
|
"model": "Qwen3-TTS-12Hz-1.7B-VoiceDesign-8bit",
|
|
"request_ms": 45,
|
|
"upstream_request_id": "tts-upstream",
|
|
}
|
|
|
|
async def fake_asr(*args, **kwargs):
|
|
return {
|
|
"text": "hello world",
|
|
"language": "zh",
|
|
"audio_bytes": len(_wav_bytes()),
|
|
"model": "Qwen3-ASR-0.6B-8bit",
|
|
"request_ms": 80,
|
|
"upstream_request_id": "asr-upstream",
|
|
}
|
|
|
|
monkeypatch.setattr(job_handlers, "generate_tts_response", fake_tts)
|
|
monkeypatch.setattr(job_handlers, "generate_asr_response", fake_asr)
|
|
monkeypatch.setattr(job_handlers, "get_audit_store", lambda *_args, **_kwargs: audit_store)
|
|
|
|
base_payload = {
|
|
"request_id": "req-1",
|
|
"risk": {
|
|
"request_id": "req-1",
|
|
"session_hash": "session",
|
|
"ip_hash": "ip",
|
|
"estimated_input_tokens": 12,
|
|
"estimated_cost": 0.0,
|
|
"policy": {
|
|
"job_type": "tts",
|
|
"model": "Qwen3-TTS-12Hz-1.7B-VoiceDesign-8bit",
|
|
"profile": "speech_tts",
|
|
"max_output_tokens": 0,
|
|
},
|
|
},
|
|
"job_context": {
|
|
"created_at": 1000,
|
|
"started_at": 1200,
|
|
"queue_ms": 200,
|
|
},
|
|
}
|
|
|
|
async def run():
|
|
events = []
|
|
|
|
async def emit(event: str, data: dict):
|
|
events.append((event, data))
|
|
|
|
tts_payload = {
|
|
**base_payload,
|
|
"text": "你好",
|
|
"speaker": "Vivian",
|
|
"format": "wav",
|
|
}
|
|
await job_handlers.tts_handler(tts_payload, emit, lambda: False)
|
|
|
|
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as handle:
|
|
handle.write(_wav_bytes())
|
|
audio_path = handle.name
|
|
|
|
try:
|
|
asr_payload = {
|
|
**base_payload,
|
|
"risk": {
|
|
**base_payload["risk"],
|
|
"policy": {
|
|
"job_type": "asr",
|
|
"model": "Qwen3-ASR-0.6B-8bit",
|
|
"profile": "speech_asr",
|
|
"max_output_tokens": 0,
|
|
},
|
|
},
|
|
"input_path": audio_path,
|
|
"language": "zh-CN",
|
|
}
|
|
await job_handlers.asr_handler(asr_payload, emit, lambda: False)
|
|
finally:
|
|
job_handlers._safe_unlink(audio_path)
|
|
|
|
return events
|
|
|
|
events = _run_async(run())
|
|
|
|
assert any(event == "result" for event, _data in events)
|
|
assert len(audit_store.llm_calls) == 2
|
|
tts_audit = audit_store.llm_calls[0]
|
|
asr_audit = audit_store.llm_calls[1]
|
|
assert tts_audit["job_type"] == "tts"
|
|
assert tts_audit["queue_ms"] == 200
|
|
assert tts_audit["metadata"]["duration_ms"] == 1200
|
|
assert tts_audit["metadata"]["upstream_request_id"] == "tts-upstream"
|
|
assert asr_audit["job_type"] == "asr"
|
|
assert asr_audit["metadata"]["language"] == "zh"
|
|
assert asr_audit["metadata"]["upstream_request_id"] == "asr-upstream"
|