Files
Ali Khokhar 58aef0dc8a Refactor provider runtime ownership (#925)
## Problem

Provider construction, model discovery, validation, and cleanup lived in
one registry module. API and admin routes depended on registry-shaped
app state and legacy process-level provider helpers.

## Changes

| Before | After |
| --- | --- |
| `providers.registry` mixed provider factories, config, cache,
discovery, validation, and cleanup. | `providers.runtime` splits
factories, config, cache, model cache, discovery, validation, and
runtime orchestration. |
| API and admin routes read `app.state.provider_registry` and sometimes
created registries ad hoc. | API and admin routes use app-scoped
`ProviderRuntime` through `app.state.provider_runtime`. |
| `api.dependencies` kept process-global provider cache helpers. |
`api.dependencies` resolves providers only through the app-scoped
runtime. |
| Registry-shaped tests preserved old internal boundaries. |
Runtime-shaped tests assert provider config, construction, cache,
discovery, validation, and import boundaries. |

<!-- greptile_comment -->

<details open><summary><h3>Greptile Summary</h3></summary>

This PR moves provider lifecycle ownership from the old registry module
into an app-scoped runtime package. The main changes are:

- Split provider config, factory wiring, instance cache, model cache,
discovery, validation, and cleanup into `providers.runtime` modules.
- Updated API and admin routes to resolve providers and model metadata
through `app.state.provider_runtime`.
- Removed legacy process-global provider helpers and the deleted
`providers.registry` module.
- Updated docs, smoke metadata, import-boundary checks, and tests for
the new runtime ownership model.
- Bumped the package version and lockfile metadata for the production
refactor.
</details>

<h3>Confidence Score: 5/5</h3>

The provider runtime refactor appears merge-safe with no identified
blocking issues.

The changes consistently move provider ownership to app-scoped runtime
modules and update API, admin, docs, smoke metadata, import-boundary
checks, and tests around that architecture.

<details><summary><h3><a href="https://www.greptile.com/trex"><img
alt="T-Rex"
src="https://greptile-static-assets.s3.amazonaws.com/trex/trex_green.svg"
height="20" align="absmiddle"></a> T-Rex Logs</h3></summary>

**What T-Rex did**
- Ran a baseline and head comparison of provider registry and runtime
states, verifying the after-state shows head
state\_has\_provider\_registry=False and
state\_has\_provider\_runtime=True, that GET /v1/models and admin
endpoints respond with 200, and that provider\_resolver\_called via
runtime, with assertions passing.
- Verified that the four focused provider-runtime contract tests passed
in both the before and after refactor runs, including runtime split
checks, with exit code 0.
- Identified environmental blockers that prevented the smoke-runtime
workflow from running, including uv unavailability, missing pytest for
/usr/local/bin/python, and Python 3.11 being used despite pyproject.toml
requiring \>=3.14.

<a
href="https://app.greptile.com/trex/runs/12528505/artifacts"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifactsDark.svg?v=1"><source
media="(prefers-color-scheme: light)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifacts.svg?v=1"><img
alt="View all artifacts"
src="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifacts.svg?v=1"
height="32"></picture></a>

<sub><a href="https://www.greptile.com/trex"><img alt="T-Rex"
src="https://greptile-static-assets.s3.amazonaws.com/trex/trex_green.svg"
height="14" align="absmiddle"></a> Ran code and verified through
T-Rex</sub>
</details>

<sub>Reviews (1): Last reviewed commit: ["Refactor provider runtime
ownership"](https://github.com/alishahryar1/free-claude-code/commit/01d589488185c1f85112f1a49c47f04512846161)
| [Re-trigger
Greptile](https://app.greptile.com/api/retrigger?id=40312173)</sub>

<!-- /greptile_comment -->
2026-06-27 15:04:34 -07:00

107 lines
3.8 KiB
Python

"""Dependency injection for FastAPI."""
import secrets
from fastapi import Depends, HTTPException, Request
from loguru import logger
from starlette.applications import Starlette
from config.provider_catalog import PROVIDER_CATALOG
from config.settings import Settings
from config.settings import get_settings as _get_settings
from core.anthropic import get_user_facing_error_message
from providers.base import BaseProvider
from providers.exceptions import (
AuthenticationError,
ServiceUnavailableError,
UnknownProviderTypeError,
)
from providers.runtime import ProviderRuntime
def get_settings() -> Settings:
"""Return cached :class:`~config.settings.Settings` (FastAPI-friendly alias)."""
return _get_settings()
def get_provider_runtime(app: Starlette) -> ProviderRuntime:
"""Return the app-scoped provider runtime installed by ``AppRuntime``."""
runtime = getattr(app.state, "provider_runtime", None)
if isinstance(runtime, ProviderRuntime):
return runtime
raise ServiceUnavailableError(
"Provider runtime is not configured. Ensure AppRuntime startup ran "
"or assign app.state.provider_runtime for test apps."
)
def maybe_provider_runtime(app: Starlette) -> ProviderRuntime | None:
"""Return the app-scoped provider runtime when it is installed."""
runtime = getattr(app.state, "provider_runtime", None)
return runtime if isinstance(runtime, ProviderRuntime) else None
def resolve_provider(
provider_type: str,
*,
app: Starlette,
) -> BaseProvider:
"""Resolve a provider through the app-scoped provider runtime."""
runtime = get_provider_runtime(app)
should_log_init = not runtime.is_cached(provider_type)
try:
provider = runtime.resolve_provider(provider_type)
except AuthenticationError as e:
# Provider :class:`~providers.exceptions.AuthenticationError` messages are
# curated configuration hints (env var names, docs links), not upstream noise.
detail = str(e).strip() or get_user_facing_error_message(e)
raise HTTPException(status_code=503, detail=detail) from e
except UnknownProviderTypeError:
logger.error(
"Unknown provider_type: '{}'. Supported: {}",
provider_type,
", ".join(f"'{key}'" for key in PROVIDER_CATALOG),
)
raise
if should_log_init:
logger.info("Provider initialized: {}", provider_type)
return provider
def require_api_key(
request: Request, settings: Settings = Depends(get_settings)
) -> None:
"""Require a server API key (Anthropic-style).
Checks `x-api-key` header or `Authorization: Bearer ...` against
`Settings.anthropic_auth_token`. If `ANTHROPIC_AUTH_TOKEN` is empty, this is a no-op.
"""
anthropic_auth_token = settings.anthropic_auth_token.strip()
if not anthropic_auth_token:
# No API key configured -> allow
return
header = (
request.headers.get("x-api-key")
or request.headers.get("authorization")
or request.headers.get("anthropic-auth-token")
)
if not header:
raise HTTPException(status_code=401, detail="Missing API key")
# Support both raw key in X-API-Key and Bearer token in Authorization
token = header.strip()
if header.lower().startswith("bearer "):
token = header.split(" ", 1)[1].strip()
# Strip anything after the first colon to handle tokens with appended model names
if token and ":" in token:
token = token.split(":", 1)[0].strip()
# Constant-time comparison to avoid leaking the configured token via
# response-time differences on a per-byte mismatch (CWE-208).
if not secrets.compare_digest(
token.encode("utf-8"), anthropic_auth_token.encode("utf-8")
):
raise HTTPException(status_code=401, detail="Invalid API key")