|
| 1 | +import json |
| 2 | +from unittest.mock import MagicMock, patch |
| 3 | + |
| 4 | +import pytest |
| 5 | + |
| 6 | +from vllm_router.routers.routing_logic import RoundRobinRouter |
| 7 | +from vllm_router.utils import AliasConfig, SingletonABCMeta |
| 8 | + |
| 9 | + |
| 10 | +class FakeEndpointInfo: |
| 11 | + def __init__(self, url, model_names=None, sleep=False, Id=None): |
| 12 | + self.url = url |
| 13 | + self.model_names = model_names or ["deepseek-r1"] |
| 14 | + self.sleep = sleep |
| 15 | + self.Id = Id |
| 16 | + |
| 17 | + |
| 18 | +ENDPOINTS = [FakeEndpointInfo(url="http://engine1")] |
| 19 | + |
| 20 | +MOCK_HEADERS = MagicMock() |
| 21 | +MOCK_HEADERS.items.return_value = [("content-type", "text/event-stream")] |
| 22 | + |
| 23 | + |
| 24 | +@pytest.fixture(autouse=True) |
| 25 | +def cleanup_singletons(): |
| 26 | + yield |
| 27 | + for cls in list(SingletonABCMeta._instances.keys()): |
| 28 | + del SingletonABCMeta._instances[cls] |
| 29 | + |
| 30 | + |
| 31 | +def _make_service_discovery(aliases): |
| 32 | + sd = MagicMock() |
| 33 | + sd.get_endpoint_info.return_value = ENDPOINTS |
| 34 | + sd.aliases = aliases |
| 35 | + sd.has_ever_seen_model.return_value = True |
| 36 | + return sd |
| 37 | + |
| 38 | + |
| 39 | +def _make_request(body_dict, router): |
| 40 | + state = MagicMock() |
| 41 | + state.router = router |
| 42 | + state.engine_stats_scraper.get_engine_stats.return_value = {} |
| 43 | + state.request_stats_monitor.get_request_stats.return_value = {} |
| 44 | + state.otel_enabled = False |
| 45 | + state.semantic_cache_available = False |
| 46 | + state.callbacks = None |
| 47 | + state.external_provider_registry = None |
| 48 | + |
| 49 | + req = MagicMock() |
| 50 | + req.headers = {"content-type": "application/json"} |
| 51 | + req.query_params = {} |
| 52 | + req.method = "POST" |
| 53 | + req.url = "http://router/v1/chat/completions" |
| 54 | + req.app.state = state |
| 55 | + |
| 56 | + raw = json.dumps(body_dict).encode() |
| 57 | + |
| 58 | + async def body(): |
| 59 | + return raw |
| 60 | + |
| 61 | + req.body = body |
| 62 | + return req |
| 63 | + |
| 64 | + |
| 65 | +async def _run_routing_test( |
| 66 | + aliases, |
| 67 | + request_body, |
| 68 | + expect_model, |
| 69 | + expect_reasoning=None, |
| 70 | + expect_enable_thinking=None, |
| 71 | +): |
| 72 | + """Route a request through route_general_request and verify the forwarded body.""" |
| 73 | + router = RoundRobinRouter() |
| 74 | + setattr(router, "max_instance_failover_reroute_attempts", 0) |
| 75 | + req = _make_request(request_body, router) |
| 76 | + captured = {} |
| 77 | + |
| 78 | + async def fake_process(request, body, server_url, *a, **kw): |
| 79 | + captured["body"] = json.loads(body) |
| 80 | + yield MOCK_HEADERS, 200 |
| 81 | + yield b'{"id":"x"}' |
| 82 | + |
| 83 | + with ( |
| 84 | + patch( |
| 85 | + "vllm_router.services.request_service.request.get_service_discovery", |
| 86 | + return_value=_make_service_discovery(aliases), |
| 87 | + ), |
| 88 | + patch( |
| 89 | + "vllm_router.services.request_service.request.is_request_rewriter_initialized", |
| 90 | + return_value=False, |
| 91 | + ), |
| 92 | + patch( |
| 93 | + "vllm_router.services.request_service.request.process_request", |
| 94 | + side_effect=fake_process, |
| 95 | + ), |
| 96 | + ): |
| 97 | + from vllm_router.services.request_service.request import route_general_request |
| 98 | + |
| 99 | + resp = await route_general_request(req, "/v1/chat/completions", MagicMock()) |
| 100 | + |
| 101 | + assert resp.status_code == 200 |
| 102 | + assert captured["body"]["model"] == expect_model |
| 103 | + if expect_reasoning is not None: |
| 104 | + assert captured["body"]["reasoning_effort"] == expect_reasoning |
| 105 | + else: |
| 106 | + assert "reasoning_effort" not in captured["body"] |
| 107 | + if expect_enable_thinking is not None: |
| 108 | + assert ( |
| 109 | + captured["body"]["chat_template_kwargs"]["enable_thinking"] |
| 110 | + == expect_enable_thinking |
| 111 | + ) |
| 112 | + else: |
| 113 | + assert "chat_template_kwargs" not in captured["body"] |
| 114 | + |
| 115 | + |
| 116 | +_MESSAGES = [{"role": "user", "content": "hi"}] |
| 117 | + |
| 118 | + |
| 119 | +@pytest.mark.asyncio |
| 120 | +async def test_alias_injects_reasoning_effort(): |
| 121 | + """When alias has reasoning_effort and request doesn't, it should be injected.""" |
| 122 | + await _run_routing_test( |
| 123 | + aliases={ |
| 124 | + "reasoning-model": AliasConfig(model="deepseek-r1", reasoning_effort="high") |
| 125 | + }, |
| 126 | + request_body={ |
| 127 | + "model": "reasoning-model", |
| 128 | + "stream": False, |
| 129 | + "messages": _MESSAGES, |
| 130 | + }, |
| 131 | + expect_model="deepseek-r1", |
| 132 | + expect_reasoning="high", |
| 133 | + ) |
| 134 | + |
| 135 | + |
| 136 | +@pytest.mark.asyncio |
| 137 | +async def test_client_reasoning_effort_not_overwritten(): |
| 138 | + """When client already provides reasoning_effort, alias should NOT overwrite it.""" |
| 139 | + await _run_routing_test( |
| 140 | + aliases={ |
| 141 | + "reasoning-model": AliasConfig(model="deepseek-r1", reasoning_effort="high") |
| 142 | + }, |
| 143 | + request_body={ |
| 144 | + "model": "reasoning-model", |
| 145 | + "stream": False, |
| 146 | + "reasoning_effort": "low", |
| 147 | + "messages": _MESSAGES, |
| 148 | + }, |
| 149 | + expect_model="deepseek-r1", |
| 150 | + expect_reasoning="low", |
| 151 | + ) |
| 152 | + |
| 153 | + |
| 154 | +@pytest.mark.asyncio |
| 155 | +async def test_plain_alias_no_reasoning_effort(): |
| 156 | + """A plain alias (no reasoning_effort) should not inject reasoning_effort.""" |
| 157 | + await _run_routing_test( |
| 158 | + aliases={"short-name": AliasConfig(model="deepseek-r1")}, |
| 159 | + request_body={"model": "short-name", "stream": False, "messages": _MESSAGES}, |
| 160 | + expect_model="deepseek-r1", |
| 161 | + ) |
| 162 | + |
| 163 | + |
| 164 | +@pytest.mark.asyncio |
| 165 | +async def test_legacy_plain_string_alias(): |
| 166 | + """A plain-string alias value (from a custom ServiceDiscovery) must still work.""" |
| 167 | + await _run_routing_test( |
| 168 | + aliases={"short-name": "deepseek-r1"}, |
| 169 | + request_body={"model": "short-name", "stream": False, "messages": _MESSAGES}, |
| 170 | + expect_model="deepseek-r1", |
| 171 | + ) |
| 172 | + |
| 173 | + |
| 174 | +@pytest.mark.asyncio |
| 175 | +async def test_reasoning_effort_none_injects_enable_thinking_false(): |
| 176 | + """When reasoning_effort is 'none', chat_template_kwargs.enable_thinking should be False.""" |
| 177 | + await _run_routing_test( |
| 178 | + aliases={ |
| 179 | + "no-thinking": AliasConfig(model="deepseek-r1", reasoning_effort="none") |
| 180 | + }, |
| 181 | + request_body={ |
| 182 | + "model": "no-thinking", |
| 183 | + "stream": False, |
| 184 | + "messages": _MESSAGES, |
| 185 | + }, |
| 186 | + expect_model="deepseek-r1", |
| 187 | + expect_reasoning="none", |
| 188 | + expect_enable_thinking=False, |
| 189 | + ) |
| 190 | + |
| 191 | + |
| 192 | +@pytest.mark.asyncio |
| 193 | +async def test_reasoning_effort_high_no_enable_thinking(): |
| 194 | + """When reasoning_effort is not 'none', chat_template_kwargs should not be injected.""" |
| 195 | + await _run_routing_test( |
| 196 | + aliases={ |
| 197 | + "reasoning-model": AliasConfig(model="deepseek-r1", reasoning_effort="high") |
| 198 | + }, |
| 199 | + request_body={ |
| 200 | + "model": "reasoning-model", |
| 201 | + "stream": False, |
| 202 | + "messages": _MESSAGES, |
| 203 | + }, |
| 204 | + expect_model="deepseek-r1", |
| 205 | + expect_reasoning="high", |
| 206 | + ) |
0 commit comments