prismal-server 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,15 @@
1
+ """prismal-server — reference host for the prismal engine.
2
+
3
+ A thin FastAPI/ASGI process that boots the engine once and serves it over
4
+ REST/SSE plus the inbound A2A endpoints. It orchestrates the engine through
5
+ exactly four seams and reimplements none of its logic (see ``CLAUDE.md``).
6
+ """
7
+
8
+ from importlib.metadata import PackageNotFoundError, version
9
+
10
+ try:
11
+ __version__ = version("prismal-server")
12
+ except PackageNotFoundError: # pragma: no cover - source checkout without install
13
+ __version__ = "0.0.0.dev0"
14
+
15
+ __all__ = ["__version__"]
prismal_server/app.py ADDED
@@ -0,0 +1,163 @@
1
+ """ASGI application factory and lifespan (RHB-01-03).
2
+
3
+ ``create_app()`` wires the ``RuntimeRegistry`` into the app, registers the
4
+ centralized error mapping, and mounts the routers. The lifespan warms nothing by
5
+ default (readiness composes on demand) and, on shutdown, ``aclose()``s every
6
+ cached ``RuntimeContext`` (SPEC-RHB-LIF-001/002). The registry is injectable so
7
+ tests can supply a fake built from ``build_test_runtime`` (SPEC-RHB-LIF-003).
8
+
9
+ The module-level ``app = create_app()`` is the entry point for
10
+ ``uvicorn prismal_server.app:app``.
11
+
12
+ Seam: composition — ``build_runtime`` (via ``deps.RuntimeRegistry``).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from collections.abc import AsyncIterator
18
+ from contextlib import asynccontextmanager
19
+ from typing import Any, cast
20
+
21
+ from fastapi import FastAPI
22
+ from prismal.agents.graph import get_async_compiled_graph
23
+
24
+ from prismal_server import __version__, openapi_examples
25
+ from prismal_server.auth import AuthBackend, build_auth_backend
26
+ from prismal_server.config import HostSettings, get_settings
27
+ from prismal_server.deps import AgentCardBuilder, GraphFactory, RuntimeRegistry
28
+ from prismal_server.errors import register_exception_handlers
29
+ from prismal_server.fake import (
30
+ FakeGraph,
31
+ FakeHeaderMiddleware,
32
+ FakeSummaries,
33
+ fake_builder,
34
+ )
35
+ from prismal_server.routes import a2a, admin, agui, health, threads, usage, ws
36
+ from prismal_server.usage.store import UsageStore, build_usage_store
37
+ from prismal_server.usage.summary import RunSummaryLookup, default_run_summary
38
+
39
+
40
+ async def _default_graph_factory(*, tool_provider: Any = None) -> Any:
41
+ """Obtain the compiled graph via the Execution seam."""
42
+ return await get_async_compiled_graph(tool_provider=tool_provider)
43
+
44
+
45
+ def _default_agent_card_builder(
46
+ *,
47
+ org_id: str | None = None,
48
+ protocol_version: str | None = None,
49
+ settings: Any = None,
50
+ ) -> dict[str, Any]:
51
+ """Build the Agent Card via the A2A seam (camelCase wire form).
52
+
53
+ Imports are deferred and sourced only from ``prismal.a2a`` (the seam
54
+ re-exports the engine settings + capability map), so the host never reaches
55
+ past the A2A seam and module import stays independent of that re-export.
56
+ """
57
+ from prismal.a2a import ( # noqa: PLC0415 - deferred to keep import off the base path
58
+ DEFAULT_CAPABILITY_MAP,
59
+ build_agent_card,
60
+ )
61
+ from prismal.a2a import (
62
+ get_settings as get_engine_settings,
63
+ )
64
+
65
+ card = build_agent_card(
66
+ settings or get_engine_settings(),
67
+ DEFAULT_CAPABILITY_MAP,
68
+ org_id=org_id,
69
+ protocol_version=protocol_version,
70
+ )
71
+ return cast("dict[str, Any]", card.model_dump(by_alias=True))
72
+
73
+
74
+ def _default_a2a_engine_settings() -> Any:
75
+ """Process engine settings for A2A routing (base URL, version, legacy)."""
76
+ from prismal.a2a import get_settings as get_engine_settings # noqa: PLC0415
77
+
78
+ return get_engine_settings()
79
+
80
+
81
+ def create_app(
82
+ *,
83
+ registry: RuntimeRegistry | None = None,
84
+ settings: HostSettings | None = None,
85
+ graph_factory: GraphFactory | None = None,
86
+ agent_card_builder: AgentCardBuilder | None = None,
87
+ auth_backend: AuthBackend | None = None,
88
+ usage_store: UsageStore | None = None,
89
+ run_summary: RunSummaryLookup | None = None,
90
+ a2a_engine_settings: Any = None,
91
+ ) -> FastAPI:
92
+ """Build the FastAPI app, optionally with injected seams (for tests)."""
93
+ settings = settings or get_settings()
94
+ if settings.host_fake_llm_mode:
95
+ # SPEC-RH2-FAK-001: engine test runtimes + the deterministic script.
96
+ summaries = FakeSummaries()
97
+
98
+ async def fake_graph_factory(*, tool_provider: Any = None) -> Any:
99
+ return FakeGraph(summaries)
100
+
101
+ registry = registry or RuntimeRegistry(settings, builder=fake_builder)
102
+ graph_factory = graph_factory or fake_graph_factory
103
+ run_summary = run_summary or summaries
104
+ registry = registry or RuntimeRegistry(settings)
105
+ graph_factory = graph_factory or _default_graph_factory
106
+ agent_card_builder = agent_card_builder or _default_agent_card_builder
107
+ auth_backend = auth_backend or build_auth_backend(settings)
108
+ usage_store = usage_store or build_usage_store(settings)
109
+ run_summary = run_summary or default_run_summary
110
+ a2a_engine_settings = a2a_engine_settings or _default_a2a_engine_settings()
111
+
112
+ @asynccontextmanager
113
+ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
114
+ # Startup: registry is already on app.state (set below). Warming the
115
+ # default tenant is optional (SPEC-RHB-LIF-001 MAY) and left off so boot
116
+ # stays resilient; /readyz composes on demand.
117
+ try:
118
+ yield
119
+ finally:
120
+ # Shutdown: release every tenant runtime (SPEC-RHB-LIF-002).
121
+ await registry.aclose_all()
122
+ await usage_store.aclose()
123
+
124
+ app = FastAPI(
125
+ title="prismal-server",
126
+ version=__version__,
127
+ summary="Reference host for the prismal engine (REST · SSE · A2A).",
128
+ lifespan=lifespan,
129
+ )
130
+ # Eagerly available so ASGI clients that don't run lifespan still resolve it.
131
+ app.state.settings = settings
132
+ app.state.registry = registry
133
+ app.state.graph_factory = graph_factory
134
+ app.state.agent_card_builder = agent_card_builder
135
+ app.state.agent_card_cache = {}
136
+ app.state.auth_backend = auth_backend
137
+ app.state.usage_store = usage_store
138
+ app.state.run_summary = run_summary
139
+ app.state.a2a_engine_settings = a2a_engine_settings
140
+
141
+ register_exception_handlers(app)
142
+ openapi_examples.install(app) # RH2-07-03: examples served verbatim
143
+ if settings.host_fake_llm_mode:
144
+ app.add_middleware(FakeHeaderMiddleware) # SPEC-RH2-FAK-003
145
+ app.include_router(health.router)
146
+ app.include_router(threads.router)
147
+ app.include_router(a2a.router)
148
+ app.include_router(usage.router)
149
+ app.include_router(agui.router)
150
+ app.include_router(admin.router) # 404 unless host_admin_enabled (ADM-001)
151
+ if settings.host_ws_enabled: # SPEC-RH2-WSK-001
152
+ app.include_router(ws.router)
153
+ # Tenant-scoped A2A routes match `{base}/{tenant}`: registered last so every
154
+ # fixed route above wins (SPEC-RH2-A2A-003).
155
+ app.include_router(
156
+ a2a.tenant_router(
157
+ a2a.base_path(getattr(a2a_engine_settings, "a2a_base_url", None))
158
+ )
159
+ )
160
+ return app
161
+
162
+
163
+ app = create_app()
@@ -0,0 +1,125 @@
1
+ """Authentication seam (RHB-04-*).
2
+
3
+ The host owns only *where the credential comes from on the wire*; the identity
4
+ *mechanism* is engine-side. Backends implement :class:`AuthBackend` and resolve a
5
+ request to an engine ``AgentIdentity`` (or ``None``). The backend is selected by
6
+ ``host_auth_backend`` (``none`` | ``bearer`` | ``oidc``) via
7
+ :func:`build_auth_backend`, pluggable without touching the routes
8
+ (SPEC-RHB-AUT-006).
9
+
10
+ :func:`authenticate` applies the host's strict-mode policy (SPEC-RHB-AUT-005) and
11
+ :func:`resolve_org_id` resolves the tenant (SPEC-RHB-TEN-001).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import TYPE_CHECKING, Any
17
+
18
+ from fastapi import HTTPException, Request
19
+
20
+ from prismal_server.auth.base import AuthBackend, extract_bearer_token
21
+ from prismal_server.auth.bearer import BearerTokenBackend
22
+ from prismal_server.auth.noauth import NoAuthBackend
23
+ from prismal_server.auth.oidc import OidcAuthBackend
24
+ from prismal_server.config import get_settings
25
+
26
+ if TYPE_CHECKING:
27
+ from prismal.identity import AgentIdentity
28
+
29
+ from prismal_server.config import HostSettings
30
+
31
+ __all__ = [
32
+ "AuthBackend",
33
+ "BearerTokenBackend",
34
+ "NoAuthBackend",
35
+ "OidcAuthBackend",
36
+ "authenticate",
37
+ "build_auth_backend",
38
+ "extract_bearer_token",
39
+ "resolve_org_id",
40
+ ]
41
+
42
+
43
+ def build_auth_backend(settings: HostSettings) -> AuthBackend:
44
+ """Select the auth backend by config (SPEC-RHB-AUT-006).
45
+
46
+ The engine ``OidcIdentityProvider`` is constructed only when the ``oidc``
47
+ backend is chosen, so ``none``/``bearer`` hosts pay no OIDC setup cost.
48
+ """
49
+ if settings.host_auth_backend == "bearer":
50
+ return BearerTokenBackend(
51
+ static_tokens=settings.bearer_static_tokens,
52
+ agent_name=settings.dev_identity_agent_name,
53
+ )
54
+ if settings.host_auth_backend == "oidc":
55
+ return OidcAuthBackend()
56
+ return NoAuthBackend(
57
+ did=settings.dev_identity_did,
58
+ agent_name=settings.dev_identity_agent_name,
59
+ )
60
+
61
+
62
+ async def authenticate(request: Request) -> AgentIdentity | None:
63
+ """Resolve the request identity, enforcing strict mode on protected routes.
64
+
65
+ Returns the resolved ``AgentIdentity`` (or ``None`` in non-strict mode). When
66
+ ``host_auth_strict`` and the request is unauthenticated, raises ``401``
67
+ (SPEC-RHB-AUT-005). Health routes never call this, so they stay public.
68
+ """
69
+ backend: AuthBackend = request.app.state.auth_backend
70
+ identity = await backend.resolve(request)
71
+ if identity is None and request.app.state.settings.host_auth_strict:
72
+ raise HTTPException(status_code=401, detail="authentication required")
73
+ return identity
74
+
75
+
76
+ class ForbiddenTenant(Exception):
77
+ """The request names a tenant it may not use (HTTP 403 ``forbidden_tenant``)."""
78
+
79
+ def __init__(self) -> None:
80
+ super().__init__("the requested tenant is not available to this caller")
81
+
82
+
83
+ def resolve_org_id(
84
+ request: Any, identity: AgentIdentity | None, *, for_read: bool = False
85
+ ) -> str | None:
86
+ """Resolve the tenant ``org_id`` (SPEC-RHB-TEN-001).
87
+
88
+ Order: the identity's ``org_id`` if present, else the ``X-Org-Id`` header,
89
+ else ``None`` (single-tenant).
90
+
91
+ WHERE per-tenant config is enabled (``host_tenant_config_dir``), tenants
92
+ carry their own gateway credentials, so tenant selection fails closed:
93
+
94
+ * an ``X-Org-Id`` that differs from the identity's ``org_id`` ⇒
95
+ :class:`ForbiddenTenant`;
96
+ * an ``X-Org-Id`` without an identity ``org_id`` is honoured only in local
97
+ development (``host_auth_backend="none"`` outside ``production``);
98
+ otherwise :class:`ForbiddenTenant` — never a silent fallback to the
99
+ process (operator) tenant.
100
+
101
+ ``for_read=True`` (usage, budget, admin reads) applies those fail-closed
102
+ rules even without tenant config: reading another org's records must never
103
+ be possible by just sending a header.
104
+ """
105
+ header: str | None = request.headers.get("X-Org-Id")
106
+ identity_org: str | None = identity.org_id if identity is not None else None
107
+ state = getattr(getattr(request, "app", None), "state", None)
108
+ settings = getattr(state, "settings", None) or get_settings()
109
+
110
+ if settings.host_tenant_config_dir is None and not for_read:
111
+ # v0.1 behaviour (SPEC-RHB-TEN-001).
112
+ return identity_org or header
113
+
114
+ if identity_org:
115
+ if header is not None and header != identity_org:
116
+ raise ForbiddenTenant()
117
+ return identity_org
118
+ if header is None:
119
+ return None
120
+ if (
121
+ settings.host_auth_backend == "none"
122
+ and settings.host_environment != "production"
123
+ ):
124
+ return header
125
+ raise ForbiddenTenant()
@@ -0,0 +1,42 @@
1
+ """``AuthBackend`` protocol and shared credential parsing (RHB-04-01).
2
+
3
+ A backend resolves an inbound request to an engine ``AgentIdentity`` (or ``None``
4
+ when the request is unauthenticated). Per SPEC-RHB-AUT-001 ``resolve`` MUST NOT
5
+ raise for an unauthenticated request — it returns ``None`` so the caller decides
6
+ policy (public vs. strict).
7
+
8
+ The host owns only *where the credential comes from on the wire*; the identity
9
+ *mechanism* stays engine-side.
10
+
11
+ Seam: identity — ``prismal.identity`` (``AgentIdentity`` / ``OidcIdentityProvider``).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
17
+
18
+ if TYPE_CHECKING:
19
+ from prismal.identity import AgentIdentity
20
+
21
+
22
+ @runtime_checkable
23
+ class AuthBackend(Protocol):
24
+ """Resolve a request to an ``AgentIdentity`` or ``None`` (never raising)."""
25
+
26
+ async def resolve(self, request: Any) -> AgentIdentity | None: ...
27
+
28
+
29
+ def extract_bearer_token(request: Any) -> str | None:
30
+ """Return the ``Authorization: Bearer <token>`` value, or ``None``.
31
+
32
+ Tolerant by construction (SPEC-RHB-AUT-001): a missing header, a non-Bearer
33
+ scheme, or an empty token all yield ``None`` rather than raising.
34
+ """
35
+ header: str | None = request.headers.get("Authorization")
36
+ if not header:
37
+ return None
38
+ scheme, _, token = header.partition(" ")
39
+ token = token.strip()
40
+ if scheme.lower() != "bearer" or not token:
41
+ return None
42
+ return token
@@ -0,0 +1,45 @@
1
+ """``BearerTokenBackend`` (RHB-04-03).
2
+
3
+ Resolves an ``Authorization: Bearer <token>`` credential to an ``AgentIdentity``
4
+ via a static token→DID map (SPEC-RHB-AUT-003). The configured tokens are
5
+ ``SecretStr`` so a raw token never surfaces in logs or responses; an unknown or
6
+ absent token resolves to ``None`` (never raises, SPEC-RHB-AUT-001).
7
+
8
+ The identity carries no ``org_id`` — tenant selection falls back to the
9
+ ``X-Org-Id`` header per SPEC-RHB-TEN-001.
10
+
11
+ Seam: identity — ``prismal.identity.AgentIdentity``.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Any
17
+
18
+ from prismal.identity import DID, AgentIdentity
19
+ from pydantic import SecretStr
20
+
21
+
22
+ class BearerTokenBackend:
23
+ """Resolve a static bearer token to a fixed-DID identity."""
24
+
25
+ def __init__(
26
+ self,
27
+ *,
28
+ static_tokens: dict[str, SecretStr],
29
+ agent_name: str = "bearer-client",
30
+ ) -> None:
31
+ self._static_tokens = static_tokens
32
+ self._agent_name = agent_name
33
+
34
+ async def resolve(self, request: Any) -> AgentIdentity | None:
35
+ from prismal_server.auth.base import extract_bearer_token
36
+
37
+ token = extract_bearer_token(request)
38
+ if token is None:
39
+ return None
40
+ did = self._static_tokens.get(token)
41
+ if did is None:
42
+ return None
43
+ return AgentIdentity(
44
+ did=DID(did.get_secret_value()), agent_name=self._agent_name
45
+ )
@@ -0,0 +1,24 @@
1
+ """``NoAuthBackend`` — dev default (RHB-04-02).
2
+
3
+ Returns a fixed local ``AgentIdentity`` (configurable DID / agent_name) for every
4
+ request, so a fresh clone boots usable with no credentials (SPEC-RHB-AUT-002).
5
+ This is a development convenience; production hosts select ``bearer`` or ``oidc``.
6
+
7
+ Seam: identity — ``prismal.identity.AgentIdentity``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any
13
+
14
+ from prismal.identity import DID, AgentIdentity
15
+
16
+
17
+ class NoAuthBackend:
18
+ """Resolve every request to one fixed, configured identity."""
19
+
20
+ def __init__(self, *, did: str, agent_name: str) -> None:
21
+ self._identity = AgentIdentity(did=DID(did), agent_name=agent_name)
22
+
23
+ async def resolve(self, request: Any) -> AgentIdentity | None:
24
+ return self._identity
@@ -0,0 +1,49 @@
1
+ """``OidcAuthBackend`` (RHB-04-04).
2
+
3
+ Delegates token validation and identity resolution to the engine's
4
+ ``OidcIdentityProvider`` — the host performs no token crypto (SPEC-RHB-AUT-004).
5
+ The bearer value is treated as the caller's DID; the engine provider verifies it
6
+ and resolves the backing ``AgentIdentity``. Any provider error (or an
7
+ unverifiable / unknown DID) resolves to ``None`` rather than raising
8
+ (SPEC-RHB-AUT-001).
9
+
10
+ The synchronous provider calls run in a worker thread so DID resolution never
11
+ blocks the event loop.
12
+
13
+ Seam: identity — ``prismal.identity.OidcIdentityProvider``.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import logging
20
+ from typing import Any
21
+
22
+ from prismal.identity import DID, AgentIdentity, OidcIdentityProvider
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ class OidcAuthBackend:
28
+ """Resolve a bearer DID via the engine OIDC identity provider."""
29
+
30
+ def __init__(self, *, provider: Any | None = None) -> None:
31
+ # Constructed lazily by the caller/factory; ``provider`` is injectable so
32
+ # tests supply a fake and never do real DID resolution.
33
+ self._provider = provider if provider is not None else OidcIdentityProvider()
34
+
35
+ async def resolve(self, request: Any) -> AgentIdentity | None:
36
+ from prismal_server.auth.base import extract_bearer_token
37
+
38
+ token = extract_bearer_token(request)
39
+ if token is None:
40
+ return None
41
+ did = DID(token)
42
+ try:
43
+ verified = await asyncio.to_thread(self._provider.verify, did)
44
+ if not verified:
45
+ return None
46
+ return await asyncio.to_thread(self._provider.resolve, did)
47
+ except Exception: # never propagate — an auth failure is just ``None``
48
+ logger.warning("OIDC identity resolution failed", exc_info=True)
49
+ return None
@@ -0,0 +1,106 @@
1
+ """Host configuration (RHB-01-01).
2
+
3
+ ``HostSettings`` (pydantic-settings, prefix ``PRISMAL_SERVER_``) governs *host*
4
+ concerns only — bind host/port, CORS, auth mode, SSE heartbeat, tenant cap.
5
+ Engine configuration stays engine-side and is read by the engine via its own
6
+ ``ConfigSourcePort`` (SPEC-RHB-CFG-001); this class MUST NOT duplicate it.
7
+ Secret-bearing values are ``SecretStr`` (SPEC-RHB-CFG-002).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from functools import lru_cache
13
+ from pathlib import Path
14
+ from typing import Literal
15
+
16
+ from pydantic import Field, SecretStr, model_validator
17
+ from pydantic_settings import BaseSettings, SettingsConfigDict
18
+
19
+ AuthBackend = Literal["none", "bearer", "oidc"]
20
+ HostEnvironment = Literal["development", "staging", "production"]
21
+ SecretsBackend = Literal["file", "env"]
22
+ UsageStoreKind = Literal["memory", "sqlite"]
23
+
24
+
25
+ class HostSettings(BaseSettings):
26
+ """Host-only settings, populated from ``PRISMAL_SERVER_*`` env vars.
27
+
28
+ See ``SPEC.md`` §7 for the authoritative key table.
29
+ """
30
+
31
+ model_config = SettingsConfigDict(
32
+ env_prefix="PRISMAL_SERVER_",
33
+ env_file=".env",
34
+ env_file_encoding="utf-8",
35
+ extra="ignore",
36
+ )
37
+
38
+ # Bind
39
+ host: str = "0.0.0.0"
40
+ port: int = 8000
41
+ cors_origins: list[str] = []
42
+
43
+ # Auth seam (backends wired in Phase 4)
44
+ host_auth_backend: AuthBackend = "none"
45
+ host_auth_strict: bool = False
46
+ dev_identity_did: str = "did:key:zLocalDev"
47
+ dev_identity_agent_name: str = "local-dev"
48
+ # Static bearer tokens (token → DID) for the dev/bearer backend. Secret by
49
+ # construction so raw tokens never surface in logs or responses.
50
+ bearer_static_tokens: dict[str, SecretStr] = {}
51
+
52
+ # Streaming
53
+ sse_heartbeat_s: int = 15
54
+
55
+ # Multi-tenancy
56
+ host_max_tenants: int = 32
57
+
58
+ # Inbound A2A: gates route presence on the host (404 when off). The engine's
59
+ # own a2a_inbound_enabled / a2a_strict govern the handler and strictness.
60
+ a2a_enabled: bool = True
61
+
62
+ # ── Host v0.2 (SPEC-RH2 §10) ────────────────────────────────────────────
63
+ host_environment: HostEnvironment = "development"
64
+
65
+ # Per-tenant engine config (RH2-01): `<dir>/<org_id>/tenant.yaml` plus
66
+ # secrets from the selected backend. None ⇒ every tenant uses process config.
67
+ host_tenant_config_dir: Path | None = None
68
+ host_secrets_backend: SecretsBackend = "file"
69
+ # File backend root (`<dir>/<org_id>/<key>`). None ⇒
70
+ # `<host_tenant_config_dir>/<org_id>/secrets/<key>`.
71
+ host_secrets_dir: Path | None = None
72
+
73
+ # Usage persistence (RH2-02).
74
+ host_usage_store: UsageStoreKind = "memory"
75
+ host_usage_db_path: Path = Path("usage.db")
76
+ host_usage_retention_days: int = Field(default=90, ge=1)
77
+
78
+ # AG-UI host gate (RH2-03); the engine's agui_events_enabled is also required.
79
+ host_agui_enabled: bool = True
80
+
81
+ # WebSocket transport (RH2-05).
82
+ host_ws_enabled: bool = False
83
+ host_ws_max_message_bytes: int = Field(default=1_048_576, ge=1)
84
+
85
+ # Deterministic fake runtime for credential-free contract tests (RH2-06).
86
+ host_fake_llm_mode: bool = False
87
+
88
+ # Read-only admin endpoints (RH2-06).
89
+ host_admin_enabled: bool = False
90
+ host_admin_scope: str = Field(default="prismal:admin", min_length=1)
91
+
92
+ @model_validator(mode="after")
93
+ def _no_fake_llm_in_production(self) -> HostSettings:
94
+ # SPEC-RH2-FAK-001: a fake model must never answer production traffic.
95
+ if self.host_fake_llm_mode and self.host_environment == "production":
96
+ raise ValueError(
97
+ "host_fake_llm_mode cannot be enabled when "
98
+ "host_environment='production'"
99
+ )
100
+ return self
101
+
102
+
103
+ @lru_cache
104
+ def get_settings() -> HostSettings:
105
+ """Return a process-wide cached ``HostSettings`` instance."""
106
+ return HostSettings()