1192 lines
42 KiB
Python
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",
|
|
},
|
|
},
|
|
},
|
|
}
|