Files
jundot--omlx/tests/test_server_prefill_memory_handler.py
wehub-resource-sync e9a2f726c9
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
CI / test (3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:29:51 +08:00

259 lines
10 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""Verify PrefillMemoryExceededError maps to HTTP 400 in server.py.
Regression-arming test for the actual prefill-guard chain validated
end-to-end on 2026-05-15: the message string format matches what the
guard surfaces in production, so a refactor that changes either the
error body shape or the HTTP code will be caught here.
"""
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from omlx.exceptions import PrefillMemoryExceededError
def _build_test_app():
"""Build a minimal FastAPI app that re-uses the production handler."""
import omlx.server as srv
app = FastAPI()
app.add_exception_handler(
PrefillMemoryExceededError, srv.prefill_memory_exceeded_handler
)
@app.get("/v1/raise")
def raise_prefill_too_large():
raise PrefillMemoryExceededError(
message=(
"Prefill would require ~43.56 GB peak "
"(current 28.00 GB + KV+SDPA 15.56 GB) "
"but limit is 40.00 GB. "
"Reduce context length or increase --max-process-memory."
),
request_id="req-abc",
estimated_bytes=46_775_000_000,
limit_bytes=42_949_672_960,
)
@app.get("/health/raise")
def raise_prefill_too_large_health():
raise PrefillMemoryExceededError(
message="Prefill would require ~50 GB peak but limit is 40 GB.",
request_id="req-xyz",
)
return app
class TestPrefillMemoryHandler:
def test_returns_400(self):
with TestClient(_build_test_app()) as client:
resp = client.get("/v1/raise")
assert resp.status_code == 400
def test_api_route_uses_openai_error_body(self):
"""/v1/* routes get the OpenAI-style {"error": {"message": ...}} wrapper."""
with TestClient(_build_test_app()) as client:
resp = client.get("/v1/raise")
body = resp.json()
assert body["type"] == "error"
assert "error" in body
msg = body["error"]["message"]
# The guard's diagnostic format is part of the public contract — the
# CLI hint at the end tells the user exactly how to recover.
assert "Prefill would require" in msg
assert "KV+SDPA" in msg
assert "--max-process-memory" in msg
assert "Memory Guard to aggressive" in msg
assert "custom memory guard ceiling" in msg
assert body["error"]["code"] == "prefill_memory_exceeded"
assert body["error"]["omlx_code"] == "prefill_memory_exceeded"
def test_api_route_body_carries_estimated_and_limit_bytes(self):
"""Clients branch on the numeric ``estimated_bytes`` /
``limit_bytes`` fields rather than regex-matching the human
message (which is localized / format-prone). Regression for
the body-shape gap: prior to the fix on 2026-05-15 the handler
embedded these numbers only inside ``message`` and dropped the
structured fields, defeating the point of the typed exception
carrying them.
"""
with TestClient(_build_test_app()) as client:
resp = client.get("/v1/raise")
body = resp.json()
assert body["error"]["estimated_bytes"] == 46_775_000_000
assert body["error"]["limit_bytes"] == 42_949_672_960
def test_non_api_route_uses_plain_detail(self):
with TestClient(_build_test_app()) as client:
resp = client.get("/health/raise")
body = resp.json()
assert "detail" in body
assert "Prefill would require" in body["detail"]
assert body["omlx_code"] == "prefill_memory_exceeded"
class TestPostCommitPrefillMemorySurface:
@pytest.mark.asyncio
async def test_json_keepalive_emits_openai_error_body(self):
import json
import omlx.server as srv
class _Request:
async def is_disconnected(self):
return False
async def _raise_late():
raise PrefillMemoryExceededError(
message="Prefill context too large for available memory",
request_id="req-json",
estimated_bytes=123,
limit_bytes=100,
)
chunks = [
chunk
async for chunk in srv._with_json_keepalive(
_Request(), _raise_late(), disconnect_poll=0.001
)
]
body = json.loads("".join(chunks))
assert body["error"]["code"] == "prefill_memory_exceeded"
assert body["error"]["omlx_code"] == "prefill_memory_exceeded"
assert body["error"]["estimated_bytes"] == 123
assert body["error"]["limit_bytes"] == 100
@pytest.mark.asyncio
async def test_sse_keepalive_emits_openai_error_chunk(self):
import json
import omlx.server as srv
async def _gen():
raise PrefillMemoryExceededError(
message="Prefill context too large for available memory",
request_id="req-sse",
estimated_bytes=123,
limit_bytes=100,
)
yield ""
chunks = [
chunk
async for chunk in srv._with_sse_keepalive(_gen(), keepalive_chunk=None)
]
assert chunks[-1] == "data: [DONE]\n\n"
data = chunks[0].removeprefix("data: ").strip()
body = json.loads(data)
assert body["error"]["code"] == "prefill_memory_exceeded"
assert body["error"]["omlx_code"] == "prefill_memory_exceeded"
class TestResponsesEndpointReaches400:
"""End-to-end regression for ``/v1/responses``. The handler-shape tests
above use a synthetic ``/v1/raise`` route, which proves the handler
body but NOT the wiring of every prompt-bearing endpoint to the
preflight call. ``/v1/responses`` is the one route most-likely to
silently regress because it shares the StreamingResponse pattern
with ``/v1/chat/completions`` and reaches preflight via the same
code path. This test forces the preflight to raise and asserts
the route returns 400 instead of 200/500.
"""
def _make_app_with_failing_preflight(self):
"""Mount the real ``/v1/responses`` route with a mocked
engine_pool that returns an engine whose ``preflight_chat``
raises ``PrefillMemoryExceededError``. Hits the *production*
handler — not a synthesized stub — so a wiring regression is
caught.
"""
from unittest.mock import AsyncMock, MagicMock
import omlx.server as srv
# Build an engine mock whose preflight_chat raises. The
# production handler awaits this BEFORE constructing
# StreamingResponse, so the raise propagates to the
# exception handler and the route can still emit 400.
async def _raising_preflight(*args, **kwargs):
raise PrefillMemoryExceededError(
message=(
"Prefill would require ~50 GB peak "
"(current 30 GB + KV+SDPA 20 GB) but limit "
"is 40 GB. Reduce context length or "
"increase --max-process-memory."
),
request_id="req-responses",
estimated_bytes=53_687_091_200,
limit_bytes=42_949_672_960,
)
engine = MagicMock()
engine.preflight_chat = AsyncMock(side_effect=_raising_preflight)
engine.start = AsyncMock()
# The handler calls ``count_chat_tokens`` and feeds the result
# into ``validate_context_window``; without a real int the
# comparison ``num_prompt_tokens > max_context`` raises before
# preflight ever runs.
engine.count_chat_tokens = MagicMock(return_value=128)
async def _get_engine_for_model(model_id, *, lease=None):
return engine
# Override the engine resolver and disable auth so the test
# talks to the real route.
srv.app.dependency_overrides[srv.verify_api_key] = lambda: True
srv.get_engine_for_model = _get_engine_for_model # type: ignore[assignment]
return srv.app
def test_v1_responses_returns_400_when_preflight_rejects(self):
from unittest.mock import MagicMock, patch
import omlx.server as srv
original_get_engine = srv.get_engine_for_model
original_overrides = dict(srv.app.dependency_overrides)
original_engine_pool = srv._server_state.engine_pool
try:
app = self._make_app_with_failing_preflight()
# Mock engine_pool so get_engine_pool() doesn't raise 503.
# get_entry returns None so the handler's preserve_thinking
# short-circuit doesn't fire.
from unittest.mock import AsyncMock
fake_pool = MagicMock()
fake_pool.get_entry = MagicMock(return_value=None)
fake_pool.preload_pinned_models = AsyncMock()
fake_pool.check_ttl_expirations = AsyncMock()
fake_pool.shutdown = AsyncMock()
srv._server_state.engine_pool = fake_pool
with TestClient(app, raise_server_exceptions=False) as client:
with (
patch.object(srv, "resolve_model_id", lambda name: name),
patch.object(srv, "validate_context_window", lambda *a, **k: None),
):
resp = client.post(
"/v1/responses",
json={
"model": "test-model",
"input": "Hello, world.",
"stream": False,
},
)
assert (
resp.status_code == 400
), f"expected 400, got {resp.status_code}: {resp.text}"
body = resp.json()
assert "error" in body, body
assert "Prefill would require" in body["error"]["message"]
assert "--max-process-memory" in body["error"]["message"]
finally:
srv.get_engine_for_model = original_get_engine
srv._server_state.engine_pool = original_engine_pool
srv.app.dependency_overrides.clear()
srv.app.dependency_overrides.update(original_overrides)