6Endpoint registry & LAN policy (Phase O2).
10A provider may serve multiple named base-URL targets (e.g. an Ollama
11install on ``localhost`` plus a LAN GPU box). :class:`EndpointRegistry`
12collects them from three sources and exposes a runtime-switchable active
13selection consumed by :class:`oc_chat.core.client_interface.ClientInterface`.
17- :func:`classify_lan_policy` — pure helper mapping a base URL to one of
18 ``"local"`` / ``"lan"`` / ``"remote"`` / ``"blocked"``.
19- :class:`EndpointRegistry` — provider-scoped collection of
20 :class:`oc_chat.core.provider_plugin.ProviderEndpoint` entries with
21 three loaders (constructor seed from ``provider.endpoints()``,
22 :meth:`load_env` for ``OLLAMA_BASE_URLS``, :meth:`load_toml` for
23 ``~/.oc_chat/endpoints.toml``) and a runtime :meth:`switch` that
26Sources merge in this order: provider defaults → env var → TOML. Later
27definitions override earlier ones for the same name.
33 L1 — malformed env / TOML entries skipped (override=True)
34 L2 — registry load lifecycle, endpoint switches
35 L3 — per-entry LAN classification
40- Constructor seeds ``entries`` from ``provider.endpoints()``. After
41 construction, callers may call :meth:`load_env` and/or :meth:`load_toml`
43- :meth:`switch` raises :class:`KeyError` on unknown name and
44 :class:`PermissionError` on a ``"blocked"`` entry.
45- :meth:`active` returns the currently selected entry; defaults to the
46 one named ``"default"`` if it exists, else the first registered.
47- ``"blocked"`` classification is consulted via
48 ``OC_CHAT_ALLOW_REMOTE_ENDPOINTS`` at load time; toggling the env var
49 after load does not re-classify existing entries until a loader is
54- oc_chat.core.provider_plugin (ProviderEndpoint, ProviderPlugin and the provider default endpoints)
55- oc_diagnostics (structured debug logging via _dbg)
56- tomllib (stdlib TOML parser for the endpoints file on Python 3.11 and later)
57- oc_chat.core.egress_guard (forbidden provider host check for LAN policy and endpoint selection)
58- tomli (TOML parser fallback for the endpoints file on older Pythons)
60from __future__
import annotations
65from collections.abc
import Mapping
66from pathlib
import Path
67from typing
import Literal, TYPE_CHECKING
68from urllib.parse
import urlparse
72except ModuleNotFoundError:
73 import tomli
as tomllib
75from oc_diagnostics
import _dbg
83_dbg(
"OC_CHAT.MAIN",
"module",
"loaded", 4)
86LANPolicy = Literal[
"local",
"lan",
"remote",
"blocked"]
89_LOCAL_HOSTS = frozenset({
"localhost",
"127.0.0.1",
"::1",
"0.0.0.0"})
91_ENV_VAR_BASE_URLS =
"OLLAMA_BASE_URLS"
92_ENV_VAR_ALLOW_REMOTE =
"OC_CHAT_ALLOW_REMOTE_ENDPOINTS"
94_ENV_ENTRY_RE = re.compile(
r"^\s*([A-Za-z0-9_.\-]+)\s*=\s*(\S.*?)\s*$")
98 """Return ``True`` when ``OC_CHAT_ALLOW_REMOTE_ENDPOINTS=1`` is set."""
99 return os.environ.get(_ENV_VAR_ALLOW_REMOTE,
"") ==
"1"
103 """Classify *base_url* as ``local`` / ``lan`` / ``remote`` / ``blocked``.
105 Loopback and ``localhost`` are ``"local"``. RFC1918 private space is
106 ``"lan"``. Any other host is ``"remote"`` if
107 ``OC_CHAT_ALLOW_REMOTE_ENDPOINTS=1``, otherwise ``"blocked"``.
109 func =
"classify_lan_policy"
110 parsed = urlparse(base_url)
111 host = (parsed.hostname
or "").strip().lower()
114 if host
in _LOCAL_HOSTS:
115 _dbg(
"OC_CHAT.MAIN", func, f
"{host} -> local", 3)
120 if is_forbidden_provider_host(host):
121 _dbg(
"OC_CHAT.MAIN", func, f
"{host} -> blocked (forbidden class)", 2)
124 ip = ipaddress.ip_address(host)
128 _dbg(
"OC_CHAT.MAIN", func, f
"{host} -> lan", 3)
133 _dbg(
"OC_CHAT.MAIN", func, f
"{host} -> {policy}", 3)
138 """Collects named :class:`ProviderEndpoint` entries for one provider.
140 The registry is provider-scoped: each :class:`ProviderPlugin`
141 instance gets its own registry built at startup from its own
142 ``endpoints()`` plus the env-var + TOML overlays.
145 def __init__(self, provider:
"ProviderPlugin") ->
None:
146 func =
"EndpointRegistry.__init__"
148 self.
entries: dict[str, ProviderEndpoint] = dict(_provider_endpoints(provider))
166 f
"provider={provider.name} entries={list(self.entries)} active={self.active_name}",
172 def _check_policy(self, endpoint: ProviderEndpoint, label: str) -> ProviderEndpoint:
173 """Re-run both M8.0 WU-4c checks on *endpoint*. Fails closed.
175 Factored out so every access path — :meth:`active`, :meth:`switch`,
176 :meth:`resolve` — applies the SAME policy, and so the committed-object
177 model preserves the "re-evaluated on every call (no allow caching)"
178 property rather than caching an allow decision alongside the object.
180 if endpoint.lan_policy_status ==
"blocked":
181 raise PermissionError(
182 f
"{label} endpoint {endpoint.name!r} ({endpoint.base_url}) is blocked "
183 f
"by LAN policy. Set {_ENV_VAR_ALLOW_REMOTE}=1 to opt in."
185 if is_forbidden_provider_host(endpoint.base_url):
186 raise PermissionError(
187 f
"{label} endpoint {endpoint.name!r} ({endpoint.base_url}) is a "
188 f
"forbidden internal address; refused."
192 def resolve(self, name: str) -> ProviderEndpoint:
193 """Resolve + policy-check *name* WITHOUT activating it (PREPARE).
195 The non-mutating half of activation: unknown / missing / blocked /
196 forbidden-host are all rejected HERE, in the owner, so a transition can
197 validate a candidate endpoint during PREPARE without touching
198 ``active_name`` or the live client. An empty *name* resolves to the
199 frozen default (:meth:`default_name`) rather than to whatever the
200 previous session's :meth:`switch` happened to leave active.
202 This exists so callers never hand-roll policy or reach for
203 ``entries[name]`` + ``rebind_to`` — that path silently bypasses the
204 ``blocked`` LAN check, which ``rebind_to`` does not enforce.
206 func =
"EndpointRegistry.resolve"
208 raise KeyError(f
"No endpoints registered for provider {self.provider.name!r}")
210 if target_name
not in self.
entries:
211 available =
", ".join(sorted(self.
entries))
or "(none)"
213 f
"Unknown endpoint {target_name!r} for provider {self.provider.name!r}. "
214 f
"Available: {available}"
216 endpoint = self.
entries[target_name]
217 _dbg(
"OC_CHAT.MAIN", func, f
"resolved {target_name!r} (non-mutating)", 3)
221 """Capture the default endpoint OBJECT once, after the overlays.
223 Idempotent and first-call-wins: repeated calls are a no-op, so the frozen
224 default can never change after startup. Called from :meth:`load_toml`'s
225 ``finally`` — the LAST overlay — so it runs on every exit path including
226 that method's three early returns (missing file, malformed TOML,
227 non-table ``[endpoints]``), each of which is a normal production case.
229 The OBJECT is captured, not merely the name, because :meth:`add`,
230 :meth:`load_env` and :meth:`load_toml` all REPLACE the entry stored under
231 a name — a frozen name alone would let a later overlay silently swap the
232 default out from under a session that selected it.
234 An empty registry freezes to ``None``; :meth:`default_name` and
235 :meth:`resolve` then fail closed with ``KeyError``, matching
238 func =
"EndpointRegistry.freeze_default"
249 _dbg(
"OC_CHAT.MAIN", func, f
"froze default={name!r}", 2)
253 """The frozen default endpoint name.
255 Before :meth:`freeze_default` runs this returns a PROVISIONAL value (the
256 construction-time ``active_name``); afterwards it is stable for the
257 process lifetime. Raises ``KeyError`` when the registry has no entries.
262 f
"No endpoints registered for provider {self.provider.name!r}"
266 raise KeyError(f
"No endpoints registered for provider {self.provider.name!r}")
270 """Publish *endpoint* as the committed active object — no-fail.
272 The COMMIT half of activation. Assigns the OBJECT handle and its name
273 together, so ``active()`` returns exactly this object until the next
274 explicit activation. Pure assignment: policy already ran in
275 :meth:`resolve`, and ``active()`` re-checks on every read anyway.
281 """Return the currently active endpoint.
283 Raises :class:`KeyError` when the registry is empty, and
284 :class:`PermissionError` when the active endpoint resolves to a forbidden
285 internal address (IMDS / link-local / multicast / reserved) **or** carries
286 a ``lan_policy_status == "blocked"`` LAN policy. M8.0 WU-4c fails closed
287 even on the default / ``active_name`` path (closing the
288 ``active_name="default"``-by-direct-assignment bypass), not only on
289 :meth:`switch`. The two checks are complementary: ``is_forbidden_provider_host``
290 catches the IP-literal internal class (incl. remote IP literals only when
291 forbidden), while the ``blocked`` guard catches a remote HOSTNAME that
292 ``is_forbidden_provider_host`` cannot parse as an IP — closing the
293 AC-V12-3 direct-``active_name``-assignment bypass of the
294 ``OC_CHAT_ALLOW_REMOTE_ENDPOINTS`` opt-in that :meth:`switch` already
295 enforces. Re-evaluated on every call (no allow caching).
297 **Committed-object model (plan 152 R11-03).** Once an explicit activation
298 has committed — :meth:`switch`, the session-transition seam, or
299 :meth:`commit_endpoint_activation` — this returns THAT object until the
300 next activation commits another. It no longer re-resolves
301 ``entries[active_name]`` on every call, because a post-freeze overlay
302 replacing ``entries["default"]`` would otherwise hand the next turn's
303 rebind an endpoint nobody activated (and a blocked replacement would
304 raise ``PermissionError`` where the caller only catches ``KeyError``,
305 aborting the turn). The NAME selects at activation time; the committed
306 OBJECT is authoritative between activations.
308 Policy is still re-run on the committed object on EVERY call, so the
309 WU-4c "no allow caching" property is preserved.
312 raise KeyError(f
"No endpoints registered for provider {self.provider.name!r}")
323 def switch(self, name: str) -> ProviderEndpoint:
324 """Activate the endpoint named *name*.
326 Raises :class:`KeyError` when no such endpoint exists, and
327 :class:`PermissionError` when the endpoint's LAN policy is
330 func =
"EndpointRegistry.switch"
332 available =
", ".join(sorted(self.
entries))
or "(none)"
334 f
"Unknown endpoint {name!r} for provider {self.provider.name!r}. "
335 f
"Available: {available}"
338 if target.lan_policy_status ==
"blocked":
339 raise PermissionError(
340 f
"Endpoint {name!r} ({target.base_url}) is blocked by LAN policy. "
341 f
"Set {_ENV_VAR_ALLOW_REMOTE}=1 to opt in."
349 f
"provider={self.provider.name} -> {name} ({target.base_url})",
354 def add(self, endpoint: ProviderEndpoint) ->
None:
355 """Insert or replace *endpoint* by name.
357 Classification is the caller's responsibility — pass a
358 :class:`ProviderEndpoint` with ``lan_policy_status`` already
361 self.
entries[endpoint.name] = endpoint
364 """Merge entries parsed from ``env['OLLAMA_BASE_URLS']``.
366 Format is ``name=url[,name=url...]``. Whitespace tolerant;
367 malformed entries are logged at L1 and skipped. Returns the
368 number of successfully parsed entries.
370 func =
"EndpointRegistry.load_env"
371 raw = env.get(_ENV_VAR_BASE_URLS,
"").strip()
375 for part
in raw.split(
","):
379 match = _ENV_ENTRY_RE.match(part)
384 f
"malformed entry {part!r} skipped",
389 name, url = match.group(1), match.group(2)
391 self.
entries[name] = ProviderEndpoint(name=name, base_url=url, lan_policy_status=policy)
396 f
"loaded {count} entries from {_ENV_VAR_BASE_URLS}",
402 """Merge entries from a ``[endpoints]`` TOML table at *path*.
404 Missing file is a silent no-op (returns 0). Malformed TOML logs
405 at L1 and returns 0. Each ``name = "url"`` becomes one entry;
406 non-string values are skipped with an L1 log.
408 **This is the LAST overlay**, so it is also where the default endpoint is
409 frozen (plan 152 ADR-2 / R10-11). The freeze lives in a ``finally`` around
410 the private parse/apply helper so it runs on EVERY exit path — including
411 the three early returns (missing file, malformed TOML / I-O failure,
412 non-table ``[endpoints]``), the first of which is the common production
413 case because ``~/.oc_chat/endpoints.toml`` usually does not exist. A
414 literal "call it at the end" would skip exactly those paths and leave
415 ``default_name`` provisional forever.
417 Malformed-file policy: freeze the VALID constructor+env state rather than
418 leaving the default provisional. Post-freeze calls may still REPLACE
419 entries — runtime endpoints stay mutable — but can never change the
420 frozen default OBJECT; the freeze itself is first-call-wins.
428 """Parse + apply the ``[endpoints]`` table. See :meth:`load_toml`.
430 Split out so the freeze can wrap every exit in a ``finally``; the three
431 early returns below are exactly why.
433 func =
"EndpointRegistry.load_toml"
434 if not path.exists():
437 with open(path,
"rb")
as fh:
438 data = tomllib.load(fh)
439 except (tomllib.TOMLDecodeError, OSError)
as exc:
443 f
"malformed TOML at {path}: {exc!r}",
448 section = data.get(
"endpoints", {})
449 if not isinstance(section, dict):
453 f
"[endpoints] is not a table at {path}",
459 for name, url
in section.items():
460 if not isinstance(url, str)
or not url:
464 f
"non-string url for entry {name!r} skipped",
470 self.
entries[name] = ProviderEndpoint(name=name, base_url=url, lan_policy_status=policy)
475 f
"loaded {count} entries from {path}",
484 "classify_lan_policy",
ProviderEndpoint|None _default_endpoint
None freeze_default(self)
ProviderEndpoint active(self)
ProviderEndpoint resolve(self, str name)
int load_toml(self, Path path)
ProviderEndpoint _check_policy(self, ProviderEndpoint endpoint, str label)
ProviderEndpoint|None _active_endpoint
dict[str, ProviderEndpoint] entries
None __init__(self, "ProviderPlugin" provider)
None commit_endpoint_activation(self, ProviderEndpoint endpoint)
None add(self, ProviderEndpoint endpoint)
int load_env(self, Mapping[str, str] env)
int _load_toml_entries(self, Path path)
ProviderEndpoint switch(self, str name)
LANPolicy classify_lan_policy(str base_url)