Skip to content

Commit 2211261

Browse files
WaelRabahwaelrabah11gemini-code-assist[bot]
authored
Feat/implement streaming path in audio transcription (#926)
* implement streaming path in audio transcription endpoint Signed-off-by: WaelRabah11 <wael.rabah@multiversecomputing.com> * Refactored proxy_multipart_request to share post call, headers, and error handling between streaming and non-streaming paths. Stats hooks (on_new_request/on_request_response/on_request_complete) now fire on both paths. Tutorial fixed to use with open(...). Tests pass (8/8). Signed-off-by: WaelRabah11 <wael.rabah@multiversecomputing.com> * Update src/vllm_router/services/request_service/request.py Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Signed-off-by: Wael Rabah <47258198+WaelRabah@users.noreply.github.com> * update streaming example, refactor try/except to reduce nesting and catch json decode errors Signed-off-by: WaelRabah11 <wael.rabah@multiversecomputing.com> * Added backend_response.close() to the non-streaming finally block Signed-off-by: WaelRabah11 <wael.rabah@multiversecomputing.com> * fix outdated test Signed-off-by: WaelRabah11 <wael.rabah@multiversecomputing.com> --------- Signed-off-by: WaelRabah11 <wael.rabah@multiversecomputing.com> Signed-off-by: Wael Rabah <47258198+WaelRabah@users.noreply.github.com> Co-authored-by: WaelRabah11 <wael.rabah@multiversecomputing.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
1 parent f4abc4d commit 2211261

3 files changed

Lines changed: 516 additions & 38 deletions

File tree

Lines changed: 370 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,370 @@
1+
"""
2+
Tests for transcription streaming path in proxy_multipart_request:
3+
4+
1. stream=True returns a StreamingResponse that proxies SSE chunks.
5+
2. stream=False (default) returns a JSONResponse (backward compatible).
6+
3. Stats hooks are called correctly for streaming transcription.
7+
4. Upstream response closed after normal completion.
8+
5. Upstream response closed when consumer aborts mid-stream (aclose).
9+
6. Connection failure before headers updates request stats.
10+
"""
11+
12+
from unittest.mock import AsyncMock, MagicMock, patch
13+
14+
import pytest
15+
from aiohttp import FormData
16+
17+
from vllm_router.routers.routing_logic import RoundRobinRouter
18+
from vllm_router.utils import SingletonABCMeta
19+
20+
21+
class EndpointInfo:
22+
def __init__(self, url, model_names=None, sleep=False, Id=None):
23+
self.url = url
24+
self.model_names = model_names or ["whisper-model"]
25+
self.sleep = sleep
26+
self.Id = Id
27+
28+
29+
ENDPOINTS = [EndpointInfo(url="http://whisper-engine")]
30+
31+
32+
@pytest.fixture(autouse=True)
33+
def cleanup_singletons():
34+
yield
35+
for cls in list(SingletonABCMeta._instances.keys()):
36+
del SingletonABCMeta._instances[cls]
37+
38+
39+
@pytest.fixture
40+
def setup_mocks():
41+
sd = MagicMock()
42+
sd.get_endpoint_info.return_value = ENDPOINTS
43+
sd.aliases = None
44+
45+
with patch(
46+
"vllm_router.services.request_service.request.get_service_discovery",
47+
return_value=sd,
48+
):
49+
yield sd
50+
51+
52+
def _make_mock_request():
53+
router = RoundRobinRouter()
54+
router.max_instance_failover_reroute_attempts = 0
55+
56+
state = MagicMock()
57+
state.router = router
58+
state.engine_stats_scraper.get_engine_stats.return_value = {}
59+
state.request_stats_monitor.get_request_stats.return_value = {}
60+
state.request_stats_monitor.on_new_request = MagicMock()
61+
state.request_stats_monitor.on_request_response = MagicMock()
62+
state.request_stats_monitor.on_request_complete = MagicMock()
63+
state.otel_enabled = False
64+
state.semantic_cache_available = False
65+
state.callbacks = None
66+
67+
req = MagicMock()
68+
req.headers = {
69+
"content-type": "multipart/form-data",
70+
"authorization": "Bearer test-key",
71+
}
72+
req.query_params = {}
73+
req.method = "POST"
74+
req.url = "http://router/v1/audio/transcriptions"
75+
req.app.state = state
76+
77+
return req
78+
79+
80+
def _make_backend_response(chunks, content_type="text/event-stream", status=200):
81+
async def iter_any():
82+
for c in chunks:
83+
yield c
84+
85+
content = MagicMock()
86+
content.iter_any = iter_any
87+
88+
resp = MagicMock()
89+
resp.status = status
90+
resp.headers = {"content-type": content_type, "x-request-id": "test-123"}
91+
resp.content = content
92+
resp.close = MagicMock()
93+
return resp
94+
95+
96+
@pytest.mark.asyncio
97+
async def test_transcription_streaming_returns_streaming_response(setup_mocks):
98+
req = _make_mock_request()
99+
100+
mock_backend_response = _make_backend_response(
101+
[b'data: {"text": "Hello"}\n\n', b'data: {"text": " World"}\n\n']
102+
)
103+
104+
mock_client = MagicMock()
105+
mock_client.post = AsyncMock(return_value=mock_backend_response)
106+
req.app.state.aiohttp_client_wrapper = MagicMock(return_value=mock_client)
107+
108+
from vllm_router.services.request_service.request import proxy_multipart_request
109+
110+
form_data = FormData()
111+
form_data.add_field(
112+
"file", b"fake-audio", filename="test.wav", content_type="audio/wav"
113+
)
114+
form_data.add_field("model", "whisper-model")
115+
form_data.add_field("stream", "true")
116+
117+
resp = await proxy_multipart_request(
118+
form_data, "whisper-model", "/v1/audio/transcriptions", req, stream=True
119+
)
120+
121+
from fastapi.responses import StreamingResponse
122+
123+
assert isinstance(resp, StreamingResponse)
124+
assert resp.status_code == 200
125+
assert resp.media_type == "text/event-stream"
126+
127+
128+
@pytest.mark.asyncio
129+
async def test_transcription_non_streaming_returns_json_response(setup_mocks):
130+
req = _make_mock_request()
131+
132+
mock_backend_response = MagicMock()
133+
mock_backend_response.status = 200
134+
mock_backend_response.headers = {
135+
"content-type": "application/json",
136+
"x-request-id": "test-123",
137+
}
138+
mock_backend_response.json = AsyncMock(
139+
return_value={"text": "Hello world transcription"}
140+
)
141+
142+
mock_client = MagicMock()
143+
mock_client.post = AsyncMock(return_value=mock_backend_response)
144+
req.app.state.aiohttp_client_wrapper = MagicMock(return_value=mock_client)
145+
146+
from vllm_router.services.request_service.request import proxy_multipart_request
147+
148+
form_data = FormData()
149+
form_data.add_field(
150+
"file", b"fake-audio", filename="test.wav", content_type="audio/wav"
151+
)
152+
form_data.add_field("model", "whisper-model")
153+
154+
resp = await proxy_multipart_request(
155+
form_data, "whisper-model", "/v1/audio/transcriptions", req, stream=False
156+
)
157+
158+
from fastapi.responses import JSONResponse
159+
160+
assert isinstance(resp, JSONResponse)
161+
assert resp.status_code == 200
162+
assert resp.body == b'{"text":"Hello world transcription"}'
163+
req.app.state.request_stats_monitor.on_new_request.assert_called_once()
164+
req.app.state.request_stats_monitor.on_request_response.assert_called_once()
165+
req.app.state.request_stats_monitor.on_request_complete.assert_called_once()
166+
167+
168+
@pytest.mark.asyncio
169+
async def test_streaming_calls_stats_hooks(setup_mocks):
170+
req = _make_mock_request()
171+
172+
mock_backend_response = _make_backend_response(
173+
[b'data: {"text": "Hello"}\n\n', b'data: {"text": " World"}\n\n']
174+
)
175+
176+
mock_client = MagicMock()
177+
mock_client.post = AsyncMock(return_value=mock_backend_response)
178+
req.app.state.aiohttp_client_wrapper = MagicMock(return_value=mock_client)
179+
180+
from vllm_router.services.request_service.request import proxy_multipart_request
181+
182+
form_data = FormData()
183+
form_data.add_field(
184+
"file", b"fake-audio", filename="test.wav", content_type="audio/wav"
185+
)
186+
form_data.add_field("model", "whisper-model")
187+
form_data.add_field("stream", "true")
188+
189+
resp = await proxy_multipart_request(
190+
form_data, "whisper-model", "/v1/audio/transcriptions", req, stream=True
191+
)
192+
193+
req.app.state.request_stats_monitor.on_new_request.assert_called_once()
194+
195+
chunks = []
196+
async for chunk in resp.body_iterator:
197+
chunks.append(chunk)
198+
199+
req.app.state.request_stats_monitor.on_request_response.assert_called_once()
200+
req.app.state.request_stats_monitor.on_request_complete.assert_called_once()
201+
mock_backend_response.close.assert_called_once()
202+
203+
204+
@pytest.mark.asyncio
205+
async def test_streaming_preserves_content_type(setup_mocks):
206+
req = _make_mock_request()
207+
208+
mock_backend_response = _make_backend_response(
209+
[b'data: {"text": "Hello"}\n\n'], content_type="application/x-ndjson"
210+
)
211+
212+
mock_client = MagicMock()
213+
mock_client.post = AsyncMock(return_value=mock_backend_response)
214+
req.app.state.aiohttp_client_wrapper = MagicMock(return_value=mock_client)
215+
216+
from vllm_router.services.request_service.request import proxy_multipart_request
217+
218+
form_data = FormData()
219+
form_data.add_field(
220+
"file", b"fake-audio", filename="test.wav", content_type="audio/wav"
221+
)
222+
form_data.add_field("model", "whisper-model")
223+
form_data.add_field("stream", "true")
224+
225+
resp = await proxy_multipart_request(
226+
form_data, "whisper-model", "/v1/audio/transcriptions", req, stream=True
227+
)
228+
229+
assert resp.media_type == "application/x-ndjson"
230+
231+
232+
@pytest.mark.asyncio
233+
async def test_streaming_uses_aiohttp_multipart_boundary(setup_mocks):
234+
req = _make_mock_request()
235+
236+
mock_backend_response = _make_backend_response([b'data: {"text": "Hello"}\n\n'])
237+
238+
mock_client = MagicMock()
239+
mock_client.post = AsyncMock(return_value=mock_backend_response)
240+
req.app.state.aiohttp_client_wrapper = MagicMock(return_value=mock_client)
241+
242+
from vllm_router.services.request_service.request import proxy_multipart_request
243+
244+
form_data = FormData()
245+
form_data.add_field(
246+
"file", b"fake-audio", filename="test.wav", content_type="audio/wav"
247+
)
248+
form_data.add_field("model", "whisper-model")
249+
form_data.add_field("stream", "true")
250+
251+
await proxy_multipart_request(
252+
form_data, "whisper-model", "/v1/audio/transcriptions", req, stream=True
253+
)
254+
255+
call_args = mock_client.post.call_args
256+
assert call_args is not None
257+
kwargs = call_args[1] if call_args[1] else {}
258+
# Headers are forwarded (auth, request-id, etc.) but content-type must be absent
259+
# so aiohttp can generate the multipart/form-data boundary automatically.
260+
passed_headers = kwargs.get("headers") or {}
261+
assert "content-type" not in {k.lower() for k in passed_headers}
262+
263+
264+
@pytest.mark.asyncio
265+
async def test_streaming_closes_upstream_on_full_consumption(setup_mocks):
266+
req = _make_mock_request()
267+
268+
mock_backend_response = _make_backend_response(
269+
[b'data: {"text": "Hello"}\n\n', b'data: {"text": " World"}\n\n']
270+
)
271+
272+
mock_client = MagicMock()
273+
mock_client.post = AsyncMock(return_value=mock_backend_response)
274+
req.app.state.aiohttp_client_wrapper = MagicMock(return_value=mock_client)
275+
276+
from vllm_router.services.request_service.request import proxy_multipart_request
277+
278+
form_data = FormData()
279+
form_data.add_field(
280+
"file", b"fake-audio", filename="test.wav", content_type="audio/wav"
281+
)
282+
form_data.add_field("model", "whisper-model")
283+
form_data.add_field("stream", "true")
284+
285+
resp = await proxy_multipart_request(
286+
form_data, "whisper-model", "/v1/audio/transcriptions", req, stream=True
287+
)
288+
289+
chunks = []
290+
async for chunk in resp.body_iterator:
291+
chunks.append(chunk)
292+
293+
assert len(chunks) == 2
294+
mock_backend_response.close.assert_called_once()
295+
296+
297+
@pytest.mark.asyncio
298+
async def test_streaming_closes_upstream_on_consumer_abort(setup_mocks):
299+
"""Downstream client disconnects mid-stream: body_iterator.aclose() must
300+
run the finally block and close the upstream response."""
301+
req = _make_mock_request()
302+
303+
mock_backend_response = _make_backend_response(
304+
[
305+
b'data: {"text": "chunk1"}\n\n',
306+
b'data: {"text": "chunk2"}\n\n',
307+
b'data: {"text": "chunk3"}\n\n',
308+
]
309+
)
310+
311+
mock_client = MagicMock()
312+
mock_client.post = AsyncMock(return_value=mock_backend_response)
313+
req.app.state.aiohttp_client_wrapper = MagicMock(return_value=mock_client)
314+
315+
from vllm_router.services.request_service.request import proxy_multipart_request
316+
317+
form_data = FormData()
318+
form_data.add_field(
319+
"file", b"fake-audio", filename="test.wav", content_type="audio/wav"
320+
)
321+
form_data.add_field("model", "whisper-model")
322+
form_data.add_field("stream", "true")
323+
324+
resp = await proxy_multipart_request(
325+
form_data, "whisper-model", "/v1/audio/transcriptions", req, stream=True
326+
)
327+
328+
body_iter = resp.body_iterator
329+
first = await body_iter.__anext__()
330+
assert first == b'data: {"text": "chunk1"}\n\n'
331+
332+
await body_iter.aclose()
333+
334+
mock_backend_response.close.assert_called_once()
335+
req.app.state.request_stats_monitor.on_request_complete.assert_called_once()
336+
337+
338+
@pytest.mark.asyncio
339+
async def test_streaming_stats_on_connection_failure(setup_mocks):
340+
req = _make_mock_request()
341+
342+
import aiohttp
343+
344+
err = aiohttp.ClientConnectorError(
345+
connection_key=MagicMock(), os_error=OSError("connection refused")
346+
)
347+
348+
mock_client = MagicMock()
349+
mock_client.post = AsyncMock(side_effect=err)
350+
req.app.state.aiohttp_client_wrapper = MagicMock(return_value=mock_client)
351+
352+
from vllm_router.services.request_service.request import proxy_multipart_request
353+
354+
form_data = FormData()
355+
form_data.add_field(
356+
"file", b"fake-audio", filename="test.wav", content_type="audio/wav"
357+
)
358+
form_data.add_field("model", "whisper-model")
359+
form_data.add_field("stream", "true")
360+
361+
resp = await proxy_multipart_request(
362+
form_data, "whisper-model", "/v1/audio/transcriptions", req, stream=True
363+
)
364+
365+
from fastapi.responses import JSONResponse
366+
367+
assert isinstance(resp, JSONResponse)
368+
assert resp.status_code == 503
369+
req.app.state.request_stats_monitor.on_new_request.assert_called_once()
370+
req.app.state.request_stats_monitor.on_request_complete.assert_called_once()

0 commit comments

Comments
 (0)