Skip to content

Commit 7a9ed88

Browse files
yzhan1Yaoming Zhanruizhang0101
authored
Fix router auth for transcription proxy (#914)
Signed-off-by: Yaoming Zhan <yzhan@Mac.attlocal.net> Co-authored-by: Yaoming Zhan <yzhan@Mac.attlocal.net> Co-authored-by: Rui Zhang <51696593+ruizhang0101@users.noreply.github.com>
1 parent b66c80c commit 7a9ed88

2 files changed

Lines changed: 172 additions & 11 deletions

File tree

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import json
2+
from unittest.mock import AsyncMock, MagicMock, patch
3+
4+
import aiohttp
5+
import pytest
6+
7+
from vllm_router.services.request_service.request import (
8+
process_request,
9+
proxy_multipart_request,
10+
)
11+
from vllm_router.utils import SingletonABCMeta
12+
13+
14+
class EndpointInfo:
15+
def __init__(self, url, model_names=None, sleep=False):
16+
self.url = url
17+
self.model_names = model_names or ["whisper-model"]
18+
self.sleep = sleep
19+
20+
21+
@pytest.fixture(autouse=True)
22+
def cleanup_singletons():
23+
yield
24+
for cls in list(SingletonABCMeta._instances.keys()):
25+
del SingletonABCMeta._instances[cls]
26+
27+
28+
def _build_request(headers=None):
29+
state = MagicMock()
30+
state.otel_enabled = False
31+
state.semantic_cache_available = False
32+
state.callbacks = None
33+
state.request_stats_monitor.get_request_stats.return_value = {}
34+
state.engine_stats_scraper.get_engine_stats.return_value = {}
35+
state.router.route_request.return_value = "http://whisper-engine"
36+
37+
request = MagicMock()
38+
request.headers = headers or {}
39+
request.method = "POST"
40+
request.url = "http://router/v1/audio/transcriptions"
41+
request.query_params = {}
42+
request.app.state = state
43+
return request
44+
45+
46+
def _mock_backend_response(payload: bytes):
47+
mock_content = MagicMock()
48+
49+
async def iter_any():
50+
yield payload
51+
52+
mock_content.iter_any = iter_any
53+
54+
response = MagicMock()
55+
response.status = 200
56+
response.headers = MagicMock()
57+
response.headers.items.return_value = [("content-type", "application/json")]
58+
response.content = mock_content
59+
return response
60+
61+
62+
@pytest.mark.asyncio
63+
async def test_process_request_preserves_client_auth_and_normalizes_request_id():
64+
request = _build_request(
65+
{
66+
"content-type": "application/json",
67+
"authorization": "Bearer router-token",
68+
"x-request-id": "client-request-id",
69+
}
70+
)
71+
backend_response = _mock_backend_response(b'{"usage": {}}')
72+
73+
client_request = MagicMock()
74+
client_request.__aenter__ = AsyncMock(return_value=backend_response)
75+
client_request.__aexit__ = AsyncMock(return_value=False)
76+
77+
mock_client = MagicMock()
78+
mock_client.request = MagicMock(return_value=client_request)
79+
request.app.state.aiohttp_client_wrapper = MagicMock(return_value=mock_client)
80+
81+
request_body = json.dumps({"model": "whisper-model", "stream": False}).encode()
82+
83+
async for _ in process_request(
84+
request,
85+
request_body,
86+
"http://whisper-engine",
87+
"req-123",
88+
"/v1/chat/completions",
89+
MagicMock(),
90+
):
91+
pass
92+
93+
forwarded_headers = mock_client.request.call_args.kwargs["headers"]
94+
lowered_headers = {key.lower(): value for key, value in forwarded_headers.items()}
95+
96+
assert lowered_headers["authorization"] == "Bearer router-token"
97+
assert forwarded_headers["X-Request-Id"] == "req-123"
98+
assert "x-request-id" not in forwarded_headers
99+
assert lowered_headers["content-type"] == "application/json"
100+
101+
102+
@pytest.mark.asyncio
103+
async def test_proxy_multipart_request_preserves_client_auth_without_stale_boundary():
104+
request = _build_request(
105+
{
106+
"content-type": "multipart/form-data; boundary=router-boundary",
107+
"authorization": "Bearer router-token",
108+
"X-Request-Id": "client-request-id",
109+
"x-custom-header": "keep-me",
110+
}
111+
)
112+
backend_response = MagicMock()
113+
backend_response.status = 200
114+
backend_response.headers.items.return_value = [("content-type", "application/json")]
115+
backend_response.json = AsyncMock(return_value={"ok": True})
116+
117+
mock_client = MagicMock()
118+
mock_client.post = AsyncMock(return_value=backend_response)
119+
request.app.state.aiohttp_client_wrapper = MagicMock(return_value=mock_client)
120+
121+
service_discovery = MagicMock()
122+
service_discovery.get_endpoint_info.return_value = [EndpointInfo("http://engine")]
123+
124+
form_data = aiohttp.FormData()
125+
form_data.add_field("file", b"audio-bytes", filename="sample.wav")
126+
form_data.add_field("model", "whisper-model")
127+
128+
with patch(
129+
"vllm_router.services.request_service.request.get_service_discovery",
130+
return_value=service_discovery,
131+
):
132+
response = await proxy_multipart_request(
133+
form_data,
134+
"whisper-model",
135+
"/v1/audio/transcriptions",
136+
request,
137+
)
138+
139+
forwarded_headers = mock_client.post.await_args.kwargs["headers"]
140+
lowered_headers = {key.lower(): value for key, value in forwarded_headers.items()}
141+
142+
assert response.status_code == 200
143+
assert lowered_headers["authorization"] == "Bearer router-token"
144+
assert forwarded_headers["X-Request-Id"] == "client-request-id"
145+
assert "x-request-id" not in forwarded_headers
146+
assert forwarded_headers["x-custom-header"] == "keep-me"
147+
assert "content-type" not in lowered_headers

src/vllm_router/services/request_service/request.py

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,25 @@ async def stream_wrapper():
200200
)
201201

202202

203+
def _build_backend_request_headers(
204+
request: Request, request_id: str, *, include_content_type: bool = True
205+
) -> dict[str, str]:
206+
headers = {}
207+
for key, value in request.headers.items():
208+
lowered_key = key.lower()
209+
if lowered_key in _HOP_BY_HOP_HEADERS:
210+
continue
211+
if not include_content_type and lowered_key == "content-type":
212+
continue
213+
if lowered_key == "x-request-id":
214+
continue
215+
headers[key] = value
216+
217+
headers["X-Request-Id"] = request_id
218+
219+
return headers
220+
221+
203222
# TODO: (Brian) check if request is json beforehand
204223
async def process_request(
205224
request: Request,
@@ -270,9 +289,7 @@ async def process_request(
270289
span.set_attribute("vllm.is_streaming", is_streaming)
271290

272291
# Sanitize the request headers
273-
headers = {
274-
k: v for k, v in request.headers.items() if k.lower() not in _HOP_BY_HOP_HEADERS
275-
}
292+
headers = _build_backend_request_headers(request, request_id)
276293

277294
# Inject trace context into outgoing headers
278295
if tracing_active:
@@ -1201,14 +1218,11 @@ async def proxy_multipart_request(
12011218
try:
12021219
client = request.app.state.aiohttp_client_wrapper()
12031220

1204-
headers = None
1205-
if isinstance(form_data, bytes):
1206-
headers = {
1207-
k: v
1208-
for k, v in request.headers.items()
1209-
if k.lower() not in _HOP_BY_HOP_HEADERS
1210-
}
1211-
headers["X-Request-Id"] = request_id
1221+
headers = _build_backend_request_headers(
1222+
request,
1223+
request_id,
1224+
include_content_type=isinstance(form_data, bytes),
1225+
)
12121226

12131227
backend_response = await client.post(
12141228
f"{chosen_url}{endpoint}",

0 commit comments

Comments
 (0)