diff --git a/docs/cli_reference.md b/docs/cli_reference.md index dd2c6aae0..be011523d 100644 --- a/docs/cli_reference.md +++ b/docs/cli_reference.md @@ -85,6 +85,25 @@ Validate a deployment, then start the proxy: --host 127.0.0.1 --port 4000 ``` +### Error Responses + +OpenAI Chat Completions, OpenAI Responses, and unknown URL paths return this +error body: + +```json +{"error": {"message": "...", "type": "...", "code": "..."}} +``` + +Anthropic Messages returns the Anthropic error body: + +```json +{"type": "error", "error": {"type": "...", "message": "..."}} +``` + +Common OpenAI-compatible `code` values include `invalid_body`, `empty_messages`, +`model_not_found`, `endpoint_not_found`, `upstream_error`, +`internal_chain_error`, and `context_length_exceeded`. + ## Removed Setup Commands `switchyard configure` and `switchyard verify` are not available. Export the diff --git a/switchyard/server/switchyard_app.py b/switchyard/server/switchyard_app.py index 404b77b95..2428a6a3e 100644 --- a/switchyard/server/switchyard_app.py +++ b/switchyard/server/switchyard_app.py @@ -9,17 +9,16 @@ translation internally. """ -from __future__ import annotations - import inspect from collections.abc import AsyncIterator, Callable, Iterable from contextlib import asynccontextmanager -from typing import TYPE_CHECKING, cast +from typing import cast from fastapi import FastAPI, Request -from fastapi.exception_handlers import request_validation_exception_handler +from fastapi.exception_handlers import http_exception_handler, request_validation_exception_handler from fastapi.exceptions import RequestValidationError -from fastapi.responses import Response +from fastapi.responses import JSONResponse, Response +from starlette.exceptions import HTTPException as StarletteHTTPException from switchyard.lib.endpoints import outcome_metrics from switchyard.lib.endpoints.anthropic_messages_endpoint import ( @@ -27,6 +26,7 @@ ) from switchyard.lib.endpoints.base import Endpoint from switchyard.lib.endpoints.dispatch import invalid_request_response +from switchyard.lib.endpoints.error_envelope import ERROR_SOURCE_HEADER, error_response from switchyard.lib.endpoints.models_endpoint import ModelsEndpoint from switchyard.lib.endpoints.openai_chat_endpoint import ( OpenAIChatEndpoint, @@ -34,6 +34,8 @@ from switchyard.lib.endpoints.responses_endpoint import ( ResponsesEndpoint, ) +from switchyard.lib.proxy_context import ERROR_SOURCE_SWITCHYARD +from switchyard.lib.route_table import SwitchyardApp from switchyard_rust.core import SwitchyardInvalidRequestError #: Inbound LLM-serving paths whose response status codes feed the @@ -46,8 +48,21 @@ "/v1/responses", }) -if TYPE_CHECKING: - from switchyard.lib.route_table import SwitchyardApp + +def _request_error_response(request: Request, message: str, code: str) -> Response: + if request.url.path == "/v1/messages": + return JSONResponse( + status_code=400, + content={ + "type": "error", + "error": { + "type": "invalid_request_error", + "message": message, + }, + }, + headers={ERROR_SOURCE_HEADER: ERROR_SOURCE_SWITCHYARD}, + ) + return invalid_request_response(message, code=code) async def _run_lifecycle_method(component: object, method_name: str) -> None: @@ -113,6 +128,19 @@ async def _lifespan(_app: FastAPI) -> AsyncIterator[None]: app = FastAPI(title="Switchyard", lifespan=_lifespan) + @app.exception_handler(StarletteHTTPException) + async def _http_error_handler( + request: Request, exc: StarletteHTTPException + ) -> Response: + if exc.status_code == 404 and "endpoint" not in request.scope: + return error_response( + 404, + "Not Found", + error_type="not_found", + code="endpoint_not_found", + ) + return await http_exception_handler(request, exc) + @app.exception_handler(RequestValidationError) async def _request_validation_error_handler( request: Request, exc: RequestValidationError @@ -133,12 +161,12 @@ async def _request_validation_error_handler( if is_json_parse else "Request body must be a JSON object" ) - return invalid_request_response(message, code="invalid_body") + return _request_error_response(request, message, "invalid_body") return await request_validation_exception_handler(request, exc) @app.exception_handler(SwitchyardInvalidRequestError) async def _invalid_request_handler( - _request: Request, exc: SwitchyardInvalidRequestError + request: Request, exc: SwitchyardInvalidRequestError ) -> Response: """Map request validation failures to the 400 envelope. @@ -148,7 +176,7 @@ async def _invalid_request_handler( ``messages`` array, so the envelope uses ``code="empty_messages"``; revisit if more validations start sharing this error. """ - return invalid_request_response(str(exc), code="empty_messages") + return _request_error_response(request, str(exc), "empty_messages") app.state.switchyard = switchyard diff --git a/tests/test_build_and_serve.py b/tests/test_build_and_serve.py index 547f8ddd7..7773c260d 100644 --- a/tests/test_build_and_serve.py +++ b/tests/test_build_and_serve.py @@ -16,8 +16,6 @@ fails one of these tests. """ -from __future__ import annotations - import argparse from collections.abc import AsyncIterator from typing import Any @@ -100,20 +98,34 @@ async def _sentinel() -> dict[str, str]: # --------------------------------------------------------------------------- -def _ns(**overrides: Any) -> argparse.Namespace: +def _ns( + host: str = "127.0.0.1", + port: int | None = 4000, + reload: bool = False, + workers: int = 1, +) -> argparse.Namespace: """Build the argparse namespace ``build_and_serve`` expects.""" - defaults = {"host": "127.0.0.1", "port": 4000, "reload": False, "workers": 1} - defaults.update(overrides) - return argparse.Namespace(**defaults) + return argparse.Namespace(host=host, port=port, reload=reload, workers=workers) def _capture_uvicorn(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: """Patch ``uvicorn.run`` to capture its kwargs without starting a server.""" captured: dict[str, Any] = {} - def _fake_run(app: FastAPI, **kwargs: Any) -> None: + def _fake_run( + app: FastAPI, + host: str, + port: int, + reload: bool, + workers: int, + ) -> None: captured["app"] = app - captured["kwargs"] = kwargs + captured["kwargs"] = { + "host": host, + "port": port, + "reload": reload, + "workers": workers, + } import uvicorn @@ -121,6 +133,20 @@ def _fake_run(app: FastAPI, **kwargs: Any) -> None: return captured +def _assert_invalid_request_response( + response: httpx.Response, + path: str, + code: str, +) -> None: + body = response.json() + assert body["error"]["type"] == "invalid_request_error" + if path == "/v1/messages": + assert body["type"] == "error" + assert set(body["error"]) == {"type", "message"} + else: + assert body["error"]["code"] == code + + def _switchyard() -> Switchyard: return Switchyard(backend=_StubBackend(), translator=TranslationEngine()) @@ -266,9 +292,7 @@ async def test_malformed_json_returns_400( ) assert resp.status_code == 400 assert resp.headers["content-type"].startswith("application/json") - body = resp.json() - assert body["error"]["type"] == "invalid_request_error" - assert body["error"]["code"] == "invalid_body" + _assert_invalid_request_response(resp, path, "invalid_body") @pytest.mark.parametrize("path", _ENDPOINTS) async def test_json_array_body_returns_400( @@ -281,9 +305,7 @@ async def test_json_array_body_returns_400( ) assert resp.status_code == 400 assert resp.headers["content-type"].startswith("application/json") - body = resp.json() - assert body["error"]["type"] == "invalid_request_error" - assert body["error"]["code"] == "invalid_body" + _assert_invalid_request_response(resp, path, "invalid_body") async def test_server_stays_healthy_after_bad_request( self, served_client: httpx.AsyncClient @@ -304,9 +326,20 @@ async def counting_client( """Like served_client but exposes a call-counting backend for short-circuit checks.""" captured: dict[str, Any] = {} - def _fake_run(app: FastAPI, **kwargs: Any) -> None: + def _fake_run( + app: FastAPI, + host: str, + port: int, + reload: bool, + workers: int, + ) -> None: captured["app"] = app - captured["kwargs"] = kwargs + captured["kwargs"] = { + "host": host, + "port": port, + "reload": reload, + "workers": workers, + } import uvicorn @@ -357,9 +390,7 @@ async def test_anthropic_messages_empty_messages_returns_400( ) assert resp.status_code == 400 assert resp.headers["content-type"].startswith("application/json") - body = resp.json() - assert body["error"]["type"] == "invalid_request_error" - assert body["error"]["code"] == "empty_messages" + _assert_invalid_request_response(resp, "/v1/messages", "empty_messages") assert backend.call_count == 0, "backend must not be invoked for empty messages" async def test_non_empty_messages_still_succeed( diff --git a/tests/test_switchyard_app_factory.py b/tests/test_switchyard_app_factory.py index e77d1e244..cc6d574d1 100644 --- a/tests/test_switchyard_app_factory.py +++ b/tests/test_switchyard_app_factory.py @@ -3,8 +3,6 @@ """Tests for the FastAPI app factory wiring.""" -from __future__ import annotations - from typing import Protocol from fastapi import FastAPI @@ -25,7 +23,6 @@ def __init__(self) -> None: async def call( self, request: _RequestWithBody, - *, ctx: object | None = None, ) -> dict[str, object]: self.requests.append(request) @@ -86,3 +83,19 @@ def test_app_registers_component_contributed_endpoints() -> None: assert response.status_code == 200 assert response.json() == {"status": "ok"} + + +def test_unknown_path_uses_switchyard_error_envelope() -> None: + app = build_switchyard_app(_RecordingSwitchyard()) # type: ignore[arg-type] + + with TestClient(app, raise_server_exceptions=False) as client: + response = client.get("/this/does/not/exist") + + assert response.status_code == 404 + assert response.json() == { + "error": { + "message": "Not Found", + "type": "not_found", + "code": "endpoint_not_found", + } + }