Merge pull request #158 from MervinPraison/claude/issue-151-20260624-000000

feat: implement /api/provider/health JSON route + guard /api/* catch-all
This commit is contained in:
Mervin Praison authored and GitHub committed 2026-06-24 23:18:06 +01:00
commit 4e2e14699c
3 files changed
+106 -5

No files matched your search

+16 -3
View File
@@ -48,18 +48,31 @@ Returns information about the active AI provider.
### GET /api/provider/health
Returns detailed health information from the provider gateway.
Returns detailed JSON health information from the provider gateway. Always
responds with `Content-Type: application/json`. The base fields from the
provider's `health()` payload are merged with the normalised fields below.
**Response:**
```json
{
"type": "gateway",
"status": "ok",
"provider": "PraisonAIProvider",
"type": "PraisonAIProvider",
"agents": 5,
"detail": "All agents operational"
"detail": "ok"
}
```
| Field | Type | Description |
|-------|------|-------------|
| `status` | string | One of the health status values above. |
| `type` | string | Provider type (from `provider` field, falls back to class name). |
| `agents` | integer | Count of agents reported by the provider. |
| `detail` | string | Human-readable detail; `"ok"` for healthy providers. |
> **Note:** Any unmatched `/api/*` path returns a JSON `404` (never the SPA
> HTML shell), so API clients can rely on parsing the response body.
## CLI Commands
### health-check
+49 -2
View File
@@ -825,8 +825,17 @@ async def _plugins_config(request: Request) -> JSONResponse:
return JSONResponse({"plugins": plugins})
async def _serve_index(request: Request) -> HTMLResponse:
"""Serve dynamically generated HTML based on active style."""
async def _serve_index(request: Request) -> Response:
"""Serve dynamically generated HTML based on active style.
Unmatched ``/api/*`` paths return a JSON 404 instead of the SPA shell so
API clients never receive HTML with a 200 status.
"""
if request.url.path.startswith("/api/"):
return JSONResponse(
{"error": "Not Found", "path": request.url.path},
status_code=404,
)
style = _effective_style # Single source of truth (set by create_app)
return HTMLResponse(_build_html(style))
@@ -1544,6 +1553,34 @@ async def api_provider(request: Request) -> JSONResponse:
return JSONResponse(info)
async def api_provider_health(request: Request) -> JSONResponse:
"""Return JSON health information for the active provider gateway."""
provider = get_provider()
try:
health = await provider.health()
except Exception as exc:
health = {"status": "error", "detail": str(exc)}
agents: list = []
try:
listed = await provider.list_agents()
if isinstance(listed, list):
agents = listed
except Exception:
agents = []
status = health.get("status", "unknown")
detail = health.get("detail") or (
"ok" if status in ("ok", "healthy") else str(health)
)
body = {
**health,
"type": health.get("provider", type(provider).__name__),
"status": status,
"agents": len(agents),
"detail": detail,
}
return JSONResponse(body)
async def api_action_click(request: Request) -> JSONResponse:
"""Handle action button clicks and dispatch to registered callbacks.
@@ -3043,6 +3080,7 @@ def create_app(
# Note: /api/usage is now provided by PraisonAIUsage feature
Route("/api/debug", api_debug, methods=["GET"]),
Route("/api/provider", api_provider, methods=["GET"]),
Route("/api/provider/health", api_provider_health, methods=["GET"]),
# Action callback API
Route("/api/actions/{action_id}/click", api_action_click, methods=["POST"]),
# Page registry protocol
@@ -3525,6 +3563,15 @@ def create_app(
response_status = 404
if response_status == 404:
request_path = scope.get("path", "")
if request_path.startswith("/api/"):
# Never serve the SPA HTML shell for unmatched API paths.
response = JSONResponse(
{"error": "Not Found", "path": request_path},
status_code=404,
)
await response(scope, receive, send)
return
# SPA fallback — serve the generated index page
from starlette.responses import HTMLResponse
+41
View File
@@ -146,6 +146,47 @@ class TestHealthEndpoint:
_features.pop("__slow_test__", None)
class TestProviderHealthEndpoint:
"""Tests for the provider health JSON route (#151)."""
def test_api_provider_health_is_json(self, client):
"""/api/provider/health returns JSON, not the SPA HTML shell."""
response = client.get("/api/provider/health")
assert response.status_code == 200
assert "application/json" in response.headers["content-type"]
body = response.json()
assert "status" in body
assert "type" in body
assert "agents" in body
assert "detail" in body
def test_api_provider_health_accept_json(self, client):
"""Accept: application/json still yields JSON body."""
response = client.get(
"/api/provider/health", headers={"Accept": "application/json"}
)
assert response.status_code == 200
assert "application/json" in response.headers["content-type"]
assert "status" in response.json()
def test_unknown_api_path_not_html(self, client):
"""Unmatched /api/* paths return JSON 404, never SPA HTML 200."""
response = client.get("/api/this-route-does-not-exist")
assert response.status_code == 404
assert "text/html" not in response.headers.get("content-type", "")
assert "application/json" in response.headers["content-type"]
def test_provider_health_route_before_catchall(self, client):
"""The dedicated route is registered before the SPA catch-all."""
paths = [getattr(r, "path", None) for r in client.app.routes]
assert "/api/provider/health" in paths
catchall = next(
(i for i, p in enumerate(paths) if p == "/{path:path}"), None
)
if catchall is not None:
assert paths.index("/api/provider/health") < catchall
class TestAgentsEndpoint:
"""Tests for agents endpoint."""