litellm/tests/unit/proxy/proxy_server/test_routes_queue.py
Mateo Wang d698106ed7
fix(router)!: schedule default_priority requests once and reject a malformed priority (#47498)
* fix(router): schedule default_priority requests once and reject a non-integer priority

A router configured with default_priority failed every request that carried no
priority with a 500 naming a missing 'priority' argument, and an explicit
priority re-entered acompletion and hit the same error. acompletion now resolves
the priority once (the request's integer, else the default, with 0 kept as a
real priority) and schedules through _schedule_factory directly, while
schedule_acompletion delegates to acompletion so the two paths share one queue
pass. A non-integer priority answers 400 naming the param, or is dropped under
drop_params, instead of leaking to the provider or crashing the scheduler.

* fix(router): honor router-level drop_params and schedule prompt-management models under a priority

* fix(router): drop a non-integer priority when the model's deployments set drop_params

* fix(router): resolve the drop_params deployment scope for team routes and deployment ids

* fix(router): read the request and router drop_params flags without a loop-assigned Final

* refactor(router): type the prompt-management wrapper's return and mock the provider in its test

* perf(router): consult drop_params for the priority only when it is not an integer

* test(integration): assert the new priority contract for non-integer and zero priorities

* docs(router): describe default_priority as the priority every request without one takes

* fix(router): parse default_priority once and apply it to text completions and the queue route

A digit string default is taken and any other non-integer default is ignored with a warning instead of failing every request. atext_completion and /queue/chat/completions resolve the priority the way acompletion does: a request without one takes the default, an integer queues once, and a non-integer answers 400 or takes the default under drop_params. Integration cells cover every default-priority surface on an owned two-instance rig.

* fix(router): type the priority dispatch so the lint budgets hold

* fix(router): read priority like the scheduler did, require a priority on the queue route, and keep litellm's drop_params order

* fix(router): drop a priority named in additional_drop_params and report the queued priority on streamed queue responses

* fix(router): serve deployment-id requests without waiting behind the queue and ignore a boolean default_priority

A request whose model is a deployment id now counts that deployment as healthy in the
scheduler, since routing by id already ignores cooldown. Before, the scheduler found no
healthy deployment for an id, so the request waited behind every entry ahead of it.

A boolean default_priority (true, or YAML off) is ignored at startup with a warning,
like any other value that is not a whole number.

The scheduler integration tests now share worker state for the defaulted pair, cool a
group through /v1/responses so no stale in-memory queue is left behind, and use "high"
instead of "1" for the non-integer cases. The concurrent burst test no longer asserts
that the queue drains, matching the explicit-priority burst test, since the scheduler's
read-modify-write queue can race across workers.

* test(router): keep the queue drain check as a BUG-skipped integration test

* fix(router): queue default_priority chat requests once instead of failing them

* test(router): open with the default_priority regression tests alone

* fix(router): honor deployment drop_params for a malformed priority on a request routed by provider model name
2026-10-20 08:00:57 +00:00

194 lines
7 KiB
Python

"""Behavior pins for ``proxy_server.py`` queue routes.
Pins (PR2):
- POST /queue/chat/completions
"""
from __future__ import annotations
from typing import Final
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from litellm import Router
from litellm.proxy import proxy_server
from .conftest import normalize # type: ignore[import-not-found]
HAPPY_RESPONSE = {
"id": "chatcmpl-queue",
"object": "chat.completion",
"created": 0,
"model": "gpt-4",
"choices": [
{
"index": 0,
"finish_reason": "stop",
"message": {"role": "assistant", "content": "queued reply"},
}
],
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
"priority": 0,
}
@pytest.fixture
def patched_queue(monkeypatch):
router = MagicMock()
router.split_request_priority = MagicMock(return_value=(0, {}))
router.schedule_acompletion = AsyncMock(return_value=dict(HAPPY_RESPONSE))
monkeypatch.setattr(proxy_server, "llm_router", router)
monkeypatch.setattr(
proxy_server,
"proxy_logging_obj",
MagicMock(post_call_failure_hook=AsyncMock()),
)
return router
@pytest.fixture
def queue_no_router(monkeypatch):
monkeypatch.setattr(proxy_server, "llm_router", None)
monkeypatch.setattr(
proxy_server,
"proxy_logging_obj",
MagicMock(post_call_failure_hook=AsyncMock()),
)
yield
def test_queue_chat_completions_happy(client, auth_as, patched_queue):
"""Pins ``POST /queue/chat/completions`` (happy)."""
payload = {
"model": "gpt-4",
"messages": [{"role": "user", "content": "hi"}],
"priority": 0,
}
with auth_as():
response = client.post("/queue/chat/completions", json=payload)
assert response.status_code == 200
assert normalize(response.json()) == {
"id": "<VOLATILE>",
"object": "chat.completion",
"created": "<VOLATILE>",
"model": "gpt-4",
"choices": [
{
"index": 0,
"finish_reason": "stop",
"message": {"role": "assistant", "content": "queued reply"},
}
],
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
"priority": 0,
}
def test_queue_chat_completions_no_router_error(client, auth_as, queue_no_router):
"""Pins ``POST /queue/chat/completions`` (error: no llm_router)."""
payload = {"model": "gpt-4", "messages": [{"role": "user", "content": "hi"}]}
with auth_as():
response = client.post("/queue/chat/completions", json=payload)
assert response.status_code == 500
assert len(response.content) > 0
def _queue_router(
monkeypatch: pytest.MonkeyPatch, default_priority: int | None, mock_response: str = "queued"
) -> Router:
router: Final = Router(
model_list=[
{
"model_name": "gpt-4",
"litellm_params": {"model": "openai/gpt-4", "api_key": "sk-queue-test", "mock_response": mock_response},
}
],
default_priority=default_priority,
)
monkeypatch.setattr(proxy_server, "llm_router", router)
monkeypatch.setattr(proxy_server, "proxy_logging_obj", MagicMock(post_call_failure_hook=AsyncMock()))
return router
@pytest.mark.parametrize(
("default_priority", "priority_fields", "expected_priority"),
[
(3, {"priority": "high", "drop_params": True}, 3),
(3, {"priority": "1"}, 1),
(None, {"priority": 1.0}, 1),
(3, {"priority": 1}, 1),
(3, {}, 3),
],
ids=("dropped-word-takes-default", "digit-string", "integral-float", "explicit", "absent-takes-default"),
)
def test_queue_chat_completions_reports_the_priority_the_router_queued_at(
client,
auth_as,
monkeypatch: pytest.MonkeyPatch,
default_priority: int | None,
priority_fields: dict[str, object],
expected_priority: int,
) -> None:
router: Final = _queue_router(monkeypatch, default_priority)
payload: Final = {"model": "gpt-4", "messages": [{"role": "user", "content": "hi"}], **priority_fields}
with patch.object(router.scheduler, "add_request", wraps=router.scheduler.add_request) as add_request:
with auth_as():
response: Final = client.post("/queue/chat/completions", json=payload)
assert response.status_code == 200, response.text
assert [call.kwargs["request"].priority for call in add_request.await_args_list] == [expected_priority]
assert response.headers.get("x-litellm-priority") == str(expected_priority)
@pytest.mark.parametrize(
("default_priority", "priority_fields", "message"),
[
(None, {}, "priority is required"),
(3, {"priority": "high"}, "priority must be an integer"),
(None, {"priority": "high", "drop_params": True}, "priority must be an integer, got str 'high'"),
],
ids=("no-priority-and-no-default", "malformed", "dropped-word-and-no-default"),
)
def test_queue_chat_completions_rejects_a_request_it_cannot_queue_as_an_invalid_request(
client,
auth_as,
monkeypatch: pytest.MonkeyPatch,
default_priority: int | None,
priority_fields: dict[str, object],
message: str,
) -> None:
router: Final = _queue_router(monkeypatch, default_priority)
payload: Final = {"model": "gpt-4", "messages": [{"role": "user", "content": "hi"}], **priority_fields}
with patch.object(router.scheduler, "add_request", wraps=router.scheduler.add_request) as add_request:
with auth_as():
response: Final = client.post("/queue/chat/completions", json=payload)
assert response.status_code == 400, response.text
error: Final = response.json()["error"]
assert (error["type"], error["param"]) == ("invalid_request_error", "priority")
assert message in error["message"]
assert add_request.await_count == 0
def test_streamed_queue_responses_report_the_priority_the_router_queued_at(
client, auth_as, monkeypatch: pytest.MonkeyPatch
) -> None:
_queue_router(monkeypatch, default_priority=3)
payload: Final = {"model": "gpt-4", "messages": [{"role": "user", "content": "hi"}], "priority": "1", "stream": True}
with auth_as():
response: Final = client.post("/queue/chat/completions", json=payload)
assert response.status_code == 200, response.text
assert response.headers.get("x-litellm-priority") == "1"
def test_a_provider_bad_request_on_the_queue_route_keeps_its_own_message(
client, auth_as, monkeypatch: pytest.MonkeyPatch
) -> None:
_queue_router(monkeypatch, default_priority=3, mock_response="litellm.ContextWindowExceededError")
payload: Final = {"model": "gpt-4", "messages": [{"role": "user", "content": "hi"}]}
with auth_as():
response: Final = client.post("/queue/chat/completions", json=payload)
assert response.status_code == 400, response.text
error: Final = response.json()["error"]
assert error["type"] == "invalid_request_error"
assert "context window" in error["message"].lower()
assert "Authentication Error" not in error["message"]