822 lines
31 KiB
Python
822 lines
31 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
|
"""API-server surface guards for the OpenAI Omni entrypoint.
|
|
|
|
Purpose
|
|
-------
|
|
Freeze the *observable* server contract so helper moves (P0.2), route
|
|
extraction (P0.3), and later endpoint-family PRs cannot silently change:
|
|
|
|
* which routes exist,
|
|
* how upstream routes are overridden at assembly time,
|
|
* unavailable / missing-handler envelopes,
|
|
* shared engine-error JSON fields,
|
|
* app-state keys wired by ``omni_init_app_state``.
|
|
|
|
CPU-only (fakes / TestClient). No checked-in OpenAPI golden JSON.
|
|
|
|
Common wrong changes these tests are meant to catch
|
|
----------------------------------------------------
|
|
* Route accidentally removed, renamed, method-changed, or double-registered
|
|
after a move (census / assembled-app checks).
|
|
* OpenAPI drops an Omni HTTP path while the router still has it (or the reverse).
|
|
* Assembly forgets to strip overridden upstream chat / models / profiler
|
|
routes, or also deletes unrelated upstream routes.
|
|
* Profiler / Omni router not mounted; storage not started; Omni engine
|
|
exception handlers not registered.
|
|
* Timestamp middleware stops stamping HTTP, or starts mutating WebSocket scopes.
|
|
* Missing-handler WS/HTTP responses change status, JSON shape, or wording
|
|
(clients treat these as capability probes).
|
|
* Engine error payloads lose ``request_id`` / ``error_stage_id``.
|
|
* Pure-diffusion vs multi-stage init drops an ``app.state`` key, or leaves a
|
|
handler as ``None`` while the route is still registered.
|
|
|
|
Not covered here (add elsewhere if needed)
|
|
------------------------------------------
|
|
* Deep per-endpoint success behavior (family UTs).
|
|
* Byte-identical helper bodies after a move (P0.2 AST integrity).
|
|
* Live GPU / speech byte-identity checks.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
from contextlib import asynccontextmanager
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
from fastapi import FastAPI, HTTPException
|
|
from fastapi.routing import APIRoute, APIWebSocketRoute
|
|
from fastapi.testclient import TestClient
|
|
from starlette.datastructures import State
|
|
from starlette.requests import Request
|
|
from starlette.websockets import WebSocketDisconnect
|
|
from vllm.v1.engine.exceptions import EngineDeadError, EngineGenerateError
|
|
|
|
from vllm_omni.entrypoints.openai import api_server
|
|
|
|
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
|
|
|
# FastAPI/Starlette auto-adds HEAD on every GET route. Including HEAD here
|
|
# would invent ("HEAD", path) entries for every GET and fail the current census.
|
|
# TODO: extend this set and the expected census if @router.head / @router.options appear.
|
|
_HTTP_METHODS = {"GET", "POST", "DELETE", "PUT", "PATCH"}
|
|
|
|
# Exact Omni router census on main. Update intentionally when routes change.
|
|
_EXPECTED_ROUTER_ROUTES = {
|
|
("POST", "/v1/chat/completions"),
|
|
("POST", "/v1/chat/completions/batch"),
|
|
("POST", "/v1/audio/speech"),
|
|
("POST", "/v1/audio/speech/batch"),
|
|
("POST", "/v1/audio/generate"),
|
|
("GET", "/v1/audio/voices"),
|
|
("POST", "/v1/audio/voices"),
|
|
("DELETE", "/v1/audio/voices/{name}"),
|
|
("WEBSOCKET", "/v1/audio/speech/stream"),
|
|
("WEBSOCKET", "/v1/video/chat/stream"),
|
|
("WEBSOCKET", "/v1/realtime/video"),
|
|
("WEBSOCKET", "/v1/realtime"),
|
|
("WEBSOCKET", "/v1/realtime/robot/openpi"),
|
|
("WEBSOCKET", "/v1/duplex"),
|
|
("GET", "/health"),
|
|
("GET", "/v1/models"),
|
|
("POST", "/v1/images/generations"),
|
|
("POST", "/v1/images/edits"),
|
|
("POST", "/v1/videos"),
|
|
("POST", "/v1/videos/sync"),
|
|
("GET", "/v1/videos"),
|
|
("GET", "/v1/videos/{video_id}"),
|
|
("DELETE", "/v1/videos/{video_id}"),
|
|
("GET", "/v1/videos/{video_id}/content"),
|
|
("POST", "/v1/omni/sleep"),
|
|
("POST", "/v1/omni/wakeup"),
|
|
}
|
|
_EXPECTED_PROFILER_ROUTES = {
|
|
("POST", "/start_profile"),
|
|
("POST", "/stop_profile"),
|
|
}
|
|
|
|
# App-state keys that must remain available across refactor phases.
|
|
# Presence is not enough: "not wired" is written as ``state.X = None``.
|
|
_DIFFUSION_APP_STATE_KEYS = {
|
|
"engine_client",
|
|
"log_stats",
|
|
"args",
|
|
"sleeping_stages",
|
|
"stage_configs",
|
|
"vllm_config",
|
|
"diffusion_engine",
|
|
"openai_serving_models",
|
|
"serving_tokenization",
|
|
"openai_serving_chat",
|
|
"openai_serving_chat_batch",
|
|
"openai_serving_speech",
|
|
"openai_serving_audio_generate",
|
|
"openai_serving_video",
|
|
"openai_streaming_video_output",
|
|
"openai_serving_duplex",
|
|
"openai_streaming_speech",
|
|
"openai_streaming_video",
|
|
"openai_serving_realtime_robot",
|
|
"enable_server_load_tracking",
|
|
"server_load_metrics",
|
|
}
|
|
_DIFFUSION_MUST_BE_NONE = {
|
|
"vllm_config",
|
|
"serving_tokenization",
|
|
"openai_serving_duplex",
|
|
"openai_streaming_speech",
|
|
"openai_streaming_video",
|
|
}
|
|
_DIFFUSION_MUST_BE_WIRED = _DIFFUSION_APP_STATE_KEYS - _DIFFUSION_MUST_BE_NONE
|
|
_MULTISTAGE_APP_STATE_KEYS = {
|
|
"engine_client",
|
|
"log_stats",
|
|
"args",
|
|
"sleeping_stages",
|
|
"stage_configs",
|
|
"vllm_config",
|
|
"openai_serving_models",
|
|
"serving_tokenization",
|
|
"online_renderer",
|
|
"openai_serving_chat",
|
|
"openai_serving_chat_batch",
|
|
"openai_serving_speech",
|
|
"openai_serving_audio_generate",
|
|
"openai_streaming_speech",
|
|
"openai_streaming_video",
|
|
"openai_serving_duplex",
|
|
"openai_serving_realtime",
|
|
"openai_serving_video",
|
|
"openai_serving_realtime_robot",
|
|
"enable_server_load_tracking",
|
|
"server_load_metrics",
|
|
}
|
|
_MULTISTAGE_MUST_BE_NONE = {
|
|
"openai_serving_duplex",
|
|
"openai_serving_realtime_robot",
|
|
}
|
|
_MULTISTAGE_MUST_BE_WIRED = _MULTISTAGE_APP_STATE_KEYS - _MULTISTAGE_MUST_BE_NONE
|
|
|
|
|
|
def _route_entries(routes) -> list[tuple[str, str]]:
|
|
entries: list[tuple[str, str]] = []
|
|
for route in routes:
|
|
if isinstance(route, APIRoute):
|
|
for method in sorted((route.methods or set()) & _HTTP_METHODS):
|
|
entries.append((method, route.path))
|
|
elif isinstance(route, APIWebSocketRoute):
|
|
entries.append(("WEBSOCKET", route.path))
|
|
return entries
|
|
|
|
|
|
def _route_manifest(routes) -> set[tuple[str, str]]:
|
|
return set(_route_entries(routes))
|
|
|
|
|
|
def _assert_unique_route_entries(routes) -> None:
|
|
entries = _route_entries(routes)
|
|
duplicates = sorted({item for item in entries if entries.count(item) > 1})
|
|
assert not duplicates, f"duplicate route registrations: {duplicates}"
|
|
|
|
|
|
def _matching_http_routes(routes, method: str, path: str) -> list[APIRoute]:
|
|
return [
|
|
route
|
|
for route in routes
|
|
if isinstance(route, APIRoute) and route.path == path and method in (route.methods or set())
|
|
]
|
|
|
|
|
|
def _single_http_route(routes, method: str, path: str) -> APIRoute:
|
|
matches = _matching_http_routes(routes, method, path)
|
|
assert len(matches) == 1, f"expected exactly one {method} {path}, got {len(matches)}"
|
|
return matches[0]
|
|
|
|
|
|
def _assert_app_state_snapshot(
|
|
state,
|
|
*,
|
|
expected_keys: set[str],
|
|
must_be_wired: set[str],
|
|
must_be_none: set[str],
|
|
) -> None:
|
|
assert must_be_wired.isdisjoint(must_be_none)
|
|
assert must_be_wired | must_be_none <= expected_keys
|
|
present = {key for key in expected_keys if hasattr(state, key)}
|
|
assert present == expected_keys
|
|
not_wired = sorted(key for key in must_be_wired if getattr(state, key) is None)
|
|
assert not not_wired, f"app.state keys registered but not wired: {not_wired}"
|
|
unexpectedly_set = sorted(key for key in must_be_none if getattr(state, key) is not None)
|
|
assert not unexpectedly_set, f"app.state keys expected to stay None: {unexpectedly_set}"
|
|
|
|
|
|
def _minimal_args(**overrides) -> SimpleNamespace:
|
|
args = SimpleNamespace(
|
|
tool_parser_plugin=None,
|
|
reasoning_parser_plugin=None,
|
|
reasoning_parser=None,
|
|
structured_outputs_config=SimpleNamespace(reasoning_parser=None),
|
|
enable_ssl_refresh=False,
|
|
host="127.0.0.1",
|
|
port=8000,
|
|
uvicorn_log_level="info",
|
|
disable_uvicorn_access_log=True,
|
|
ssl_keyfile=None,
|
|
ssl_certfile=None,
|
|
ssl_ca_certs=None,
|
|
ssl_cert_reqs=0,
|
|
ssl_ciphers=None,
|
|
h11_max_incomplete_event_size=None,
|
|
h11_max_header_count=None,
|
|
log_config_file=None,
|
|
disable_access_log_for_endpoints=None,
|
|
disable_log_stats=True,
|
|
enable_log_requests=False,
|
|
max_log_len=None,
|
|
served_model_name=None,
|
|
model="demo-model",
|
|
enable_server_load_tracking=False,
|
|
log_error_stack=False,
|
|
chat_template=None,
|
|
chat_template_content_format="auto",
|
|
trust_request_chat_template=False,
|
|
tool_server=None,
|
|
lora_modules=None,
|
|
response_role="assistant",
|
|
default_chat_template_kwargs=None,
|
|
return_tokens_as_token_ids=False,
|
|
enable_auto_tool_choice=False,
|
|
exclude_tools_when_tool_choice_none=False,
|
|
tool_call_parser=None,
|
|
enable_prompt_tokens_details=False,
|
|
enable_force_include_usage=False,
|
|
enable_log_outputs=False,
|
|
enable_log_deltas=False,
|
|
tokens_only=False,
|
|
shutdown_timeout=0,
|
|
stage_configs_path=None,
|
|
deploy_config=None,
|
|
)
|
|
for key, value in overrides.items():
|
|
setattr(args, key, value)
|
|
return args
|
|
|
|
|
|
def _request_for(app: FastAPI, *, method: str = "GET", path: str = "/") -> Request:
|
|
return Request(
|
|
{
|
|
"type": "http",
|
|
"method": method,
|
|
"path": path,
|
|
"headers": [],
|
|
"app": app,
|
|
"state": {},
|
|
}
|
|
)
|
|
|
|
|
|
class _FakeSocket:
|
|
def __init__(self) -> None:
|
|
self.closed = False
|
|
|
|
def close(self) -> None:
|
|
self.closed = True
|
|
|
|
|
|
class _FakeEngineClient:
|
|
def __init__(self, *, stage_configs=None, endpoint_restrictions=None, vllm_config=None) -> None:
|
|
self.stage_configs = stage_configs if stage_configs is not None else []
|
|
self.endpoint_restrictions = endpoint_restrictions if endpoint_restrictions is not None else {}
|
|
self.model_config = SimpleNamespace()
|
|
self.input_processor = object()
|
|
self.renderer = object()
|
|
self.errored = False
|
|
self.vllm_config = vllm_config
|
|
|
|
async def get_vllm_config(self):
|
|
return self.vllm_config
|
|
|
|
async def get_supported_tasks(self) -> tuple[str, ...]:
|
|
return ("generate",)
|
|
|
|
async def get_tokenizer(self):
|
|
return SimpleNamespace(chat_template="dummy")
|
|
|
|
async def check_health(self) -> None:
|
|
return None
|
|
|
|
|
|
def _marker(name: str):
|
|
return SimpleNamespace(name=name)
|
|
|
|
|
|
def test_router_route_manifest_is_explicit_and_complete() -> None:
|
|
"""Lock the Omni + profiler router census.
|
|
|
|
Fails if a route is added/removed/renamed, a method changes, or a
|
|
WebSocket path disappears during helper/router moves. Also fails if the
|
|
same public route is registered more than once, which a set manifest alone
|
|
would collapse.
|
|
"""
|
|
# TODO(P0.3): retarget this object-level census when routes move onto family
|
|
# routers. The assembled-app census is the client-facing lock that must survive.
|
|
_assert_unique_route_entries(api_server.router.routes)
|
|
_assert_unique_route_entries(api_server.profiler_router.routes)
|
|
assert _route_manifest(api_server.router.routes) == _EXPECTED_ROUTER_ROUTES
|
|
assert _route_manifest(api_server.profiler_router.routes) == _EXPECTED_PROFILER_ROUTES
|
|
|
|
|
|
def test_router_openapi_paths_cover_http_manifest() -> None:
|
|
"""Lock that OpenAPI advertises every HTTP census route/method.
|
|
|
|
Fails if a decorator still registers a route but OpenAPI loses the path
|
|
(or method), e.g. after moving route bodies / response models.
|
|
"""
|
|
app = FastAPI()
|
|
app.include_router(api_server.router)
|
|
app.include_router(api_server.profiler_router)
|
|
paths = app.openapi().get("paths", {})
|
|
|
|
http_routes = {
|
|
(method, path) for method, path in _EXPECTED_ROUTER_ROUTES | _EXPECTED_PROFILER_ROUTES if method != "WEBSOCKET"
|
|
}
|
|
for method, path in sorted(http_routes):
|
|
assert path in paths, f"missing OpenAPI path {path}"
|
|
assert method.lower() in paths[path], f"missing OpenAPI method {method} {path}"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_api_server_assembly_replaces_upstream_routes_and_mounts_omni_router(monkeypatch) -> None:
|
|
"""Lock worker assembly: override, mount, storage, handlers, full census.
|
|
|
|
Fails if assembly stops replacing upstream chat/batch/models/profiler,
|
|
deletes unrelated upstream routes, forgets Omni/profiler mounts, skips
|
|
storage start, or drops Omni ``EngineDeadError`` / ``EngineGenerateError``
|
|
handlers.
|
|
"""
|
|
captured: dict[str, object] = {}
|
|
|
|
def fake_build_openai_app(args, supported_tasks):
|
|
app = FastAPI()
|
|
|
|
@app.post("/v1/chat/completions")
|
|
async def upstream_chat():
|
|
return {"owner": "upstream"}
|
|
|
|
@app.post("/v1/chat/completions/batch")
|
|
async def upstream_batch():
|
|
return {"owner": "upstream"}
|
|
|
|
@app.get("/v1/models")
|
|
async def upstream_models():
|
|
return {"owner": "upstream"}
|
|
|
|
@app.post("/start_profile")
|
|
async def upstream_start_profile():
|
|
return {"owner": "upstream"}
|
|
|
|
@app.post("/stop_profile")
|
|
async def upstream_stop_profile():
|
|
return {"owner": "upstream"}
|
|
|
|
@app.get("/v1/upstream-only")
|
|
async def upstream_only():
|
|
return {"owner": "upstream"}
|
|
|
|
captured["supported_tasks"] = supported_tasks
|
|
return app
|
|
|
|
@asynccontextmanager
|
|
async def fake_build_async_omni(*_args, **_kwargs):
|
|
yield _FakeEngineClient(
|
|
stage_configs=[{"engine_args": {"profiler_config": {"profiler": "torch"}}}],
|
|
vllm_config=SimpleNamespace(parallel_config=SimpleNamespace(_api_process_rank=0)),
|
|
)
|
|
|
|
async def fake_omni_init_app_state(engine_client, state, args):
|
|
state.engine_client = engine_client
|
|
state.initialized_by_omni = True
|
|
|
|
async def fake_storage_start():
|
|
captured["storage_started"] = True
|
|
|
|
async def fake_serve_http(app, **_kwargs):
|
|
captured["served_app"] = app
|
|
task = asyncio.create_task(asyncio.sleep(0))
|
|
await asyncio.sleep(0)
|
|
return task
|
|
|
|
monkeypatch.setattr(api_server, "build_openai_app", fake_build_openai_app)
|
|
monkeypatch.setattr(api_server, "build_async_omni", fake_build_async_omni)
|
|
monkeypatch.setattr(api_server, "omni_init_app_state", fake_omni_init_app_state)
|
|
monkeypatch.setattr(
|
|
api_server,
|
|
"shutdown_unsupported_routes",
|
|
lambda app, restrictions: captured.setdefault("restrictions", restrictions),
|
|
)
|
|
monkeypatch.setattr(api_server, "STORAGE_MANAGER", SimpleNamespace(start=fake_storage_start))
|
|
monkeypatch.setattr(api_server, "serve_http", fake_serve_http)
|
|
|
|
sock = _FakeSocket()
|
|
await api_server.omni_run_server_worker("127.0.0.1:8000", sock, _minimal_args())
|
|
|
|
served_wrapper = captured["served_app"]
|
|
# Timestamp middleware wraps the FastAPI app.
|
|
assert hasattr(served_wrapper, "_inner")
|
|
served_app = served_wrapper._inner
|
|
routes = served_app.routes
|
|
_assert_unique_route_entries(routes)
|
|
manifest = _route_manifest(routes)
|
|
|
|
assert captured["supported_tasks"] == ("generate",)
|
|
assert captured["storage_started"] is True
|
|
assert captured["restrictions"] == {}
|
|
assert sock.closed is True
|
|
assert served_app.state.initialized_by_omni is True
|
|
|
|
# Upstream override: unrelated route kept; Omni owns overridden pairs once.
|
|
# Endpoint identity ensures the surviving route is the Omni handler, not
|
|
# the fake upstream handler with the same method/path.
|
|
assert ("GET", "/v1/upstream-only") in manifest
|
|
assert _single_http_route(routes, "POST", "/v1/chat/completions").endpoint is api_server.create_chat_completion
|
|
assert (
|
|
_single_http_route(routes, "POST", "/v1/chat/completions/batch").endpoint
|
|
is api_server.create_batch_chat_completion
|
|
)
|
|
assert _single_http_route(routes, "GET", "/v1/models").endpoint is api_server.show_available_models
|
|
assert _single_http_route(routes, "POST", "/start_profile").endpoint is api_server.start_profile
|
|
assert _single_http_route(routes, "POST", "/stop_profile").endpoint is api_server.stop_profile
|
|
|
|
# Assembled app includes Omni router HTTP/WS surface + profiler.
|
|
for item in _EXPECTED_ROUTER_ROUTES:
|
|
assert item in manifest, f"assembled app missing {item}"
|
|
assert ("POST", "/start_profile") in manifest
|
|
assert ("POST", "/stop_profile") in manifest
|
|
|
|
assert served_app.exception_handlers[EngineGenerateError]
|
|
assert served_app.exception_handlers[EngineDeadError]
|
|
|
|
# Light OpenAPI check on the assembled FastAPI app (not the ASGI wrapper).
|
|
openapi_paths = served_app.openapi().get("paths", {})
|
|
assert "/v1/chat/completions" in openapi_paths
|
|
assert "/v1/models" in openapi_paths
|
|
assert "/health" in openapi_paths
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_timestamp_middleware_stamps_http_and_passes_websocket(monkeypatch) -> None:
|
|
"""Lock outermost timestamp middleware behavior.
|
|
|
|
Fails if HTTP requests lose ``request_timestamp``, or WebSocket scopes
|
|
start getting stamped / mutated incorrectly.
|
|
"""
|
|
captured: dict[str, object] = {}
|
|
|
|
def fake_build_openai_app(args, supported_tasks):
|
|
return FastAPI()
|
|
|
|
@asynccontextmanager
|
|
async def fake_build_async_omni(*_args, **_kwargs):
|
|
yield _FakeEngineClient()
|
|
|
|
async def fake_omni_init_app_state(engine_client, state, args):
|
|
state.engine_client = engine_client
|
|
|
|
async def fake_serve_http(app, **_kwargs):
|
|
captured["served_app"] = app
|
|
task = asyncio.create_task(asyncio.sleep(0))
|
|
await asyncio.sleep(0)
|
|
return task
|
|
|
|
monkeypatch.setattr(api_server, "build_openai_app", fake_build_openai_app)
|
|
monkeypatch.setattr(api_server, "build_async_omni", fake_build_async_omni)
|
|
monkeypatch.setattr(api_server, "omni_init_app_state", fake_omni_init_app_state)
|
|
monkeypatch.setattr(api_server, "shutdown_unsupported_routes", lambda *_a, **_k: None)
|
|
monkeypatch.setattr(api_server, "STORAGE_MANAGER", SimpleNamespace(start=lambda: asyncio.sleep(0)))
|
|
monkeypatch.setattr(api_server, "serve_http", fake_serve_http)
|
|
|
|
await api_server.omni_run_server_worker("127.0.0.1:8000", _FakeSocket(), _minimal_args())
|
|
middleware = captured["served_app"]
|
|
|
|
http_scope = {"type": "http", "state": {}}
|
|
ws_scope = {"type": "websocket"}
|
|
|
|
async def _receive():
|
|
return {"type": "http.disconnect"}
|
|
|
|
async def _send(_message):
|
|
return None
|
|
|
|
called = {"http": False, "websocket": False}
|
|
|
|
async def inner(scope, receive, send):
|
|
called[scope["type"]] = True
|
|
|
|
middleware._inner = inner
|
|
await middleware(http_scope, _receive, _send)
|
|
await middleware(ws_scope, _receive, _send)
|
|
|
|
assert called["http"] is True
|
|
assert called["websocket"] is True
|
|
assert "request_timestamp" in http_scope["state"]
|
|
assert isinstance(http_scope["state"]["request_timestamp"], float)
|
|
assert "request_timestamp" not in ws_scope.get("state", {})
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("path", "payload"),
|
|
[
|
|
("/v1/audio/speech/stream", {"type": "error", "message": "Streaming speech is not available"}),
|
|
("/v1/video/chat/stream", {"type": "error", "message": "Streaming video chat is not available"}),
|
|
("/v1/realtime/video", {"type": "error", "message": "Streaming video generation is not available"}),
|
|
("/v1/realtime", {"type": "error", "error": "Realtime API is not available", "code": "unsupported"}),
|
|
(
|
|
"/v1/realtime/robot/openpi",
|
|
{"type": "error", "error": "Robot policy not available", "code": "unsupported"},
|
|
),
|
|
("/v1/duplex", {"type": "error", "error": "Duplex API is not available", "code": "unsupported"}),
|
|
],
|
|
)
|
|
def test_websocket_routes_emit_stable_unavailable_frames_and_close(path: str, payload: dict[str, str]) -> None:
|
|
"""Lock missing-handler WebSocket capability probes.
|
|
|
|
Fails if unavailable JSON changes (keys/wording/code) or the socket stays
|
|
open after the error frame — common when WS wrappers move between modules.
|
|
"""
|
|
app = FastAPI()
|
|
app.include_router(api_server.router)
|
|
|
|
with TestClient(app) as client:
|
|
with client.websocket_connect(path) as websocket:
|
|
assert websocket.receive_json() == payload
|
|
with pytest.raises(WebSocketDisconnect):
|
|
websocket.receive_text()
|
|
|
|
|
|
def test_health_without_engine_returns_stable_unhealthy_response() -> None:
|
|
"""Lock ``/health`` with no engine initialized.
|
|
|
|
Fails if status drifts from 503 or the unhealthy payload shape/reason
|
|
string changes during health-route extraction.
|
|
"""
|
|
app = FastAPI()
|
|
response = asyncio.run(api_server.health(_request_for(app, path="/health")))
|
|
|
|
assert response.status_code == 503
|
|
assert json.loads(response.body) == {
|
|
"status": "unhealthy",
|
|
"reason": "No engine initialized",
|
|
}
|
|
|
|
|
|
def test_models_without_handler_returns_empty_openai_list() -> None:
|
|
"""Lock ``/v1/models`` when ``openai_serving_models`` is unset.
|
|
|
|
Fails if the empty listing becomes an error, or the OpenAI list envelope
|
|
changes (``object`` / ``data``).
|
|
"""
|
|
app = FastAPI()
|
|
response = asyncio.run(api_server.show_available_models(_request_for(app, path="/v1/models")))
|
|
|
|
assert response.status_code == 200
|
|
assert json.loads(response.body) == {"object": "list", "data": []}
|
|
|
|
|
|
def test_images_generation_without_engine_preserves_service_unavailable_error() -> None:
|
|
"""Lock images generation when the multi-stage engine is missing.
|
|
|
|
Fails if the 503 detail string changes — clients/tests use it as a
|
|
capability signal when bootstrap failed.
|
|
"""
|
|
app = FastAPI()
|
|
raw_request = _request_for(app, method="POST", path="/v1/images/generations")
|
|
request = api_server.ImageGenerationRequest(prompt="a cat", model="demo-model")
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
asyncio.run(api_server.generate_images(request, raw_request))
|
|
|
|
assert exc_info.value.status_code == 503
|
|
assert exc_info.value.detail == "Multi-stage engine not initialized. Start server with a multi-stage omni model."
|
|
|
|
|
|
def test_images_generation_without_multistage_chat_handler_preserves_unavailable_error(monkeypatch) -> None:
|
|
"""Lock images generation when chat serving was not wired.
|
|
|
|
Fails if multi-stage images stop requiring ``openai_serving_chat``, or the
|
|
503 detail changes after images/chat ownership splits.
|
|
"""
|
|
app = FastAPI()
|
|
app.state.engine_client = SimpleNamespace(stage_configs=[object(), object()])
|
|
app.state.stage_configs = app.state.engine_client.stage_configs
|
|
app.state.openai_serving_models = SimpleNamespace(base_model_paths=[SimpleNamespace(name="demo-model")])
|
|
app.state.openai_serving_chat = None
|
|
app.state.args = SimpleNamespace(max_generated_image_size=None)
|
|
|
|
raw_request = _request_for(app, method="POST", path="/v1/images/generations")
|
|
request = api_server.ImageGenerationRequest(prompt="a cat", model="demo-model")
|
|
monkeypatch.setattr(api_server, "get_stage_type", lambda _stage_cfg: "diffusion")
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
asyncio.run(api_server.generate_images(request, raw_request))
|
|
|
|
assert exc_info.value.status_code == 503
|
|
assert exc_info.value.detail == "openai_serving_chat is not initialized for multi-stage image generation."
|
|
|
|
|
|
def test_speech_without_handler_preserves_not_found_http_error() -> None:
|
|
"""Lock speech HTTP when ``openai_serving_speech`` is unset.
|
|
|
|
Fails if missing speech becomes a different status/detail after TTS route
|
|
wrappers move out of ``api_server``.
|
|
"""
|
|
app = FastAPI()
|
|
app.state.openai_serving_speech = None
|
|
raw_request = _request_for(app, method="POST", path="/v1/audio/speech")
|
|
request = api_server.OpenAICreateSpeechRequest(input="hello", model="demo-model", voice="alloy")
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
asyncio.run(api_server.create_speech(request, raw_request))
|
|
|
|
assert exc_info.value.status_code == 404
|
|
assert exc_info.value.detail == "The model does not support Speech API"
|
|
|
|
|
|
def test_engine_error_json_response_includes_request_and_stage_fields(monkeypatch) -> None:
|
|
"""Lock shared ``EngineGenerateError`` JSON fields used by Omni handlers.
|
|
|
|
Fails if ``request_id`` / ``error_stage_id`` are dropped when error helpers
|
|
move (P0.2) or exception-handler wiring changes.
|
|
|
|
Exercises the envelope through the registered handler, not the private
|
|
``_create_engine_error_json_response`` helper, so relocating that helper
|
|
in P0.2 is not a false red.
|
|
"""
|
|
app = FastAPI()
|
|
# TODO(P0.2): retarget this call if ``_register_omni_exception_handlers``
|
|
# leaves api_server (red here is a rename, not a surface regression).
|
|
api_server._register_omni_exception_handlers(app)
|
|
app.state.engine_client = SimpleNamespace(errored=True, engine=SimpleNamespace(is_alive=lambda: False))
|
|
app.state.server = object()
|
|
app.state.args = SimpleNamespace(log_error_stack=False)
|
|
|
|
req = _request_for(app, method="POST", path="/v1/chat/completions")
|
|
req.state.request_metadata = SimpleNamespace(request_id="req-123")
|
|
|
|
monkeypatch.setattr(api_server, "terminate_if_errored", lambda **_kwargs: None)
|
|
|
|
exc = EngineGenerateError("boom")
|
|
exc.error_stage_id = 2 # type: ignore[attr-defined]
|
|
handler = app.exception_handlers[EngineGenerateError]
|
|
response = asyncio.run(handler(req, exc))
|
|
payload = json.loads(response.body)
|
|
|
|
assert "error" in payload
|
|
assert payload["error"]["request_id"] == "req-123"
|
|
assert payload["error"]["error_stage_id"] == 2
|
|
assert response.status_code >= 400
|
|
|
|
|
|
def test_engine_dead_error_handler_registered_returns_json(monkeypatch) -> None:
|
|
"""Lock Omni ``EngineDeadError`` handler registration + JSON envelope.
|
|
|
|
Fails if registration is skipped or the handler stops returning OpenAI-style
|
|
JSON with ``request_id``.
|
|
"""
|
|
app = FastAPI()
|
|
# TODO(P0.2): retarget this call if ``_register_omni_exception_handlers``
|
|
# leaves api_server (red here is a rename, not a surface regression).
|
|
api_server._register_omni_exception_handlers(app)
|
|
app.state.engine_client = SimpleNamespace(errored=True, engine=SimpleNamespace(is_alive=lambda: False))
|
|
app.state.server = object()
|
|
app.state.args = SimpleNamespace(log_error_stack=False)
|
|
|
|
monkeypatch.setattr(api_server, "terminate_if_errored", lambda **_kwargs: None)
|
|
|
|
handler = app.exception_handlers[EngineDeadError]
|
|
req = _request_for(app)
|
|
req.state.request_metadata = SimpleNamespace(request_id="dead-1")
|
|
response = asyncio.run(handler(req, EngineDeadError()))
|
|
payload = json.loads(response.body)
|
|
assert payload["error"]["request_id"] == "dead-1"
|
|
assert response.status_code >= 400
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pure_diffusion_app_state_key_snapshot(monkeypatch) -> None:
|
|
"""Lock pure-diffusion ``app.state`` keys after init, including live vs None.
|
|
|
|
Fails if bootstrap drops keys route owners still read (e.g. video/speech/
|
|
models), wires a handler that must stay None, or leaves a required
|
|
handler as None.
|
|
"""
|
|
stage = SimpleNamespace(engine_args={})
|
|
engine = _FakeEngineClient(stage_configs=[stage])
|
|
|
|
def _for_diffusion_factory(label: str):
|
|
@classmethod
|
|
def _factory(cls, *args, **kwargs):
|
|
return _marker(label)
|
|
|
|
return _factory
|
|
|
|
monkeypatch.setattr(api_server, "get_stage_type", lambda _cfg: "diffusion")
|
|
monkeypatch.setattr(api_server.OmniOpenAIServingChat, "for_diffusion", _for_diffusion_factory("chat"))
|
|
monkeypatch.setattr(api_server.OmniOpenAIServingChatBatch, "for_diffusion", _for_diffusion_factory("chat_batch"))
|
|
monkeypatch.setattr(
|
|
api_server.OmniOpenAIServingAudioGenerate,
|
|
"for_diffusion",
|
|
_for_diffusion_factory("audio_generate"),
|
|
)
|
|
monkeypatch.setattr(api_server.OmniOpenAIServingVideo, "for_diffusion", _for_diffusion_factory("video"))
|
|
monkeypatch.setattr(api_server.OmniStreamingVideoOutputHandler, "__init__", lambda self, *a, **k: None)
|
|
monkeypatch.setattr(api_server.OmniOpenAIServingSpeech, "for_diffusion", _for_diffusion_factory("speech"))
|
|
monkeypatch.setattr(
|
|
api_server.ServingRealtimeRobotOpenPI,
|
|
"create_policy_server",
|
|
classmethod(lambda cls, *a, **k: _marker("openpi")),
|
|
)
|
|
|
|
state = State()
|
|
await api_server.omni_init_app_state(engine, state, _minimal_args())
|
|
|
|
_assert_app_state_snapshot(
|
|
state,
|
|
expected_keys=_DIFFUSION_APP_STATE_KEYS,
|
|
must_be_wired=_DIFFUSION_MUST_BE_WIRED,
|
|
must_be_none=_DIFFUSION_MUST_BE_NONE,
|
|
)
|
|
assert state.diffusion_engine is engine
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_multistage_app_state_key_snapshot(monkeypatch) -> None:
|
|
"""Lock multi-stage ``app.state`` keys after init, including live vs None.
|
|
|
|
Fails if chat/speech/video/realtime/tokenization keys disappear or are
|
|
left as None after init refactors — classic “route still registered,
|
|
handler never wired” failure mode.
|
|
"""
|
|
engine = _FakeEngineClient(
|
|
stage_configs=[object(), object()],
|
|
vllm_config=SimpleNamespace(
|
|
lora_config=None,
|
|
model_config=SimpleNamespace(),
|
|
parallel_config=SimpleNamespace(_api_process_rank=0),
|
|
),
|
|
)
|
|
|
|
class _FakeModels:
|
|
def __init__(self, *args, **kwargs):
|
|
self.base_model_paths = kwargs.get("base_model_paths") or []
|
|
|
|
async def init_static_loras(self):
|
|
return None
|
|
|
|
class _FakeCtor:
|
|
def __init__(self, *args, **kwargs):
|
|
pass
|
|
|
|
def warmup(self):
|
|
return None
|
|
|
|
class _FakeSpeech(_FakeCtor):
|
|
async def warmup(self):
|
|
return None
|
|
|
|
monkeypatch.setattr(api_server, "load_chat_template", lambda *_a, **_k: None)
|
|
monkeypatch.setattr(api_server, "process_lora_modules", lambda modules, _defaults: modules or [])
|
|
monkeypatch.setattr(api_server, "OpenAIServingModels", _FakeModels)
|
|
monkeypatch.setattr(api_server, "OnlineRenderer", _FakeCtor)
|
|
monkeypatch.setattr(api_server, "OpenAIServingResponses", _FakeCtor)
|
|
monkeypatch.setattr(api_server, "OmniOpenAIServingChat", _FakeCtor)
|
|
monkeypatch.setattr(api_server, "OmniOpenAIServingChatBatch", _FakeCtor)
|
|
monkeypatch.setattr(api_server, "OpenAIServingCompletion", _FakeCtor)
|
|
monkeypatch.setattr(api_server, "ServingPooling", _FakeCtor)
|
|
monkeypatch.setattr(api_server, "OpenAIServingEmbedding", _FakeCtor)
|
|
monkeypatch.setattr(api_server, "ServingClassification", _FakeCtor)
|
|
monkeypatch.setattr(api_server, "ServingScores", _FakeCtor)
|
|
monkeypatch.setattr(api_server, "ServingTokenization", _FakeCtor)
|
|
monkeypatch.setattr(api_server, "OpenAIServingTranscription", _FakeCtor)
|
|
monkeypatch.setattr(api_server, "OpenAIServingTranslation", _FakeCtor)
|
|
monkeypatch.setattr(api_server, "AnthropicServingMessages", _FakeCtor)
|
|
monkeypatch.setattr(api_server, "ServingTokens", _FakeCtor)
|
|
monkeypatch.setattr(api_server, "OmniOpenAIServingSpeech", _FakeSpeech)
|
|
monkeypatch.setattr(api_server, "OmniOpenAIServingAudioGenerate", _FakeCtor)
|
|
monkeypatch.setattr(api_server, "OmniStreamingSpeechHandler", _FakeCtor)
|
|
monkeypatch.setattr(api_server, "create_streaming_video_handler", lambda **_k: _marker("streaming_video"))
|
|
monkeypatch.setattr(api_server, "OpenAIServingRealtime", _FakeCtor)
|
|
monkeypatch.setattr(api_server, "OmniOpenAIServingVideo", _FakeCtor)
|
|
monkeypatch.setattr(api_server, "should_enable_duplex_endpoint", lambda *_a, **_k: False)
|
|
|
|
state = State()
|
|
await api_server.omni_init_app_state(engine, state, _minimal_args())
|
|
|
|
_assert_app_state_snapshot(
|
|
state,
|
|
expected_keys=_MULTISTAGE_APP_STATE_KEYS,
|
|
must_be_wired=_MULTISTAGE_MUST_BE_WIRED,
|
|
must_be_none=_MULTISTAGE_MUST_BE_NONE,
|
|
)
|