feat: migrate Inspect API to FastAPI router (#4403)

# What does this PR do?

Migrate the Inspect API to the FastAPI router pattern.

Changes:
- Add inspect API to FastAPI router registry
- Add PUBLIC_ROUTE_KEY support for routes that don't require auth
- Update WebMethod creation to respect route's openapi_extra for
authentication requirements

Fixes: https://github.com/llamastack/llama-stack/issues/4346

<!-- Provide a short summary of what this PR does and why. Link to
relevant issues if applicable. -->

<!-- If resolving an issue, uncomment and update the line below -->
<!-- Closes #[issue-number] -->

## Test Plan

CI and various curls on /v1/inspect/routes, /v1/health, /v1/version

Signed-off-by: Sébastien Han <seb@redhat.com>
This commit is contained in:
Sébastien Han 2025-12-17 17:33:42 +01:00 committed by GitHub
parent cd5095a247
commit a7d509aaf9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 281 additions and 182 deletions

View file

@ -28,9 +28,11 @@ class AuthenticationMiddleware:
4. Makes these attributes available to the route handlers for access control
Unauthenticated Access:
Endpoints can opt out of authentication by setting require_authentication=False
in their @webmethod decorator. This is typically used for operational endpoints
like /health and /version to support monitoring, load balancers, and observability tools.
Endpoints can opt out of authentication by:
- For legacy @webmethod routes: setting require_authentication=False in the decorator
- For FastAPI router routes: setting openapi_extra={PUBLIC_ROUTE_KEY: True}
This is typically used for operational endpoints like /health and /version to support
monitoring, load balancers, and observability tools.
The middleware supports multiple authentication providers through the AuthProvider interface:
- Kubernetes: Validates tokens against the Kubernetes API server

View file

@ -15,9 +15,8 @@ from typing import Any, cast
from fastapi import APIRouter
from fastapi.routing import APIRoute
from starlette.routing import Route
from llama_stack_api import batches, benchmarks, datasets, providers
from llama_stack_api import batches, benchmarks, datasets, inspect_api, providers
# Router factories for APIs that have FastAPI routers
# Add new APIs here as they are migrated to the router system
@ -28,6 +27,7 @@ _ROUTER_FACTORIES: dict[str, Callable[[Any], APIRouter]] = {
"benchmarks": benchmarks.fastapi_routes.create_router,
"datasets": datasets.fastapi_routes.create_router,
"providers": providers.fastapi_routes.create_router,
"inspect": inspect_api.fastapi_routes.create_router,
}
@ -63,38 +63,20 @@ def build_fastapi_router(api: "Api", impl: Any) -> APIRouter | None:
return cast(APIRouter, router_factory(impl))
def get_router_routes(router: APIRouter) -> list[Route]:
"""Extract routes from a FastAPI router.
def get_router_routes(router: APIRouter) -> list[APIRoute]:
"""Extract APIRoute objects from a FastAPI router.
Args:
router: The FastAPI router to extract routes from
Returns:
List of Route objects from the router
List of APIRoute objects from the router (preserves tags and other metadata)
"""
routes = []
for route in router.routes:
# FastAPI routers use APIRoute objects, which have path and methods attributes
# FastAPI routers use APIRoute objects, which have path, methods, tags, etc.
if isinstance(route, APIRoute):
# Combine router prefix with route path
routes.append(
Route(
path=route.path,
methods=route.methods,
name=route.name,
endpoint=route.endpoint,
)
)
elif isinstance(route, Route):
# Fallback for regular Starlette Route objects
routes.append(
Route(
path=route.path,
methods=route.methods,
name=route.name,
endpoint=route.endpoint,
)
)
routes.append(route)
return routes

View file

@ -19,6 +19,7 @@ from llama_stack.core.server.fastapi_router_registry import (
get_router_routes,
)
from llama_stack_api import Api, ExternalApiSpec, WebMethod
from llama_stack_api.router_utils import PUBLIC_ROUTE_KEY
EndpointFunc = Callable[..., Any]
PathParams = dict[str, str]
@ -124,7 +125,13 @@ def initialize_route_impls(impls, external_apis: dict[Api, ExternalApiSpec] | No
# - Pass summary directly in RouteMatch instead of WebMethod
# - Remove this WebMethod() instantiation entirely
# - Update library_client.py to use the extracted summary instead of webmethod.descriptive_name
webmethod = WebMethod(descriptive_name=None)
# Routes with openapi_extra[PUBLIC_ROUTE_KEY]=True don't require authentication
is_public = (route.openapi_extra or {}).get(PUBLIC_ROUTE_KEY, False)
webmethod = WebMethod(
descriptive_name=None,
require_authentication=not is_public,
)
route_impls[method][_convert_path_to_regex(route.path)] = (
func,
route.path,
@ -139,19 +146,19 @@ def initialize_route_impls(impls, external_apis: dict[Api, ExternalApiSpec] | No
if api not in impls:
continue
for route, webmethod in api_routes:
for legacy_route, webmethod in api_routes:
impl = impls[api]
func = getattr(impl, route.name)
func = getattr(impl, legacy_route.name)
# Get the first (and typically only) method from the set, filtering out HEAD
available_methods = [m for m in (route.methods or []) if m != "HEAD"]
available_methods = [m for m in (legacy_route.methods or []) if m != "HEAD"]
if not available_methods:
continue # Skip if only HEAD method is available
method = available_methods[0].lower()
if method not in route_impls:
route_impls[method] = {}
route_impls[method][_convert_path_to_regex(route.path)] = (
route_impls[method][_convert_path_to_regex(legacy_route.path)] = (
func,
route.path,
legacy_route.path,
webmethod,
)