from __future__ import annotations import asyncio import base64 import logging import httpx import pytest from cmvr_edge_ai.application import create_default_capability_registry from cmvr_edge_ai.config import load_config_data from cmvr_edge_ai.contracts import ( DetectionsOutput, InferenceResponse, InferenceStatus, ModelReference, ScalarOutput, ) from cmvr_edge_ai.core import HealthReport from cmvr_edge_ai.server import InvocationBroker from cmvr_edge_ai.server.api import ( InferenceHttpApi, _InvalidContentLength, _read_limited_body, ) def _config( *, max_request_bytes: int = 4096, request_timeout_s: float = 0.2, model_id: str = "yolov8n-mobile-phone@1", bearer_token: str | None = None, ): # type: ignore[no-untyped-def] return load_config_data( { "api_version": "cmvr.edge.ai/v1", "pipelines": { "mobile_remote": { "nodes": { "source": {"uses": "server.request_source@1"}, } } }, "server": { "enabled": True, "http": { "request_timeout_s": request_timeout_s, "max_request_bytes": max_request_bytes, "max_image_bytes": min(max_request_bytes, 1024), **( {} if bearer_token is None else {"bearer_token": bearer_token} ), }, "routes": { "detect.mobile_phone": { "pipeline": "mobile_remote", "model_id": model_id, "queue_capacity": 1, } }, }, } ) def _request_payload(*, category: str = "detect.mobile_phone") -> dict[str, object]: return { "schema_version": "cmvr.inference-request/v1", "request_id": "request-1", "category": category, "inputs": [ { "kind": "image", "name": "image", "media_type": "image/jpeg", "encoding": "base64", "data": base64.b64encode(b"jpeg-bytes").decode("ascii"), } ], "parameters": {}, "requested_artifact_roles": [], } def _response( request_id: str, *, category: str = "detect.mobile_phone", model_id: str = "yolov8n-mobile-phone@1", outputs: tuple[object, ...] | None = None, ) -> InferenceResponse: return InferenceResponse( request_id=request_id, trace_id=request_id, category=category, model=ModelReference(model_id=model_id, backend="test"), status=InferenceStatus.NO_RESULT, outputs=( (DetectionsOutput(items=()),) if outputs is None else outputs ), ) def test_http_api_routes_one_request_through_the_broker() -> None: async def exercise() -> None: config = _config() broker = InvocationBroker() broker.register_route("detect.mobile_phone", capacity=1) async def health(): # type: ignore[no-untyped-def] return { "state": "running", "pipelines": { "mobile_remote": {"source": HealthReport(True)} }, } api = InferenceHttpApi( config, create_default_capability_registry(discover_entry_points=False), broker, health_provider=health, ) async def pipeline() -> None: item = await broker.receive("detect.mobile_phone") await broker.complete( item.request_id, InferenceResponse( request_id=item.request_id, trace_id=item.request_id, category=item.category, model=ModelReference( model_id="yolov8n-mobile-phone@1", backend="test", ), status=InferenceStatus.NO_RESULT, outputs=(DetectionsOutput(items=()),), ), invocation_token=item.invocation_token, ) worker = asyncio.create_task(pipeline()) async with httpx.AsyncClient( transport=httpx.ASGITransport(app=api.app), base_url="http://test", ) as client: response = await client.post("/v1/inference", json=_request_payload()) assert response.status_code == 200 assert response.json()["request_id"] == "request-1" assert response.json()["status"] == "no_result" models = await client.get("/v1/models") assert models.status_code == 200 assert [ item["category"] for item in models.json()["passive_invoke"] ] == ["detect.mobile_phone"] ready = await client.get("/health/ready") assert ready.status_code == 200 assert ready.json()["status"] == "ready" await worker await broker.close() asyncio.run(exercise()) def test_http_api_returns_structured_errors_for_invalid_requests() -> None: async def exercise() -> None: config = _config() broker = InvocationBroker() broker.register_route("detect.mobile_phone", capacity=1) async def health(): # type: ignore[no-untyped-def] return {"state": "starting", "pipelines": {}} api = InferenceHttpApi( config, create_default_capability_registry(discover_entry_points=False), broker, health_provider=health, ) async with httpx.AsyncClient( transport=httpx.ASGITransport(app=api.app), base_url="http://test", ) as client: unsupported = await client.post( "/v1/inference", json=_request_payload(category="detect.unknown"), ) assert unsupported.status_code == 404 assert unsupported.json()["error"]["code"] == "UNSUPPORTED_CATEGORY" malformed = await client.post( "/v1/inference", content=b"not-json", headers={"content-type": "application/json"}, ) assert malformed.status_code == 422 assert malformed.json()["schema_version"] == "cmvr.inference-error/v1" wrong_type = await client.post( "/v1/inference", content=b"{}", headers={"content-type": "text/plain"}, ) assert wrong_type.status_code == 415 ready = await client.get("/health/ready") assert ready.status_code == 503 await broker.close() asyncio.run(exercise()) def test_http_api_rejects_body_before_pydantic_parsing_when_too_large() -> None: async def exercise() -> None: config = _config(max_request_bytes=1024) broker = InvocationBroker() broker.register_route("detect.mobile_phone", capacity=1) async def health(): # type: ignore[no-untyped-def] return {"state": "running", "pipelines": {}} api = InferenceHttpApi( config, create_default_capability_registry(discover_entry_points=False), broker, health_provider=health, ) async with httpx.AsyncClient( transport=httpx.ASGITransport(app=api.app), base_url="http://test", ) as client: response = await client.post( "/v1/inference", content=b"x" * 1025, headers={"content-type": "application/json"}, ) assert response.status_code == 413 assert response.json()["error"]["code"] == "REQUEST_TOO_LARGE" await broker.close() asyncio.run(exercise()) def test_http_api_requires_configured_bearer_token_except_for_health() -> None: async def exercise() -> None: config = _config(bearer_token="deployment-secret") broker = InvocationBroker() broker.register_route("detect.mobile_phone", capacity=1) async def health(): # type: ignore[no-untyped-def] return { "state": "running", "pipelines": { "mobile_remote": {"source": HealthReport(True)} }, } api = InferenceHttpApi( config, create_default_capability_registry(discover_entry_points=False), broker, health_provider=health, ) async with httpx.AsyncClient( transport=httpx.ASGITransport(app=api.app), base_url="http://test", ) as client: live = await client.get("/health/live") ready = await client.get("/health/ready") missing = await client.get("/v1/models") wrong = await client.get( "/v1/models", headers={"Authorization": "Bearer wrong-secret"}, ) openapi_without_token = await client.get("/openapi.json") malformed_without_token = await client.post( "/v1/inference", json={}, ) authorized = await client.get( "/v1/models", headers={"Authorization": "bearer deployment-secret"}, ) openapi_with_token = await client.get( "/openapi.json", headers={"Authorization": "Bearer deployment-secret"}, ) malformed_with_token = await client.post( "/v1/inference", json={}, headers={"Authorization": "Bearer deployment-secret"}, ) await broker.close() assert live.status_code == 200 assert ready.status_code == 200 for response in ( missing, wrong, openapi_without_token, malformed_without_token, ): assert response.status_code == 401 assert response.headers["www-authenticate"] == "Bearer" assert response.json()["error"]["code"] == "UNAUTHORIZED" assert authorized.status_code == 200 assert openapi_with_token.status_code == 200 assert malformed_with_token.status_code == 422 asyncio.run(exercise()) @pytest.mark.parametrize("content_length", ["invalid", "-1", "1, 2"]) def test_limited_body_rejects_invalid_content_length( content_length: str, ) -> None: class Request: headers = {"content-length": content_length} async def stream(self): # type: ignore[no-untyped-def] yield b"{}" with pytest.raises(_InvalidContentLength): asyncio.run(_read_limited_body(Request(), 1024)) def test_http_api_generates_and_echoes_request_id_when_client_omits_it() -> None: async def exercise() -> str: config = _config() broker = InvocationBroker() broker.register_route("detect.mobile_phone", capacity=1) async def health(): # type: ignore[no-untyped-def] return {"state": "running", "pipelines": {}} api = InferenceHttpApi( config, create_default_capability_registry(discover_entry_points=False), broker, health_provider=health, ) async def pipeline() -> None: item = await broker.receive("detect.mobile_phone") assert item.payload.request_id == item.request_id await broker.complete( item.request_id, _response(item.request_id), invocation_token=item.invocation_token, ) payload = _request_payload() del payload["request_id"] worker = asyncio.create_task(pipeline()) async with httpx.AsyncClient( transport=httpx.ASGITransport(app=api.app), base_url="http://test", ) as client: response = await client.post("/v1/inference", json=payload) await worker await broker.close() assert response.status_code == 200 return response.json()["request_id"] request_id = asyncio.run(exercise()) assert len(request_id) == 32 assert all(character in "0123456789abcdef" for character in request_id) def test_http_api_rejects_pipeline_responses_for_another_request_or_model( caplog: pytest.LogCaptureFixture, ) -> None: async def exercise() -> list[dict[str, object]]: cases = ( lambda request_id: _response("another-request"), lambda request_id: _response( request_id, category="detect.analog_gauge", ), lambda request_id: _response( request_id, model_id="another-model@1", ), lambda request_id: _response( request_id, outputs=(ScalarOutput(name="reading", value=1.0),), ), lambda request_id: {"request_id": request_id}, ) errors: list[dict[str, object]] = [] for index, build_response in enumerate(cases): config = _config() broker = InvocationBroker() broker.register_route("detect.mobile_phone", capacity=1) async def health(): # type: ignore[no-untyped-def] return {"state": "running", "pipelines": {}} api = InferenceHttpApi( config, create_default_capability_registry(discover_entry_points=False), broker, health_provider=health, ) async def pipeline() -> None: item = await broker.receive("detect.mobile_phone") await broker.complete( item.request_id, build_response(item.request_id), invocation_token=item.invocation_token, ) payload = _request_payload() payload["request_id"] = f"mismatch-{index}" worker = asyncio.create_task(pipeline()) async with httpx.AsyncClient( transport=httpx.ASGITransport(app=api.app), base_url="http://test", ) as client: response = await client.post("/v1/inference", json=payload) await worker await broker.close() assert response.status_code == 500 body = response.json() assert body["request_id"] == f"mismatch-{index}" assert body["category"] == "detect.mobile_phone" assert body["error"]["code"] == "INVALID_PIPELINE_RESPONSE" errors.append(body) return errors caplog.set_level(logging.ERROR, logger="cmvr_edge_ai.server.api") asyncio.run(exercise()) assert "response request_id does not match" in caplog.text assert "response category does not match" in caplog.text assert "response model_id does not match" in caplog.text assert "unsupported output kind" in caplog.text assert "unexpected response type dict" in caplog.text def test_http_api_maps_broker_overload_duplicate_timeout_and_close() -> None: async def exercise() -> tuple[dict[str, object], ...]: config = _config(request_timeout_s=0.01) broker = InvocationBroker() broker.register_route("detect.mobile_phone", capacity=1) async def health(): # type: ignore[no-untyped-def] return {"state": "running", "pipelines": {}} api = InferenceHttpApi( config, create_default_capability_registry(discover_entry_points=False), broker, health_provider=health, ) async with httpx.AsyncClient( transport=httpx.ASGITransport(app=api.app), base_url="http://test", ) as client: await broker.submit("detect.mobile_phone", "duplicate", object()) duplicate_payload = _request_payload() duplicate_payload["request_id"] = "duplicate" duplicate = await client.post( "/v1/inference", json=duplicate_payload, ) await broker.cancel("duplicate") await broker.submit("detect.mobile_phone", "blocker", object()) busy_payload = _request_payload() busy_payload["request_id"] = "busy" busy = await client.post("/v1/inference", json=busy_payload) await broker.cancel("blocker") timeout_payload = _request_payload() timeout_payload["request_id"] = "timeout" timeout = await client.post("/v1/inference", json=timeout_payload) await broker.close() closed_payload = _request_payload() closed_payload["request_id"] = "closed" closed = await client.post("/v1/inference", json=closed_payload) assert duplicate.status_code == 409 assert busy.status_code == 429 assert timeout.status_code == 504 assert closed.status_code == 503 return tuple( response.json() for response in (duplicate, busy, timeout, closed) ) duplicate, busy, timeout, closed = asyncio.run(exercise()) assert duplicate["error"]["code"] == "DUPLICATE_REQUEST_ID" assert busy["error"]["code"] == "BUSY" assert busy["error"]["retryable"] is True assert timeout["error"]["code"] == "TIMEOUT" assert timeout["error"]["retryable"] is True assert closed["error"]["code"] == "MODEL_UNAVAILABLE" def test_configured_route_missing_from_broker_is_server_misconfiguration() -> None: async def exercise() -> tuple[int, dict[str, object]]: config = _config() broker = InvocationBroker() async def health(): # type: ignore[no-untyped-def] return {"state": "running", "pipelines": {}} api = InferenceHttpApi( config, create_default_capability_registry(discover_entry_points=False), broker, health_provider=health, ) async with httpx.AsyncClient( transport=httpx.ASGITransport(app=api.app), base_url="http://test", ) as client: response = await client.post("/v1/inference", json=_request_payload()) await broker.close() return response.status_code, response.json() status_code, body = asyncio.run(exercise()) assert status_code == 503 assert body["error"]["code"] == "ROUTE_MISCONFIGURED" assert body["request_id"] == "request-1" def test_pipeline_exception_is_logged_but_not_exposed_to_client( caplog: pytest.LogCaptureFixture, ) -> None: async def exercise() -> tuple[int, dict[str, object]]: config = _config() broker = InvocationBroker() broker.register_route("detect.mobile_phone", capacity=1) async def health(): # type: ignore[no-untyped-def] return {"state": "running", "pipelines": {}} api = InferenceHttpApi( config, create_default_capability_registry(discover_entry_points=False), broker, health_provider=health, ) async def pipeline() -> None: item = await broker.receive("detect.mobile_phone") await broker.fail( item.request_id, RuntimeError("private adapter path /tmp/private-model.pt"), invocation_token=item.invocation_token, ) worker = asyncio.create_task(pipeline()) async with httpx.AsyncClient( transport=httpx.ASGITransport(app=api.app), base_url="http://test", ) as client: response = await client.post("/v1/inference", json=_request_payload()) await worker await broker.close() return response.status_code, response.json() caplog.set_level(logging.ERROR, logger="cmvr_edge_ai.server.api") status_code, body = asyncio.run(exercise()) assert status_code == 500 assert body["error"]["code"] == "INFERENCE_FAILED" assert "private-model.pt" not in str(body) assert "request_id=request-1" in caplog.text assert "private-model.pt" in caplog.text def test_health_failure_keeps_liveness_and_catalog_available_with_unavailable_status( caplog: pytest.LogCaptureFixture, ) -> None: async def exercise() -> tuple[object, object, object]: config = _config() broker = InvocationBroker() broker.register_route("detect.mobile_phone", capacity=1) async def health(): # type: ignore[no-untyped-def] raise RuntimeError("health backend failed") api = InferenceHttpApi( config, create_default_capability_registry(discover_entry_points=False), broker, health_provider=health, ) async with httpx.AsyncClient( transport=httpx.ASGITransport(app=api.app), base_url="http://test", ) as client: live = await client.get("/health/live") ready = await client.get("/health/ready") models = await client.get("/v1/models") await broker.close() return live, ready, models caplog.set_level(logging.ERROR, logger="cmvr_edge_ai.server.api") live, ready, models = asyncio.run(exercise()) assert live.status_code == 200 assert ready.status_code == 503 assert ready.json() == { "status": "not_ready", "application_state": "unknown", } assert models.status_code == 200 assert ( models.json()["passive_invoke"][0]["deployment_status"] == "unavailable" ) assert caplog.text.count("health provider failed") == 2 def test_ready_and_catalog_accept_wire_safe_health_reports() -> None: async def exercise() -> tuple[object, object]: config = _config() broker = InvocationBroker() broker.register_route("detect.mobile_phone", capacity=1) async def health(): # type: ignore[no-untyped-def] return { "state": "running", "pipelines": { "mobile_remote": { "source": {"healthy": True, "detail": "ready"} } }, } api = InferenceHttpApi( config, create_default_capability_registry(discover_entry_points=False), broker, health_provider=health, ) async with httpx.AsyncClient( transport=httpx.ASGITransport(app=api.app), base_url="http://test", ) as client: ready = await client.get("/health/ready") models = await client.get("/v1/models") await broker.close() return ready, models ready, models = asyncio.run(exercise()) assert ready.status_code == 200 assert models.status_code == 200 assert models.json()["passive_invoke"][0]["deployment_status"] == "ready" def test_closed_broker_makes_readiness_and_passive_catalog_unavailable() -> None: async def exercise() -> tuple[object, object]: config = _config() broker = InvocationBroker() broker.register_route("detect.mobile_phone", capacity=1) await broker.close() async def health(): # type: ignore[no-untyped-def] return { "state": "running", "pipelines": { "mobile_remote": {"source": HealthReport(True)} }, } api = InferenceHttpApi( config, create_default_capability_registry(discover_entry_points=False), broker, health_provider=health, ) async with httpx.AsyncClient( transport=httpx.ASGITransport(app=api.app), base_url="http://test", ) as client: ready = await client.get("/health/ready") models = await client.get("/v1/models") return ready, models ready, models = asyncio.run(exercise()) assert ready.status_code == 503 assert models.status_code == 200 assert ( models.json()["passive_invoke"][0]["deployment_status"] == "unavailable" ) def test_unknown_capability_returns_structured_catalog_and_route_errors( caplog: pytest.LogCaptureFixture, ) -> None: async def exercise() -> tuple[object, object]: config = _config(model_id="not-installed@1") broker = InvocationBroker() broker.register_route("detect.mobile_phone", capacity=1) async def health(): # type: ignore[no-untyped-def] return {"state": "running", "pipelines": {}} api = InferenceHttpApi( config, create_default_capability_registry(discover_entry_points=False), broker, health_provider=health, ) async with httpx.AsyncClient( transport=httpx.ASGITransport(app=api.app), base_url="http://test", ) as client: models = await client.get("/v1/models") inference = await client.post( "/v1/inference", json=_request_payload(), ) await broker.close() return models, inference caplog.set_level(logging.ERROR, logger="cmvr_edge_ai.server.api") models, inference = asyncio.run(exercise()) assert models.status_code == 503 assert models.json()["error"]["code"] == "CATALOG_UNAVAILABLE" assert models.json()["schema_version"] == "cmvr.inference-error/v1" assert inference.status_code == 503 assert inference.json()["error"]["code"] == "ROUTE_MISCONFIGURED" assert "not-installed@1" not in str(inference.json()) assert "failed to build deployed model catalog" in caplog.text