Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions docs/compositions/reach/operator.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions docs/sepulcher/animator/portal.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
61 changes: 55 additions & 6 deletions scripts/reach_model_broker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"^(?P<scheme>https?)://(?P<host>\[[0-9a-fA-F:.]+\]|[A-Za-z0-9.-]+)(?::(?P<port>[0-9]{1,5}))?(?P<path>(?:/[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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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] = []
Expand All @@ -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(
Expand All @@ -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")
Expand Down Expand Up @@ -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 (
Expand Down
41 changes: 38 additions & 3 deletions scripts/reach_operator.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,15 +41,27 @@
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
from scripts.reach_research_broker import OperatorResearchBroker
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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
20 changes: 17 additions & 3 deletions scripts/reach_probe.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
)
)
Expand Down
4 changes: 2 additions & 2 deletions src/lychd/compositions/reach/operator_model.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
Expand Down
23 changes: 19 additions & 4 deletions src/lychd/compositions/reach/operator_model_wire.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading