diff --git a/docs/compositions/reach/operator.md b/docs/compositions/reach/operator.md index 3b886fee..2e910a32 100644 --- a/docs/compositions/reach/operator.md +++ b/docs/compositions/reach/operator.md @@ -711,14 +711,23 @@ uncertain earlier effect. The bot needs Discord's Add Reactions and Embed Links where used; this option does not grant or change permissions. Discord client settings and provider availability still determine whether the GIF animates. -This option requires an already running, warm local `Qwen3.8-27B` at the fixed literal endpoint -`http://127.0.0.1:28000`. The host transport checks fresh `/health` and `/v1/models` observations -for the exact model. It cannot start, activate, evict or replace an engine. An absent or unsuitable -model refuses the turn; document search and another model are not fallbacks. +By default this option requires an already running, warm local `Qwen3.8-27B` at +`http://127.0.0.1:28000`. The private profile's `animator_id` and `model_id` select a different +binding, and `--model-endpoint` a different road: plain HTTP is admitted only for a loopback IP +literal with an explicit port, while HTTPS may name any OpenAI-compatible endpoint with an +optional path prefix. `--model-api-key-file` reads one bearer credential from a private `0600` +file; it never appears in argv. A loopback endpoint is checked with fresh `/health` and +`/v1/models` observations; a remote endpoint has no standard health route, so its `/v1/models` +inventory is the probe, and only the selected model's presence crosses to the worker. A +gateway may report the bare model it reached for a routed id such as `vendor/model`; that exact +suffix is the only accepted alias. The transport cannot start, activate, evict or replace an +engine. An absent or unsuitable model refuses the turn; document search and another model are +not fallbacks. The remote road sends the conversation, persona and selected context to that +provider: choose it deliberately. The default `--model-transport host` reads that loopback endpoint on the operator host. When the already-running model is inside the native Podman pod, explicitly select -`--model-transport podman`. Its bounded transport executes only a fixed Python HTTP helper +`--model-transport podman` (loopback endpoints only). Its bounded transport executes only a fixed Python HTTP helper in the exact `lychd-night-qwen38` container and reads the same loopback endpoint there. Request text crosses standard input, never shell arguments. The helper cannot select a different container, URL, command, model, or runtime transition; it publishes no host port. diff --git a/docs/sepulcher/animator/portal.md b/docs/sepulcher/animator/portal.md index e4894013..09dba69e 100644 --- a/docs/sepulcher/animator/portal.md +++ b/docs/sepulcher/animator/portal.md @@ -30,6 +30,14 @@ dashboard appearing there is a candidate classification, not a delivered connect local-before-paid, and x402 gates are not current enforcement. Declare only a credential, data boundary, and spend profile that could become eligible after those gates are delivered. +!!! danger "Local opt-in: `allow_dispatch`" + A Rune with `probe = true` and `allow_dispatch = true` is exempt from the quarantine below: + once its authenticated `/models` probe lists the declared model, Dispatcher grants it like any + warm capability. None of the privatization, secure-mode, local-before-paid or spend gates + exist; the operator's flag is the only authority. Among equally ready candidates a local + Soulstone still outranks an admitted Portal. Archive, semantic, Scout and Creation workflows + remain local-only. + Three limits matter: 1. **No automatic cloud replay.** A failed local call is not replayed through a Portal; no current @@ -237,6 +245,7 @@ cannot serve as a Reach provider path, including in the public VPS profile. | `models` | `[]` | Explicit declarations; empty means zero capabilities. | | `generation` | `null` | Portal-wide generation overlay. | | `probe` | `false` | Opt in to the unauthenticated `/models` probe. | +| `allow_dispatch` | `false` | Operator opt-in: Dispatcher may grant this Portal, sending prompts and history to `base_url`. Requires `probe = true`; the probe then sends the Portal secret as a bearer credential. | Each model requires provider-facing `id`; it may add capability hints and a per-model generation overlay. Operator description belongs to the Portal, not each model block. Capability hints default to chat with text admission. Verification diff --git a/scripts/reach_model_broker.py b/scripts/reach_model_broker.py index f5e9d78d..64fbbe4e 100644 --- a/scripts/reach_model_broker.py +++ b/scripts/reach_model_broker.py @@ -7,6 +7,8 @@ from __future__ import annotations import asyncio +import ipaddress +import re import time from collections.abc import Callable from typing import Any, cast @@ -28,17 +30,52 @@ MODEL_ENDPOINT = "http://127.0.0.1:28000" MAX_MODEL_IDS = 32 +MAX_REMOTE_MODEL_IDS = 4096 MAX_MODEL_ID_CHARS = 256 +_ORIGIN = re.compile( + r"^(?Phttps?)://(?P\[[0-9a-fA-F:.]+\]|[A-Za-z0-9.-]+)(?::(?P[0-9]{1,5}))?(?P(?:/[A-Za-z0-9._~-]+)*)$" +) +_API_KEY = re.compile(r"^[\x21-\x7e]{1,4096}$") + + +def is_loopback_endpoint(endpoint: str) -> bool: + """Whether the endpoint is a plain-HTTP loopback IP literal, the original local road.""" + matched = _ORIGIN.fullmatch(endpoint) + if matched is None or matched["scheme"] != "http": + return False + try: + return ipaddress.ip_address(matched["host"].strip("[]")).is_loopback + except ValueError: + return False def validate_endpoint(endpoint: str) -> str: - """Pin the one literal local transport; caller frames cannot alter its road.""" - if endpoint != MODEL_ENDPOINT: + """Admit a loopback HTTP origin or an HTTPS endpoint; caller frames cannot alter it. + + Plain HTTP is limited to a loopback IP literal with an explicit port and no path. + HTTPS may name any host and an optional path prefix. Credentials, queries, + fragments, trailing slashes and port zero are refused. + """ + matched = _ORIGIN.fullmatch(endpoint) + port = int(matched["port"]) if matched is not None and matched["port"] is not None else None + if ( + matched is None + or (port is not None and not 1 <= port <= 65535) # noqa: PLR2004 - TCP port range + or (matched["scheme"] == "http" and (port is None or matched["path"] or not is_loopback_endpoint(endpoint))) + ): msg = "model_endpoint" raise ValueError(msg) return endpoint +def validate_api_key(value: str) -> str: + """Admit one printable header-safe bearer credential without echoing it.""" + if _API_KEY.fullmatch(value) is None: + msg = "model_api_key" + raise ValueError(msg) + return value + + def _object(value: Any, code: str) -> dict[str, Any]: if not isinstance(value, dict): raise TypeError(code) @@ -100,10 +137,13 @@ def __init__( endpoint: str = MODEL_ENDPOINT, transport: httpx2.AsyncBaseTransport | None = None, trace: TraceSink | None = None, + api_key: str | None = None, ) -> None: """Bind transport to a frozen profile without opening any network connection.""" self.profile = profile self.endpoint = validate_endpoint(endpoint) + self._local = is_loopback_endpoint(self.endpoint) + self._api_key = validate_api_key(api_key) if api_key is not None else None self.transport = transport self._busy = False self.trace = TraceEmitter("host", trace) @@ -157,10 +197,12 @@ async def _request( ) async def _probe(self, client: httpx2.AsyncClient) -> str: - await self._request(client, "GET", "/health") + # A remote OpenAI-compatible API has no standard /health route; its inventory is the probe. + if self._local: + await self._request(client, "GET", "/health") value = _object(await self._request(client, "GET", "/v1/models"), "model_inventory") entries = _array(value.get("data"), "model_inventory") - if len(entries) > MAX_MODEL_IDS: + if len(entries) > (MAX_MODEL_IDS if self._local else MAX_REMOTE_MODEL_IDS): msg = "model_inventory" raise ValueError(msg) model_ids: list[str] = [] @@ -178,6 +220,9 @@ async def _probe(self, client: httpx2.AsyncClient) -> str: if self.profile.model_id not in ids: msg = "model_identity" raise LookupError(msg) + if len(ids) > MAX_MODEL_IDS: + # A gateway catalogue can exceed the wire bound; the worker needs only its own model. + ids = (self.profile.model_id,) return canonical_json(OperatorModelProbe(healthy=True, model_ids=ids).model_dump(mode="json")) async def _generate( @@ -191,7 +236,7 @@ async def _generate( await self._request(client, "POST", "/v1/chat/completions", body, authorize=authorize), "model_completion" ) self._completion_metadata = _completion_observation(value) - if value.get("model") != self.profile.model_id: + if not self.profile.serves(value.get("model")): msg = "model_completion" raise ValueError(msg) choices = _array(value.get("choices"), "model_choices") @@ -231,7 +276,11 @@ async def handle( create_async_http_client( transport=self.transport, timeout=httpx2.Timeout(budget), - headers={"Content-Type": "application/json", "Accept-Encoding": "identity"}, + headers={ + "Content-Type": "application/json", + "Accept-Encoding": "identity", + **({"Authorization": f"Bearer {self._api_key}"} if self._api_key else {}), + }, ) as client, ): payload = await ( diff --git a/scripts/reach_operator.py b/scripts/reach_operator.py index d10097bf..298bd065 100644 --- a/scripts/reach_operator.py +++ b/scripts/reach_operator.py @@ -41,7 +41,13 @@ from lychd.interface.discord.operator_io import load_policy if __package__: - from scripts.reach_model_broker import MODEL_ENDPOINT, OperatorModelBroker, validate_endpoint + from scripts.reach_model_broker import ( + MODEL_ENDPOINT, + OperatorModelBroker, + is_loopback_endpoint, + validate_api_key, + validate_endpoint, + ) from scripts.reach_observation import ReachObservationPublisher from scripts.reach_operator_transport import remove_owned, safe_environment, strict_object, supervise from scripts.reach_podman_model import PodmanModelTransport @@ -49,7 +55,13 @@ from scripts.reach_trace_archive import TraceArchive from scripts.reach_trace_capture import TraceCapture else: - from reach_model_broker import MODEL_ENDPOINT, OperatorModelBroker, validate_endpoint + from reach_model_broker import ( + MODEL_ENDPOINT, + OperatorModelBroker, + is_loopback_endpoint, + validate_api_key, + validate_endpoint, + ) from reach_observation import ReachObservationPublisher from reach_operator_transport import remove_owned, safe_environment, strict_object, supervise from reach_podman_model import PodmanModelTransport @@ -476,6 +488,27 @@ def instance_lock(state: Path) -> Generator[None]: os.close(descriptor) +def model_credential(args: argparse.Namespace) -> str | None: + """Validate the model road and read its optional bearer credential from a private file. + + The Podman transport execs into the local model container and reaches only its loopback + endpoint, so it cannot carry a remote endpoint. The credential never appears in argv. + """ + try: + validate_endpoint(args.model_endpoint) + except ValueError: + _fail("operator_model_endpoint") + if args.model_transport == "podman" and not is_loopback_endpoint(args.model_endpoint): + _fail("operator_model_transport") + if args.model_api_key_file is None: + return None + raw = read_regular(args.model_api_key_file, 8192, private=True) + try: + return validate_api_key(raw.decode("ascii").strip()) + except (UnicodeDecodeError, ValueError): + _fail("operator_model_api_key") + + def container_arguments(engine: str, image: str, cidfile: Path, *, offline: bool) -> list[str]: """Build a closed boundary without published ports or inherited environment.""" if IMAGE_ID.fullmatch(image) is None: @@ -585,7 +618,7 @@ def run_containers(args: argparse.Namespace) -> int: # noqa: C901, PLR0912, PLR if args.onboarding_profile is not None: onboarding_raw = read_regular(args.onboarding_profile, 4096, private=True) strict_object(onboarding_raw) - validate_endpoint(args.model_endpoint) + model_api_key = model_credential(args) publications = None if args.publications_dir is not None: publications = private_directory(args.publications_dir) @@ -689,6 +722,7 @@ def run_containers(args: argparse.Namespace) -> int: # noqa: C901, PLR0912, PLR endpoint=args.model_endpoint, transport=PodmanModelTransport() if args.model_transport == "podman" else None, trace=capture.host, + api_key=model_api_key, ), research_broker=research_broker, capture=capture, @@ -786,6 +820,7 @@ def parser() -> argparse.ArgumentParser: child.add_argument("--conversation-profile", type=Path) child.add_argument("--onboarding-profile", type=Path) child.add_argument("--model-endpoint", default=MODEL_ENDPOINT) + child.add_argument("--model-api-key-file", type=Path) child.add_argument("--model-transport", choices=("host", "podman"), default="host") child.add_argument("--research-profile", type=Path) child.add_argument("--research-state-dir", type=Path) diff --git a/scripts/reach_probe.py b/scripts/reach_probe.py index 6efc47bc..bbbcbdc6 100644 --- a/scripts/reach_probe.py +++ b/scripts/reach_probe.py @@ -94,14 +94,14 @@ if __package__: from scripts import reach_operator as operator from scripts import reach_operator_transport as transport - from scripts.reach_model_broker import OperatorModelBroker + from scripts.reach_model_broker import MODEL_ENDPOINT, OperatorModelBroker from scripts.reach_podman_model import PodmanModelTransport from scripts.reach_trace_archive import TraceArchive from scripts.reach_trace_capture import TraceCapture else: import reach_operator as operator import reach_operator_transport as transport - from reach_model_broker import OperatorModelBroker + from reach_model_broker import MODEL_ENDPOINT, OperatorModelBroker from reach_podman_model import PodmanModelTransport from reach_trace_archive import TraceArchive from reach_trace_capture import TraceCapture @@ -1449,7 +1449,15 @@ async def cleanup_owned( async def supervise( - turn: Turn, arguments: list[str], cidfile: Path, *, engine: str, model_transport: str, budget_seconds: float + turn: Turn, + arguments: list[str], + cidfile: Path, + *, + engine: str, + model_transport: str, + budget_seconds: float, + model_endpoint: str = MODEL_ENDPOINT, + model_api_key: str | None = None, ) -> None: """Retire the one owned process and exact CID on every terminal path, without retries.""" process: asyncio.subprocess.Process | None = None @@ -1483,6 +1491,8 @@ async def supervise( turn.profile, transport=PodmanModelTransport() if model_transport == "podman" else None, trace=turn.capture.host, + endpoint=model_endpoint, + api_key=model_api_key, ) tasks = [ asyncio.create_task(exchange(turn, spawned, broker)), @@ -1517,6 +1527,8 @@ def parser() -> argparse.ArgumentParser: result.add_argument("--scenario", type=Path, required=True) result.add_argument("--output", type=Path, required=True) result.add_argument("--model-transport", choices=("podman", "http"), default="podman") + result.add_argument("--model-endpoint", default=MODEL_ENDPOINT) + result.add_argument("--model-api-key-file", type=Path) result.add_argument("--timeout-seconds", type=int, default=420) result.add_argument("--capture-model-output", action="store_true") return result @@ -1638,6 +1650,8 @@ def run_turn( cidfile, engine=args.engine, model_transport=args.model_transport, + model_endpoint=args.model_endpoint, + model_api_key=operator.model_credential(args), budget_seconds=args.timeout_seconds, ) ) diff --git a/src/lychd/compositions/reach/operator_model.py b/src/lychd/compositions/reach/operator_model.py index c1f9388e..cef0603b 100644 --- a/src/lychd/compositions/reach/operator_model.py +++ b/src/lychd/compositions/reach/operator_model.py @@ -1,4 +1,4 @@ -"""Exact vLLM development capability through a fixed host relay, under ordinary grants.""" +"""Exact operator-bound model capability through a host relay, under ordinary grants.""" from __future__ import annotations @@ -78,7 +78,7 @@ async def handle_async_request(self, request: httpx2.Request) -> httpx2.Response choice = result["choices"][0] message = choice["message"] valid = ( - result["model"] == self.profile.model_id + self.profile.serves(result["model"]) and choice.get("finish_reason") != "length" and (not thinking or choice.get("finish_reason") == "stop") and len(result["choices"]) == 1 diff --git a/src/lychd/compositions/reach/operator_model_wire.py b/src/lychd/compositions/reach/operator_model_wire.py index accab468..f2fbed55 100644 --- a/src/lychd/compositions/reach/operator_model_wire.py +++ b/src/lychd/compositions/reach/operator_model_wire.py @@ -1,4 +1,4 @@ -"""Closed values for one operator-selected local model and static voice, not Mirror.""" +"""Closed values for one operator-selected model and static voice, not Mirror.""" from __future__ import annotations @@ -26,6 +26,11 @@ MAX_MODEL_PAYLOAD_BYTES = 1_048_576 DECISION_SCHEMA_VERSION = 4 +DEFAULT_ANIMATOR_ID = "night-qwen38" +DEFAULT_MODEL_ID = "Qwen3.8-27B" +# Provider-facing model ids, including gateway routes such as ``vendor/model``. +MODEL_ID_PATTERN = r"^[A-Za-z0-9][A-Za-z0-9._:/@+-]{0,255}$" +ANIMATOR_ID_PATTERN = r"^[a-z][a-z0-9-]{0,62}$" def canonical_json(value: Any) -> str: @@ -57,8 +62,8 @@ class OperatorConversationProfile(Record): schema_version: Literal[1] = 1 persona_name: str = Field(min_length=1, max_length=80) persona_instructions: str = Field(min_length=1, max_length=8000, repr=False) - animator_id: Literal["night-qwen38"] = "night-qwen38" - model_id: Literal["Qwen3.8-27B"] = "Qwen3.8-27B" + animator_id: str = Field(default=DEFAULT_ANIMATOR_ID, pattern=ANIMATOR_ID_PATTERN) + model_id: str = Field(default=DEFAULT_MODEL_ID, pattern=MODEL_ID_PATTERN) adaptive_context: bool = False enable_thinking: bool = False thinking_phases: tuple[DecisionPhase, ...] = Field(default=(), max_length=len(DECISION_PHASES)) @@ -144,6 +149,16 @@ def digest(self) -> str: def capability_key(self) -> str: return f"{self.animator_id}:chat:{self.model_id}" + def serves(self, observed: object) -> bool: + """Whether a completion's reported model is this binding's model. + + Gateways route a prefixed id such as ``vendor/model`` and report the bare + ``model`` they reached; that one exact suffix is the only accepted alias. + """ + if not isinstance(observed, str) or not observed: + return False + return observed == self.model_id or ("/" not in observed and self.model_id.endswith("/" + observed)) + class OperatorModelRequest(Record): """Only exact readiness or bounded text formation may cross to the trusted host.""" @@ -196,7 +211,7 @@ class _Message(Record): class _Generation(Record): - model: Literal["Qwen3.8-27B"] + model: str = Field(pattern=MODEL_ID_PATTERN) messages: tuple[_Message, ...] = Field(min_length=1, max_length=60) max_tokens: int = Field(ge=1, le=4096) temperature: float = Field(ge=0, le=1) diff --git a/src/lychd/domain/animation/capabilities.py b/src/lychd/domain/animation/capabilities.py index 876bd92a..70459108 100644 --- a/src/lychd/domain/animation/capabilities.py +++ b/src/lychd/domain/animation/capabilities.py @@ -63,6 +63,13 @@ class CapabilitySpec(BaseModel): generation_profile: GenerationProfile = Field(default_factory=GenerationProfile) is_dynamic: bool = False concurrency: ConcurrencyIntent = Field(default_factory=ConcurrencyIntent) + # Operator opt-in copied from the Portal Rune's ``allow_dispatch``; meaningless for Soulstones. + egress_admitted: bool = False + + @property + def egress_refused(self) -> bool: + """Whether this is a Portal whose operator has not opted in to dispatch.""" + return self.source_kind is SourceKind.PORTAL and not self.egress_admitted def require_executable_v1_family(self) -> None: """Refuse metadata-only labels before readiness can request physical work. diff --git a/src/lychd/domain/animation/schemas/runes/animators.py b/src/lychd/domain/animation/schemas/runes/animators.py index aafb1de5..da35641c 100644 --- a/src/lychd/domain/animation/schemas/runes/animators.py +++ b/src/lychd/domain/animation/schemas/runes/animators.py @@ -295,6 +295,13 @@ class PortalConfig(AnimatorConfig, ABC): default=False, description="Opt-in live reachability probe (no surprise egress by default).", ) + allow_dispatch: bool = Field( + default=False, + description=( + "Operator opt-in: let Dispatcher grant this Portal, sending prompts and history to " + "base_url. Requires probe = true; the probe then authenticates with the Portal secret." + ), + ) @field_validator("generation") @classmethod @@ -316,6 +323,9 @@ def _validate_api_key_secret_name(cls, value: str | None) -> str | None: @model_validator(mode="after") def _validate_model_ids(self) -> PortalConfig: _require_unique_model_ids(self.models) + if self.allow_dispatch and not self.probe: + msg = "allow_dispatch requires probe = true; an unobserved Portal can never be granted" + raise ValueError(msg) return self diff --git a/src/lychd/domain/animation/services/adapters/registry.py b/src/lychd/domain/animation/services/adapters/registry.py index dac3ad01..bdf4225c 100644 --- a/src/lychd/domain/animation/services/adapters/registry.py +++ b/src/lychd/domain/animation/services/adapters/registry.py @@ -249,6 +249,7 @@ def _build_portal_capability_specs(self, portal: PortalConfig) -> list[Capabilit is_dynamic=False, # A Portal is remote: LychD does not own its lifecycle (ADR-22). concurrency=ConcurrencyIntent(dedicated=False), + egress_admitted=portal.allow_dispatch, ) for family in synthesize_families(info, hints) ) diff --git a/src/lychd/domain/animation/services/adapters/runtimes/shared.py b/src/lychd/domain/animation/services/adapters/runtimes/shared.py index 192d9af7..13589367 100644 --- a/src/lychd/domain/animation/services/adapters/runtimes/shared.py +++ b/src/lychd/domain/animation/services/adapters/runtimes/shared.py @@ -74,6 +74,7 @@ async def probe_openai_compatible_link( *, timeout_seconds: float = PROBE_TIMEOUT_SECONDS, path: str = "/models", + api_key: str | None = None, ) -> Link: """Probe link liveness and the exact OpenAI-compatible model inventory. @@ -85,6 +86,8 @@ async def probe_openai_compatible_link( ``up=False``. A reachable but non-conformant response keeps the physical link live and records a separate inventory error; this function never raises (A3-U3: async HTTPX2 replaces the blocking ``urlopen``). + + ``api_key`` adds a bearer credential; only an operator-admitted Portal passes one. """ normalized_path = path if path.startswith("/") else f"/{path}" request_url = f"{connector.base_url.rstrip('/')}{normalized_path}" @@ -92,7 +95,7 @@ async def probe_openai_compatible_link( payload = await request_json( "GET", request_url, - headers={"Accept": "application/json"}, + headers={"Accept": "application/json"} | ({"Authorization": f"Bearer {api_key}"} if api_key else {}), timeout=timeout_seconds, ) except HttpJsonError as exc: diff --git a/src/lychd/domain/animation/services/adapters/surfaces.py b/src/lychd/domain/animation/services/adapters/surfaces.py index ac4e06a7..a84186a6 100644 --- a/src/lychd/domain/animation/services/adapters/surfaces.py +++ b/src/lychd/domain/animation/services/adapters/surfaces.py @@ -142,6 +142,10 @@ def _select_model_surface(self, requested: str | None) -> ModelSurface: return self._default_surface + def resolve_api_key(self) -> str | None: + """Read the mounted Portal credential for an operator-admitted authenticated probe.""" + return self._resolve_api_key() + def _resolve_api_key(self) -> str | None: """Resolve API key value from mounted Podman secret files when configured.""" if not self._api_key_secret_name: diff --git a/src/lychd/domain/animation/services/registry.py b/src/lychd/domain/animation/services/registry.py index a1db36b3..5c46226c 100644 --- a/src/lychd/domain/animation/services/registry.py +++ b/src/lychd/domain/animation/services/registry.py @@ -415,7 +415,7 @@ async def issue_grant( spec = self._capabilities.get(key) if spec is None: raise CapabilityUnavailable(key, "unknown capability") - if spec.source_kind is SourceKind.PORTAL: + if spec.egress_refused: raise CapabilityUnavailable(key, "portal egress admission is not configured") spec.require_executable_v1_family() diff --git a/src/lychd/domain/cortex/dispatcher.py b/src/lychd/domain/cortex/dispatcher.py index 11671001..f486f529 100644 --- a/src/lychd/domain/cortex/dispatcher.py +++ b/src/lychd/domain/cortex/dispatcher.py @@ -175,12 +175,13 @@ async def _grant_for_spec(self, spec: CapabilitySpec, *, holder: str) -> Capabil @staticmethod def _require_egress_admission(spec: CapabilitySpec) -> None: - """Quarantine remote execution until a typed egress authority exists. + """Refuse remote execution unless the operator opted the Portal in. Portal declarations and health remain observable through the registry/Nexus, - but dispatching prompts or history to them is a deny-by-default boundary. + but dispatching prompts or history to them is deny-by-default: only a Rune + with ``allow_dispatch = true`` is eligible. """ - if spec.source_kind is SourceKind.PORTAL: + if spec.egress_refused: raise CapabilityUnavailable( spec.key, "portal egress admission is not configured", @@ -263,7 +264,7 @@ def _resolve_spec( continue if requires_tools and spec.supports_tools is not True: continue - if spec.source_kind is SourceKind.PORTAL: + if spec.egress_refused: quarantined_portal = True continue # Cached failure and invalidation do not remove the declared route. @@ -296,8 +297,11 @@ def _normalize_family(self, intent_type: str) -> CapabilityFamily: def _candidate_sort_key( self, candidate: tuple[CapabilitySpec, CapabilityState], - ) -> tuple[bool, bool, bool, bool, str, str]: - """Prefer open admission before warmth so a draining runtime gets no new work.""" + ) -> tuple[bool, bool, bool, bool, bool, str, str]: + """Prefer open admission before warmth so a draining runtime gets no new work. + + Among equally ready candidates a local Soulstone outranks an admitted Portal. + """ spec, state = candidate draining = self._leases.admission(spec.animator_name) is AnimatorAdmission.DRAINING return ( @@ -305,6 +309,7 @@ def _candidate_sort_key( state.phase is CapabilityPhase.ERROR, not state.is_active, not state.warm, + spec.source_kind is SourceKind.PORTAL, spec.animator_name, spec.key, ) diff --git a/src/lychd/extensions/builtin/animator/register.py b/src/lychd/extensions/builtin/animator/register.py index 73f31f38..4128f700 100644 --- a/src/lychd/extensions/builtin/animator/register.py +++ b/src/lychd/extensions/builtin/animator/register.py @@ -2,6 +2,7 @@ from typing import TYPE_CHECKING +from lychd.domain.animation.links import Link from lychd.domain.animation.model_factory import validate_openai_interface_target from lychd.domain.animation.schemas import ModelSurface, OpenAICompatibleProvider, PortalConfig from lychd.domain.animation.services.adapters.catalog import model_info_from_portal_model @@ -57,7 +58,19 @@ async def probe_openai_portal(animator: RuntimeAnimator) -> None: if not isinstance(connector, OpenAICompatibleConnector): msg = "OpenAI-compatible Portal probe received a runtime with another connector contract." raise TypeError(msg) - connector.set_link(await probe_openai_compatible_link(connector)) + rune = animator.rune + api_key: str | None = None + if isinstance(rune, PortalConfig) and rune.allow_dispatch: + # An admitted Portal already sends this credential on every call; the bare probe + # stays unauthenticated for every other declaration. + try: + api_key = connector.resolve_api_key() + except RuntimeError as exc: + connector.set_observed_model_ids(None) + connector.set_inventory_error(None) + connector.set_link(Link(up=False, reason=str(exc))) + return + connector.set_link(await probe_openai_compatible_link(connector, api_key=api_key)) def register(context: ExtensionRegistrationContext) -> None: diff --git a/tests/unit/cli/test_reach_operator_model.py b/tests/unit/cli/test_reach_operator_model.py index 0b566540..1284ecf7 100644 --- a/tests/unit/cli/test_reach_operator_model.py +++ b/tests/unit/cli/test_reach_operator_model.py @@ -327,15 +327,112 @@ async def test_multiplexer_keeps_model_payload_off_discord_pipe_and_refuses_unkn "http://localhost:28000", "http://127.0.0.1:28000/", "http://127.0.0.1:28000?q=1", - "https://127.0.0.1:28000", + "http://127.0.0.1", + "http://127.0.0.1:0", + "http://127.0.0.1:28000/v1", + "http://10.0.0.5:28000", "http://user:pass@127.0.0.1:28000", + "https://user:pass@gateway.example", + "https://gateway.example/", + "https://gateway.example/v1?q=1", + "https://gateway.example#fragment", + "ftp://gateway.example", ], ) -def test_endpoint_is_one_literal_fixed_road(endpoint: str) -> None: +def test_endpoint_refuses_plain_remote_http_credentials_and_url_state(endpoint: str) -> None: with pytest.raises(ValueError, match="model_endpoint"): validate_endpoint(endpoint) +@pytest.mark.parametrize( + "endpoint", + [ + "http://127.0.0.1:28000", + "http://127.0.0.1:9000", + "http://[::1]:28000", + "https://gateway.example", + "https://gateway.example:8443/zen/go", + ], +) +def test_endpoint_admits_loopback_http_and_any_https(endpoint: str) -> None: + assert validate_endpoint(endpoint) == endpoint + + +@pytest.mark.asyncio +async def test_remote_gateway_probe_skips_health_bounds_inventory_and_accepts_routed_model() -> None: + selected = OperatorConversationProfile( + persona_name="Fixture", + persona_instructions="Answer plainly.", + animator_id="gateway", + model_id="vendor/small-model", + ) + observed: list[httpx2.Request] = [] + catalogue = [{"id": f"other/model-{index}"} for index in range(600)] + [{"id": "vendor/small-model"}] + + def handle(value: httpx2.Request) -> httpx2.Response: + observed.append(value) + assert str(value.url).startswith("https://gateway.example/zen/v1/") + assert value.headers["authorization"] == "Bearer sk-fixture" + if value.url.path == "/zen/v1/models": + return httpx2.Response(200, json={"data": catalogue}) + assert value.url.path == "/zen/v1/chat/completions" + assert json.loads(value.content)["model"] == "vendor/small-model" + # Gateways report the bare model they reached. + return httpx2.Response( + 200, + json={ + "model": "small-model", + "choices": [{"message": {"role": "assistant", "content": "Hi"}, "finish_reason": "stop"}], + }, + ) + + broker = OperatorModelBroker( + selected, + endpoint="https://gateway.example/zen", + api_key="sk-fixture", + transport=httpx2.MockTransport(handle), + ) + probe = await broker.handle( + OperatorModelRequest(call_id=str(uuid4()), binding_digest=selected.digest, operation="probe", payload_json="{}") + ) + assert probe.status == "ok" + assert json.loads(probe.payload_json) == {"healthy": True, "model_ids": ["vendor/small-model"]} + body = { + "model": "vendor/small-model", + "messages": [{"role": "system", "content": "Answer plainly."}, {"role": "user", "content": "Hello"}], + "max_tokens": selected.max_tokens, + "temperature": selected.temperature, + "stream": False, + "chat_template_kwargs": {"enable_thinking": False}, + } + result = await broker.handle( + OperatorModelRequest( + call_id=str(uuid4()), + binding_digest=selected.digest, + operation="generate", + payload_json=canonical_json(body), + ) + ) + assert result.status == "ok" + assert [item.url.path for item in observed] == ["/zen/v1/models", "/zen/v1/chat/completions"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("reported", ["other-model", "vendor/other", "", None]) +async def test_completion_from_a_different_model_is_still_refused(reported: object) -> None: + def handle(value: httpx2.Request) -> httpx2.Response: + return httpx2.Response(200, json=completion() | {"model": reported}) + + result = await OperatorModelBroker(profile(), transport=httpx2.MockTransport(handle)).handle(request()) + assert (result.status, result.reason) == ("error", "model_protocol") + + +@pytest.mark.parametrize("value", ["", "two words", "line\nbreak", "x" * 4097]) +def test_api_key_must_be_one_printable_header_token(value: str) -> None: + with pytest.raises(ValueError, match="model_api_key"): + OperatorModelBroker(profile(), api_key=value) + + def test_profile_is_frozen_worker_only_and_publication_mount_is_edge_only( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/unit/compositions/test_reach_operator_model.py b/tests/unit/compositions/test_reach_operator_model.py index c871bb80..8cc66316 100644 --- a/tests/unit/compositions/test_reach_operator_model.py +++ b/tests/unit/compositions/test_reach_operator_model.py @@ -2187,3 +2187,61 @@ def test_explicit_210000_input_bound_preserves_default_and_physical_limits() -> payload["messages"] = [{"role": "system", "content": "\x00" * 180_000}] with pytest.raises(ValueError, match="model_payload"): validate_generation_payload(selected, canonical_json(payload)) + + +def test_model_binding_is_configurable_and_keeps_the_legacy_defaults() -> None: + legacy = profile() + assert (legacy.animator_id, legacy.model_id) == ("night-qwen38", "Qwen3.8-27B") + assert legacy.digest == LEGACY_PROFILE_DIGEST + routed = OperatorConversationProfile( + persona_name="Example", + persona_instructions="Be useful.", + animator_id="gateway", + model_id="vendor/small-model", + ) + assert routed.capability_key == "gateway:chat:vendor/small-model" + assert routed.digest != legacy.digest + payload = canonical_json(body() | {"model": "vendor/small-model"}) + assert validate_generation_payload(routed, payload)["model"] == "vendor/small-model" + with pytest.raises(ValueError, match="model_generation_binding"): + validate_generation_payload(routed, canonical_json(body())) + + +@pytest.mark.parametrize( + ("observed", "accepted"), + [ + ("vendor/small-model", True), + ("small-model", True), + ("model", False), + ("other/small-model", False), + ("", False), + (None, False), + (7, False), + ], +) +def test_served_model_accepts_only_the_exact_id_or_its_gateway_route_suffix( + observed: object, *, accepted: bool +) -> None: + routed = OperatorConversationProfile( + persona_name="Example", persona_instructions="Be useful.", model_id="vendor/small-model" + ) + assert routed.serves(observed) is accepted + assert profile().serves("Qwen3.8-27B") + assert not profile().serves("27B") + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("model_id", ""), + ("model_id", "has space"), + ("model_id", "/leading"), + ("animator_id", "Upper"), + ("animator_id", ""), + ], +) +def test_model_binding_identifiers_are_closed(field: str, value: str) -> None: + with pytest.raises(ValidationError): + OperatorConversationProfile.model_validate( + {"persona_name": "Example", "persona_instructions": "Be useful.", field: value} + ) diff --git a/tests/unit/domain/animation/test_portals.py b/tests/unit/domain/animation/test_portals.py index 82908280..6a1596d6 100644 --- a/tests/unit/domain/animation/test_portals.py +++ b/tests/unit/domain/animation/test_portals.py @@ -474,3 +474,131 @@ def test_portal_registration_collision_reports_both_registrants() -> None: RegisteredPortalDefinition(registrant_id="crypt:collision", definition=_OPENAI_PORTAL), ] ) + + +# --- operator-admitted dispatch --------------------------------------------- + + +def test_allow_dispatch_requires_probe() -> None: + with pytest.raises(ValidationError, match="allow_dispatch requires probe"): + OpenAIPortalConfig.model_validate({"name": "remote", "allow_dispatch": True}) + + +@pytest.mark.asyncio +async def test_admitted_portal_probes_with_its_credential_and_grants( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + httpx2_mock: respx.MockRouter, +) -> None: + authorizations: list[str | None] = [] + + def inventory(request: httpx.Request) -> httpx.Response: + authorizations.append(request.headers.get("Authorization")) + return httpx.Response(200, json={"object": "list", "data": [{"id": "vendor/small"}]}) + + route = httpx2_mock.get("https://gateway.example/v1/models").mock(side_effect=inventory) + secrets = tmp_path / "secrets" + secrets.mkdir(parents=True, exist_ok=True) + (secrets / "portal_gateway").write_text("sk-test\n", encoding="utf-8") + monkeypatch.setenv("LYCHD_SECRET_ROOT", str(secrets)) + + runes_dir = tmp_path / "runes" + _write( + runes_dir / "animator" / "portals" / "openai" / "gateway.toml", + """ + name = "gateway" + provider_name = "openai-compatible" + base_url = "https://gateway.example/v1" + api_key_secret_name = "portal_gateway" + probe = true + allow_dispatch = true + + [[models]] + id = "vendor/small" + [models.capabilities] + supports_tools = true + """, + ) + registry = AnimatorRegistry( + declarations=_portal_declarations(runes_dir), + runtime_adapters=[], + portal_definitions=[_OPENAI_PORTAL], + ) + leases = LeaseLedger() + dispatcher = Dispatcher(registry=registry, leases=leases) + + spec = registry.get_capability("gateway:chat:vendor/small") + assert spec is not None + assert spec.egress_admitted is True + + async with dispatcher.lease_grant( + family="chat", capability_key="gateway:chat:vendor/small", run_id="r1", requires_tools=True + ) as grant: + assert grant.spec.key == "gateway:chat:vendor/small" + assert grant.model is not None + assert leases.active() == [] + assert route.called + assert authorizations + assert set(authorizations) == {"Bearer sk-test"} + + +def test_unadmitted_portal_probe_stays_unauthenticated(tmp_path: Path, httpx2_mock: respx.MockRouter) -> None: + authorizations: list[str | None] = [] + + def inventory(request: httpx.Request) -> httpx.Response: + authorizations.append(request.headers.get("Authorization")) + return httpx.Response(200, json={"object": "list", "data": [{"id": "gpt-x"}]}) + + route = httpx2_mock.get("https://api.openai.com/v1/models").mock(side_effect=inventory) + runes_dir = tmp_path / "runes" + _write( + runes_dir / "animator" / "portals" / "openai" / "live.toml", + """ + name = "live" + probe = true + api_key_secret_name = "portal_never_read" + [[models]] + id = "gpt-x" + """, + ) + registry = AnimatorRegistry( + declarations=_portal_declarations(runes_dir), + runtime_adapters=[], + portal_definitions=[_OPENAI_PORTAL], + ) + registry.load() + + assert route.called + assert authorizations == [None] + assert registry.list_capability_states()[0].phase is CapabilityPhase.WARM + + +def test_admitted_portal_with_missing_secret_is_cold( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, httpx2_mock: respx.MockRouter +) -> None: + route = httpx2_mock.get("https://api.openai.com/v1/models").mock(return_value=httpx.Response(200, json={})) + monkeypatch.setenv("LYCHD_SECRET_ROOT", str(tmp_path / "absent")) + runes_dir = tmp_path / "runes" + _write( + runes_dir / "animator" / "portals" / "openai" / "live.toml", + """ + name = "live" + probe = true + allow_dispatch = true + api_key_secret_name = "portal_missing" + [[models]] + id = "gpt-x" + """, + ) + registry = AnimatorRegistry( + declarations=_portal_declarations(runes_dir), + runtime_adapters=[], + portal_definitions=[_OPENAI_PORTAL], + ) + registry.load() + + assert not route.called + state = registry.list_capability_states()[0] + assert state.phase is CapabilityPhase.COLD + assert state.reason is not None + assert "portal_missing" in state.reason