Files
EvoScientist/EvoScientist/model_registry/http_api.py
T

1192 lines
42 KiB
Python

"""HTTP API for the unified model registry (design doc 7.3, 9.1-9.5).
This module is the only backend entry point the WebUI BFF calls. It mounts:
- ``GET/PUT /api/model-registry`` and
``PUT /api/model-registry/credentials/{credential_id}`` — the Config API,
requiring ``model_config:read`` / ``model_config:write`` scopes.
- ``POST /api/model-registry/test`` — the section 9.4 provider test, the
only model verification entry point, requiring ``model_config:test``.
- ``GET /api/models`` — the user-facing selector, requiring ``model:select``.
- ``POST /api/runtime-snapshots[/{snapshot_id}/bind]`` and
``DELETE /api/runtime-snapshots/{snapshot_id}`` — thread-level snapshot
routes requiring ``run:create`` plus a matching ``thread_id`` claim.
Every route passes the fixed BFF authentication format (service bearer token
plus ``X-Evo-Actor`` delegation JWT, section 7.3) and answers failures with
the unified section 9.5 error payload. The PUT route runs the section 9.2
save-time checks inside the registry write transaction, after the
``credential_writes`` have been applied, so the verification five-tuple sees
the new credential revisions and any failure rolls back without partial
updates.
"""
from __future__ import annotations
import asyncio
import json
import logging
import sqlite3
import uuid
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any, Literal
from pydantic import (
BaseModel,
Field,
NonNegativeInt,
PositiveInt,
TypeAdapter,
ValidationError,
)
from pydantic.json_schema import models_json_schema
from starlette.requests import Request
from starlette.responses import JSONResponse, Response
from starlette.routing import Route
from EvoScientist.image_gen.adapters.base import ImageGenError
from EvoScientist.image_gen.config import (
ImageGenerationSettings,
load_image_generation_settings,
save_image_generation_settings,
)
from .adapters import find_adapter_spec, resolve_parameters
from .auth import ActorContext, BffAuthenticator
from .endpoint_policy import EndpointPolicy
from .errors import (
CREDENTIAL_NOT_CONFIGURED,
FORBIDDEN,
IMAGE_MODEL_NOT_CHAT_MODEL,
INVALID_REQUEST,
MODEL_LIMITS_UNCONFIRMED,
MODEL_NOT_AVAILABLE,
PLATFORM_CONFIG_MISSING,
SNAPSHOT_NOT_FOUND,
VALIDATION_FAILED,
ErrorDetail,
ErrorPayload,
ModelRegistryError,
)
from .hashing import configuration_hash
from .platform import (
PlatformConfigError,
PlatformSecurityConfig,
load_platform_security_config,
)
from .provider_test import ProviderTester
from .resolver import ModelRegistryResolver
from .schemas import (
AdapterParameterSpec,
Capabilities,
CredentialId,
CredentialStatus,
CredentialWrite,
EndpointPolicyPublic,
ModelAvailability,
ModelRef,
NonEmptyString,
RegistryDefaults,
RegistryV4,
)
from .snapshots import (
SnapshotCreateRequest,
SnapshotCreation,
SnapshotService,
public_snapshot_view,
)
from .store import ModelRuntimeStore
_logger = logging.getLogger(__name__)
_CREDENTIAL_ID_ADAPTER = TypeAdapter(CredentialId)
# --- API contract models (sections 9.1-9.3, 8.2); OpenAPI exports these ---
class GetModelRegistryResponse(BaseModel):
"""The section 9.1 registry read response."""
revision: PositiveInt
registry: RegistryV4
adapter_specs: list[AdapterParameterSpec]
credential_status: list[CredentialStatus]
model_status: list[ModelAvailability]
endpoint_policy: EndpointPolicyPublic
class PutModelRegistryRequest(BaseModel):
"""The section 9.2 registry save request.
``expected_revision`` is deprecated: accepted for backward
compatibility but ignored — saves are last-write-wins.
"""
expected_revision: PositiveInt | None = None
registry: RegistryV4
credential_writes: list[CredentialWrite] = Field(default_factory=list)
class CredentialReplaceRequest(BaseModel):
"""The section 9.3 credential rotation request."""
operation: Literal["replace"] = "replace"
secret_value: NonEmptyString
class ProviderTestRequest(BaseModel):
"""The section 9.4 provider test request."""
expected_registry_revision: PositiveInt
model_ref: ModelRef
class ProviderTestResponse(BaseModel):
"""The section 9.4 provider test outcome; never carries secrets."""
ok: bool
registry_revision: PositiveInt
model_ref: ModelRef
adapter_spec_revision: PositiveInt
effective_request_options: dict[str, Any]
model_status: ModelAvailability
latency_ms: NonNegativeInt
class CredentialWriteResponse(BaseModel):
"""The section 9.3 credential rotation response; never the plaintext."""
credential_id: NonEmptyString
revision: PositiveInt
configured: bool
hint: str | None = None
updated_at: str | None = None
class SelectableModel(BaseModel):
"""One entry of the section 9.1 model selector response."""
model_ref: ModelRef
name: str
provider_name: str
effective_capabilities: Capabilities
class GetSelectableModelsResponse(BaseModel):
"""The section 9.1 selector response: only selectable enabled models."""
models: list[SelectableModel]
# Lets the selector display the registry default before the first turn,
# when no run snapshot exists yet to resolve `inherit` against.
defaults: RegistryDefaults = Field(default_factory=RegistryDefaults)
class SnapshotBindRequest(BaseModel):
"""The section 9.5 bind request."""
langgraph_run_id: NonEmptyString
class ImageModelWrite(BaseModel):
"""One image model entry in a PUT; blank api_key keeps the stored key."""
id: str
name: str = ""
provider: Literal["openai", "gemini"] = "openai"
api_key: str = ""
base_url: str = ""
supports_generation: bool = True
supports_edit: bool = True
default_size: str = "1024x1024"
default_quality: str = "auto"
params: dict[str, Any] = Field(default_factory=dict)
class PutImageGenerationRequest(BaseModel):
"""The image_generation section save request."""
default_model: str = ""
timeout_seconds: float = 120.0
models: list[ImageModelWrite] = Field(default_factory=list)
class RuntimeOptionsPublic(BaseModel):
"""The public runtime subset of a frozen model config (section 8.2)."""
max_output_tokens: PositiveInt
temperature: float | None = None
top_p: float | None = None
timeout_seconds: int
max_retries: int
class ResolvedModelPublic(BaseModel):
"""The public per-role diagnostic subset; never carries secrets."""
provider_id: str
model_key: str
adapter_spec_revision: PositiveInt
runtime: RuntimeOptionsPublic
class SnapshotPublicResponse(BaseModel):
"""The section 8.2 public snapshot diagnostic subset."""
snapshot_id: str
registry_revision: PositiveInt
primary: ResolvedModelPublic
# --- services bundle --------------------------------------------------------
@dataclass(frozen=True)
class ApiServices:
"""Everything the HTTP layer needs; built once per deployment."""
store: ModelRuntimeStore
resolver: ModelRegistryResolver
snapshot_service: SnapshotService
endpoint_policy: EndpointPolicy
authenticator: BffAuthenticator
# Optional so pre-Task-5b service bundles keep working; the test route
# lazily builds a default tester when the field is unset.
provider_tester: ProviderTester | None = None
def build_services(platform: PlatformSecurityConfig) -> ApiServices:
"""Wire the store, resolver, snapshot service, policy, and authenticator."""
store = (
ModelRuntimeStore(database_path=platform.model_runtime_db)
if platform.model_runtime_db is not None
else ModelRuntimeStore()
)
policy = EndpointPolicy(platform.development_endpoints)
resolver = ModelRegistryResolver(store)
authenticator = BffAuthenticator(
service_token=platform.bff_service_token,
service_token_hash=platform.bff_service_token_hash,
delegation_keys=platform.webui_delegation_public_keys,
jti_store=store,
)
return ApiServices(
store=store,
resolver=resolver,
snapshot_service=SnapshotService(store, resolver),
endpoint_policy=policy,
authenticator=authenticator,
provider_tester=ProviderTester(store, resolver, policy),
)
_default_services: ApiServices | None = None
def get_default_services() -> ApiServices:
"""Lazily build the production services from ``config.yaml``.
A missing platform security configuration raises
:class:`PlatformConfigError`; the routes map it to
``500 PLATFORM_CONFIG_MISSING`` instead of serving unauthenticated.
Only a successful build is cached.
"""
global _default_services
if _default_services is None:
try:
_default_services = build_services(load_platform_security_config())
except ValueError as exc:
raise PlatformConfigError(str(exc)) from exc
return _default_services
# --- section 9.2 save-time validation ---------------------------------------
_ROOTED_PATH_PREFIXES = ("providers[", "defaults.", "expected_revision")
def _scoped_error(exc: ModelRegistryError, prefix: str) -> ModelRegistryError:
"""Re-raise ``exc`` with detail paths rooted at the offending entity."""
details = [
ErrorDetail(
path=(
detail.path
if detail.path.startswith(_ROOTED_PATH_PREFIXES)
else f"{prefix}.{detail.path}"
),
code=detail.code,
)
for detail in exc.details
]
if not details:
details = [ErrorDetail(path=prefix, code=exc.code)]
return ModelRegistryError(exc.code, exc.message, details=details)
def _check_enabled_model(
connection: sqlite3.Connection,
provider: Any,
model: Any,
spec: AdapterParameterSpec,
) -> None:
"""Section 9.2 rules 4-5: confirmed limits and a passing five-tuple.
The credential pointer is read from the open transaction, so a
``credential_writes`` rotation in the same request is already visible.
"""
if model.runtime.limits_status != "confirmed":
raise ModelRegistryError(
MODEL_LIMITS_UNCONFIRMED,
"An enabled model requires confirmed context limits.",
details=[
{"path": "runtime.limits_status", "code": MODEL_LIMITS_UNCONFIRMED}
],
)
auth_spec = spec.auth_specs[provider.auth.mode]
credential_revision = 0
if auth_spec.credential_required:
if provider.auth.credential_id is None:
# resolve_parameters already rejects this case; raise explicitly
# here so the five-tuple check never depends on call ordering.
raise ModelRegistryError(
CREDENTIAL_NOT_CONFIGURED,
f"Auth mode {provider.auth.mode!r} requires a credential reference.",
details=[
{
"path": "auth.credential_id",
"code": CREDENTIAL_NOT_CONFIGURED,
}
],
)
row = connection.execute(
"SELECT current_revision FROM credential_pointers WHERE credential_id = ?",
(provider.auth.credential_id,),
).fetchone()
if row is None:
raise ModelRegistryError(
CREDENTIAL_NOT_CONFIGURED,
"The credential referenced by this provider is not configured.",
details=[
{
"path": "auth.credential_id",
"code": CREDENTIAL_NOT_CONFIGURED,
}
],
)
credential_revision = int(row[0])
record = connection.execute(
"SELECT result FROM model_verifications "
"WHERE provider_id = ? AND model_key = ? "
"AND configuration_hash = ? AND credential_revision = ? "
"AND adapter_spec_revision = ?",
(
provider.id,
model.key,
configuration_hash(provider, model),
credential_revision,
spec.spec_revision,
),
).fetchone()
if record is None or str(record[0]) != "passed":
raise ModelRegistryError(
MODEL_NOT_AVAILABLE,
"An enabled model requires a passing verification for its "
"current configuration, credential revision, and adapter contract.",
details=[{"path": "model.verification", "code": MODEL_NOT_AVAILABLE}],
)
def validate_registry_save(
connection: sqlite3.Connection,
registry: RegistryV4,
*,
specs: tuple[AdapterParameterSpec, ...],
policy: EndpointPolicy,
) -> None:
"""The section 9.2 atomic save-time checks, inside the write transaction.
Rule 1 (unique, legal IDs) is enforced by the Pydantic schema and rule 7
(defaults reference enabled models) by ``ModelRuntimeStore``. Rule 8 has
no role-override fields in RegistryV4; capability consistency between
declared capabilities and the adapter protocol is checked here through
``resolve_parameters``, and the resolve-time tightening rules are
enforced by the resolver/adapter layer.
"""
from EvoScientist.image_gen import is_image_generation_model
for provider_index, provider in enumerate(registry.providers):
for model_index, model in enumerate(provider.models):
if model.enabled and is_image_generation_model(model.upstream_model_id):
raise ModelRegistryError(
IMAGE_MODEL_NOT_CHAT_MODEL,
"Image-only models must be configured in the "
"image_generation section of config.yaml, not as chat models.",
details=[
{
"path": (
f"providers[{provider_index}].models"
f"[{model_index}].upstream_model_id"
),
"code": IMAGE_MODEL_NOT_CHAT_MODEL,
}
],
)
for provider_index, provider in enumerate(registry.providers):
provider_path = f"providers[{provider_index}]"
try:
policy.validate_base_url(provider.base_url)
except ModelRegistryError as exc:
raise _scoped_error(exc, f"{provider_path}.base_url") from exc
for model_index, model in enumerate(provider.models):
model_path = f"{provider_path}.models[{model_index}]"
try:
spec = find_adapter_spec(
provider.adapter, model.upstream_model_id, specs=specs
)
if spec is None:
# No matching contract: the model may only remain
# 'configured' (resolver judgement), so rules 3-6 — which
# all evaluate against a contract — do not apply.
continue
parameters = resolve_parameters(provider, model, spec)
if provider.enabled and model.enabled:
_check_enabled_model(connection, provider, model, spec)
# Rule 6 holds whether or not the model is enabled.
ModelRegistryResolver._resolve_budget(
model, parameters.max_output_tokens
)
except ModelRegistryError as exc:
raise _scoped_error(exc, model_path) from exc
# --- HTTP layer ---------------------------------------------------------------
def _error_response(exc: ModelRegistryError, request_id: str) -> JSONResponse:
return JSONResponse(exc.payload(request_id=request_id), status_code=exc.http_status)
def _validation_error_response(exc: ValidationError, request_id: str) -> JSONResponse:
details = [
ErrorDetail(
path=".".join(str(part) for part in error["loc"]) or "(body)",
code=str(error["type"]),
)
for error in exc.errors()
]
return _error_response(
ModelRegistryError(
VALIDATION_FAILED, "The request payload failed validation.", details=details
),
request_id,
)
class ModelRegistryHttpApi:
"""The Starlette route handlers for the model registry HTTP API."""
def __init__(self, services_provider: Callable[[], ApiServices]) -> None:
self._services_provider = services_provider
def routes(self) -> list[Route]:
return [
Route("/api/model-registry", self.get_model_registry, methods=["GET"]),
Route("/api/model-registry", self.put_model_registry, methods=["PUT"]),
Route(
"/api/model-registry/credentials/{credential_id}",
self.put_credential,
methods=["PUT"],
),
Route(
"/api/model-registry/test",
self.test_provider,
methods=["POST"],
),
Route("/api/models", self.get_selectable_models, methods=["GET"]),
Route(
"/api/image-generation",
self.get_image_generation,
methods=["GET"],
),
Route(
"/api/image-generation",
self.put_image_generation,
methods=["PUT"],
),
Route("/api/runtime-snapshots", self.create_snapshot, methods=["POST"]),
Route(
"/api/runtime-snapshots/{snapshot_id}/bind",
self.bind_snapshot,
methods=["POST"],
),
Route(
"/api/runtime-snapshots/{snapshot_id}",
self.delete_snapshot,
methods=["DELETE"],
),
]
# --- helpers ----------------------------------------------------------
def _services(self) -> ApiServices:
try:
return self._services_provider()
except PlatformConfigError:
_logger.exception("Model registry platform configuration is unusable")
raise ModelRegistryError(
PLATFORM_CONFIG_MISSING,
"The model registry platform security configuration is "
"missing or invalid; the Config API is unavailable.",
) from None
@staticmethod
async def _body(request: Request) -> Any:
try:
return await request.json()
except json.JSONDecodeError:
raise ModelRegistryError(
INVALID_REQUEST, "The request body must be valid JSON."
) from None
def _authenticate_sync(
self, request: Request, *, required_scope: str, require_thread_id: bool
) -> tuple[ApiServices, ActorContext]:
services = self._services()
actor = services.authenticator.authenticate(
request.headers,
required_scope=required_scope,
require_thread_id=require_thread_id,
)
return services, actor
async def _authenticate(
self, request: Request, *, required_scope: str, require_thread_id: bool
) -> tuple[ApiServices, ActorContext]:
# Services build (config.yaml read, store mkdir/chmod) and jti
# registration (SQLite) are blocking I/O — keep them off the event
# loop (langgraph dev's blockbuster rejects them there).
return await asyncio.to_thread(
self._authenticate_sync,
request,
required_scope=required_scope,
require_thread_id=require_thread_id,
)
# --- Config API ----------------------------------------------------------
async def get_model_registry(self, request: Request) -> Response:
request_id = uuid.uuid4().hex
try:
services, _actor = await self._authenticate(
request, required_scope="model_config:read", require_thread_id=False
)
response = await asyncio.to_thread(self._registry_response, services)
except ModelRegistryError as exc:
return _error_response(exc, request_id)
return JSONResponse(response.model_dump(mode="json"))
async def put_model_registry(self, request: Request) -> Response:
request_id = uuid.uuid4().hex
try:
services, _actor = await self._authenticate(
request, required_scope="model_config:write", require_thread_id=False
)
body = await self._body(request)
response = await asyncio.to_thread(self._save_registry, services, body)
except ModelRegistryError as exc:
return _error_response(exc, request_id)
except ValidationError as exc:
return _validation_error_response(exc, request_id)
return JSONResponse(response.model_dump(mode="json"))
async def put_credential(self, request: Request) -> Response:
request_id = uuid.uuid4().hex
try:
services, _actor = await self._authenticate(
request, required_scope="model_config:write", require_thread_id=False
)
body = await self._body(request)
credential_id = _CREDENTIAL_ID_ADAPTER.validate_python(
request.path_params["credential_id"]
)
response = await asyncio.to_thread(
self._replace_credential,
services,
credential_id,
body,
)
except ModelRegistryError as exc:
return _error_response(exc, request_id)
except ValidationError as exc:
return _validation_error_response(exc, request_id)
return JSONResponse(response.model_dump(mode="json"))
async def test_provider(self, request: Request) -> Response:
request_id = uuid.uuid4().hex
try:
services, _actor = await self._authenticate(
request, required_scope="model_config:test", require_thread_id=False
)
body = await self._body(request)
response = await asyncio.to_thread(self._run_provider_test, services, body)
except ModelRegistryError as exc:
return _error_response(exc, request_id)
except ValidationError as exc:
return _validation_error_response(exc, request_id)
return JSONResponse(response.model_dump(mode="json"))
async def get_selectable_models(self, request: Request) -> Response:
request_id = uuid.uuid4().hex
try:
services, _actor = await self._authenticate(
request, required_scope="model:select", require_thread_id=False
)
response = await asyncio.to_thread(self._selectable_models, services)
except ModelRegistryError as exc:
return _error_response(exc, request_id)
return JSONResponse(response.model_dump(mode="json"))
# --- image-generation config API -----------------------------------------
async def get_image_generation(self, request: Request) -> Response:
request_id = uuid.uuid4().hex
try:
await self._authenticate(
request, required_scope="model_config:read", require_thread_id=False
)
response = await asyncio.to_thread(self._image_generation_response)
except ModelRegistryError as exc:
return _error_response(exc, request_id)
except ImageGenError as exc:
return _error_response(
ModelRegistryError(VALIDATION_FAILED, str(exc)), request_id
)
return JSONResponse(response)
async def put_image_generation(self, request: Request) -> Response:
request_id = uuid.uuid4().hex
try:
await self._authenticate(
request, required_scope="model_config:write", require_thread_id=False
)
body = await self._body(request)
response = await asyncio.to_thread(self._save_image_generation, body)
except ModelRegistryError as exc:
return _error_response(exc, request_id)
except ValidationError as exc:
return _validation_error_response(exc, request_id)
except ImageGenError as exc:
return _error_response(
ModelRegistryError(VALIDATION_FAILED, str(exc)), request_id
)
return JSONResponse(response)
# --- snapshot API ----------------------------------------------------------
async def create_snapshot(self, request: Request) -> Response:
request_id = uuid.uuid4().hex
try:
services, actor = await self._authenticate(
request, required_scope="run:create", require_thread_id=True
)
body = await self._body(request)
creation = await asyncio.to_thread(
self._create_snapshot, services, actor, body
)
except ModelRegistryError as exc:
return _error_response(exc, request_id)
except ValidationError as exc:
return _validation_error_response(exc, request_id)
status_code = 201 if creation.created else 200
return JSONResponse(
self._snapshot_response(creation.snapshot).model_dump(mode="json"),
status_code=status_code,
)
async def bind_snapshot(self, request: Request) -> Response:
request_id = uuid.uuid4().hex
try:
services, actor = await self._authenticate(
request, required_scope="run:create", require_thread_id=True
)
body = await self._body(request)
bound = await asyncio.to_thread(
self._bind_snapshot,
services,
actor,
request.path_params["snapshot_id"],
body,
)
except ModelRegistryError as exc:
return _error_response(exc, request_id)
except ValidationError as exc:
return _validation_error_response(exc, request_id)
return JSONResponse(self._snapshot_response(bound).model_dump(mode="json"))
async def delete_snapshot(self, request: Request) -> Response:
request_id = uuid.uuid4().hex
try:
services, actor = await self._authenticate(
request, required_scope="run:create", require_thread_id=True
)
await asyncio.to_thread(
self._abort_snapshot,
services,
actor,
request.path_params["snapshot_id"],
)
except ModelRegistryError as exc:
return _error_response(exc, request_id)
return Response(status_code=204)
# --- sync workers (run in a thread; SQLite and config I/O block) --------
@staticmethod
def _registry_response(services: ApiServices) -> GetModelRegistryResponse:
store = services.store
registry = store.load_registry()
credential_ids = sorted(
{
provider.auth.credential_id
for provider in registry.providers
if provider.auth.credential_id is not None
}
)
return GetModelRegistryResponse(
revision=registry.revision,
registry=registry,
adapter_specs=list(services.resolver.specs),
credential_status=[
store.credential_status(credential_id)
for credential_id in credential_ids
],
# The full pointer mapping is mandatory: a default/empty mapping
# misjudges verified models as stale after a credential rotation.
model_status=services.resolver.compute_availability(
registry,
store.list_model_verifications(),
credential_revisions=store.list_credential_revisions(),
),
endpoint_policy=services.endpoint_policy.public_view(),
)
@staticmethod
def _save_registry(services: ApiServices, body: Any) -> GetModelRegistryResponse:
request = PutModelRegistryRequest.model_validate(body)
def validate(connection: sqlite3.Connection, registry: RegistryV4) -> None:
validate_registry_save(
connection,
registry,
specs=services.resolver.specs,
policy=services.endpoint_policy,
)
services.store.save_registry(
expected_revision=request.expected_revision,
registry=request.registry,
credential_writes=request.credential_writes,
validate=validate,
)
return ModelRegistryHttpApi._registry_response(services)
@staticmethod
def _replace_credential(
services: ApiServices, credential_id: str, body: Any
) -> CredentialWriteResponse:
request = CredentialReplaceRequest.model_validate(body)
revision = services.store.write_credential_version(
credential_id, request.secret_value
)
status = services.store.credential_status(credential_id)
return CredentialWriteResponse(
credential_id=credential_id,
revision=revision,
configured=status.configured,
hint=status.hint,
updated_at=status.updated_at,
)
@staticmethod
def _run_provider_test(services: ApiServices, body: Any) -> ProviderTestResponse:
request = ProviderTestRequest.model_validate(body)
tester = services.provider_tester or ProviderTester(
services.store, services.resolver, services.endpoint_policy
)
result = tester.run(
expected_registry_revision=request.expected_registry_revision,
model_ref=request.model_ref,
)
return ProviderTestResponse(
ok=result.ok,
registry_revision=result.registry_revision,
model_ref=result.model_ref,
adapter_spec_revision=result.adapter_spec_revision,
effective_request_options=result.effective_request_options,
model_status=result.model_status,
latency_ms=result.latency_ms,
)
@staticmethod
def _selectable_models(services: ApiServices) -> GetSelectableModelsResponse:
store = services.store
registry = store.load_registry()
availability = services.resolver.compute_availability(
registry,
store.list_model_verifications(),
credential_revisions=store.list_credential_revisions(),
)
models: list[SelectableModel] = []
for item in availability:
if not item.selectable:
continue
provider = registry.find_provider(item.model_ref.provider_id)
model = (
provider.find_model(item.model_ref.model_key)
if provider is not None
else None
)
if provider is None or model is None: # pragma: no cover - judged above
continue
models.append(
SelectableModel(
model_ref=item.model_ref,
name=model.name,
provider_name=provider.name,
effective_capabilities=item.effective_capabilities,
)
)
return GetSelectableModelsResponse(models=models, defaults=registry.defaults)
@staticmethod
def _image_generation_response() -> dict[str, Any]:
settings = load_image_generation_settings()
models = []
for entry in settings.models:
payload = entry.model_dump(mode="json")
key = payload.pop("api_key")
configured = bool(key)
hint = f"...{key[-4:]}" if len(key) >= 4 else None
models.append(
{
**payload,
"api_key": "",
"api_key_configured": configured,
"api_key_hint": hint,
}
)
return {
"default_model": settings.default_model,
"timeout_seconds": settings.timeout_seconds,
"models": models,
}
@staticmethod
def _save_image_generation(body: Any) -> dict[str, Any]:
request = PutImageGenerationRequest.model_validate(body)
existing = load_image_generation_settings()
existing_keys = {entry.id: entry.api_key for entry in existing.models}
entries = []
for model in request.models:
payload = model.model_dump(mode="json")
if not payload["api_key"]:
payload["api_key"] = existing_keys.get(model.id, "")
entries.append(payload)
settings = ImageGenerationSettings.model_validate(
{
"default_model": request.default_model,
"timeout_seconds": request.timeout_seconds,
"models": entries,
}
)
save_image_generation_settings(settings)
return ModelRegistryHttpApi._image_generation_response()
@staticmethod
def _create_snapshot(
services: ApiServices, actor: ActorContext, body: Any
) -> SnapshotCreation:
request = SnapshotCreateRequest.model_validate(body)
if request.thread_id != actor.thread_id:
raise ModelRegistryError(
FORBIDDEN,
"The delegation JWT thread does not match the request thread.",
)
if request.deployment_id != actor.deployment_id:
raise ModelRegistryError(
FORBIDDEN,
"The delegation JWT deployment does not match the request.",
)
return services.snapshot_service.create(request)
@staticmethod
def _bound_row(services: ApiServices, actor: ActorContext, snapshot_id: str):
row = services.store.get_run_snapshot(snapshot_id)
if (
row is None
or row["thread_id"] != actor.thread_id
or row["deployment_id"] != actor.deployment_id
):
raise ModelRegistryError(SNAPSHOT_NOT_FOUND, "The snapshot does not exist.")
return row
@staticmethod
def _bind_snapshot(
services: ApiServices, actor: ActorContext, snapshot_id: str, body: Any
):
request = SnapshotBindRequest.model_validate(body)
ModelRegistryHttpApi._bound_row(services, actor, snapshot_id)
return services.snapshot_service.bind(snapshot_id, request.langgraph_run_id)
@staticmethod
def _abort_snapshot(
services: ApiServices, actor: ActorContext, snapshot_id: str
) -> None:
ModelRegistryHttpApi._bound_row(services, actor, snapshot_id)
services.snapshot_service.abort(snapshot_id)
@staticmethod
def _snapshot_response(snapshot: Any) -> SnapshotPublicResponse:
return SnapshotPublicResponse.model_validate(public_snapshot_view(snapshot))
def model_registry_routes(
services_provider: Callable[[], ApiServices] = get_default_services,
) -> list[Route]:
"""Return the route table; services resolve lazily per request."""
return ModelRegistryHttpApi(services_provider).routes()
# --- OpenAPI / JSON Schema export (section 11 step 3) ------------------------
_OPENAPI_MODELS = (
GetModelRegistryResponse,
PutModelRegistryRequest,
CredentialReplaceRequest,
CredentialWriteResponse,
ProviderTestRequest,
ProviderTestResponse,
GetSelectableModelsResponse,
SnapshotCreateRequest,
SnapshotBindRequest,
SnapshotPublicResponse,
ErrorPayload,
)
def _schema_ref(model: type[BaseModel]) -> dict[str, str]:
return {"$ref": f"#/components/schemas/{model.__name__}"}
def _error_responses(*status_codes: int) -> dict[str, Any]:
return {
str(status): {
"description": "Unified error payload (section 9.5).",
"content": {"application/json": {"schema": _schema_ref(ErrorPayload)}},
}
for status in status_codes
}
def _json_response(status: str, model: type[BaseModel], description: str) -> dict:
return {
status: {
"description": description,
"content": {"application/json": {"schema": _schema_ref(model)}},
}
}
def build_openapi_document() -> dict[str, Any]:
"""Build the OpenAPI 3.1 contract from the same Pydantic models."""
_, definitions = models_json_schema(
[(model, "validation") for model in _OPENAPI_MODELS],
ref_template="#/components/schemas/{model}",
)
auth_security = [{"BffServiceToken": [], "DelegationJwt": []}]
paths: dict[str, Any] = {
"/api/model-registry": {
"get": {
"operationId": "getModelRegistry",
"security": auth_security,
"responses": {
**_json_response(
"200", GetModelRegistryResponse, "The current registry."
),
**_error_responses(401, 403, 500),
},
},
"put": {
"operationId": "putModelRegistry",
"security": auth_security,
"requestBody": {
"required": True,
"content": {
"application/json": {
"schema": _schema_ref(PutModelRegistryRequest)
}
},
},
"responses": {
**_json_response(
"200", GetModelRegistryResponse, "The saved registry."
),
**_error_responses(400, 401, 403, 409, 422, 500),
},
},
},
"/api/model-registry/credentials/{credential_id}": {
"put": {
"operationId": "replaceCredential",
"security": auth_security,
"parameters": [
{
"name": "credential_id",
"in": "path",
"required": True,
"schema": {"type": "string"},
}
],
"requestBody": {
"required": True,
"content": {
"application/json": {
"schema": _schema_ref(CredentialReplaceRequest)
}
},
},
"responses": {
**_json_response(
"200", CredentialWriteResponse, "The rotated credential."
),
**_error_responses(400, 401, 403, 422, 500),
},
}
},
"/api/model-registry/test": {
"post": {
"operationId": "testProviderModel",
"security": auth_security,
"requestBody": {
"required": True,
"content": {
"application/json": {"schema": _schema_ref(ProviderTestRequest)}
},
},
"responses": {
**_json_response(
"200", ProviderTestResponse, "The provider test outcome."
),
**_error_responses(400, 401, 403, 404, 409, 422, 500),
},
}
},
"/api/models": {
"get": {
"operationId": "getSelectableModels",
"security": auth_security,
"responses": {
**_json_response(
"200",
GetSelectableModelsResponse,
"Enabled models the user may select.",
),
**_error_responses(401, 403, 500),
},
}
},
"/api/runtime-snapshots": {
"post": {
"operationId": "createRuntimeSnapshot",
"security": auth_security,
"requestBody": {
"required": True,
"content": {
"application/json": {
"schema": _schema_ref(SnapshotCreateRequest)
}
},
},
"responses": {
**_json_response(
"201", SnapshotPublicResponse, "The created snapshot."
),
**_json_response(
"200",
SnapshotPublicResponse,
"Idempotent replay of the same run request.",
),
**_error_responses(400, 401, 403, 409, 422, 500),
},
}
},
"/api/runtime-snapshots/{snapshot_id}/bind": {
"post": {
"operationId": "bindRuntimeSnapshot",
"security": auth_security,
"parameters": [
{
"name": "snapshot_id",
"in": "path",
"required": True,
"schema": {"type": "string"},
}
],
"requestBody": {
"required": True,
"content": {
"application/json": {"schema": _schema_ref(SnapshotBindRequest)}
},
},
"responses": {
**_json_response(
"200", SnapshotPublicResponse, "The bound snapshot."
),
**_error_responses(400, 401, 403, 404, 409, 500),
},
}
},
"/api/runtime-snapshots/{snapshot_id}": {
"delete": {
"operationId": "abortRuntimeSnapshot",
"security": auth_security,
"parameters": [
{
"name": "snapshot_id",
"in": "path",
"required": True,
"schema": {"type": "string"},
}
],
"responses": {
"204": {"description": "The snapshot was aborted."},
**_error_responses(401, 403, 404, 409, 500),
},
}
},
}
return {
"openapi": "3.1.0",
"info": {
"title": "EvoScientist Model Registry API",
"version": "1.0.0",
"description": (
"Config, selector, and run snapshot API consumed by the "
"WebUI BFF (design doc sections 7.3, 9.1-9.3, 9.5)."
),
},
"paths": paths,
"components": {
"schemas": definitions["$defs"],
"securitySchemes": {
"BffServiceToken": {"type": "http", "scheme": "bearer"},
"DelegationJwt": {
"type": "apiKey",
"in": "header",
"name": "X-Evo-Actor",
},
},
},
}