oc_chat
Loading...
Searching...
No Matches
endpoint_registry.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/core/endpoint_registry.py
4
5"""
6Endpoint registry & LAN policy (Phase O2).
7
8Purpose
9-------
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`.
14
15Responsibilities
16----------------
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
24 enforces LAN policy.
25
26Sources merge in this order: provider defaults → env var → TOML. Later
27definitions override earlier ones for the same name.
28
29Diagnostics
30-----------
31Domain: OC_CHAT.MAIN
32Levels:
33 L1 — malformed env / TOML entries skipped (override=True)
34 L2 — registry load lifecycle, endpoint switches
35 L3 — per-entry LAN classification
36 L4 — module load
37
38Contracts
39---------
40- Constructor seeds ``entries`` from ``provider.endpoints()``. After
41 construction, callers may call :meth:`load_env` and/or :meth:`load_toml`
42 to add more.
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
50 re-run.
51
52Dependencies
53------------
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)
59"""
60from __future__ import annotations
61
62import ipaddress
63import os
64import re
65from collections.abc import Mapping
66from pathlib import Path
67from typing import Literal, TYPE_CHECKING
68from urllib.parse import urlparse
69
70try:
71 import tomllib # type: ignore[import-not-found]
72except ModuleNotFoundError: # pragma: no cover - Python < 3.11 fallback
73 import tomli as tomllib # type: ignore[import-not-found,no-redef]
74
75from oc_diagnostics import _dbg
76
77from oc_chat.core.egress_guard import is_forbidden_provider_host
78from oc_chat.core.provider_plugin import ProviderEndpoint, endpoints as _provider_endpoints
79
80if TYPE_CHECKING:
81 from oc_chat.core.provider_plugin import ProviderPlugin
82
83_dbg("OC_CHAT.MAIN", "module", "loaded", 4)
84
85
86LANPolicy = Literal["local", "lan", "remote", "blocked"]
87
88
89_LOCAL_HOSTS = frozenset({"localhost", "127.0.0.1", "::1", "0.0.0.0"})
90
91_ENV_VAR_BASE_URLS = "OLLAMA_BASE_URLS"
92_ENV_VAR_ALLOW_REMOTE = "OC_CHAT_ALLOW_REMOTE_ENDPOINTS"
93
94_ENV_ENTRY_RE = re.compile(r"^\s*([A-Za-z0-9_.\-]+)\s*=\s*(\S.*?)\s*$")
95
96
97def _remote_allowed() -> bool:
98 """Return ``True`` when ``OC_CHAT_ALLOW_REMOTE_ENDPOINTS=1`` is set."""
99 return os.environ.get(_ENV_VAR_ALLOW_REMOTE, "") == "1"
100
101
102def classify_lan_policy(base_url: str) -> LANPolicy:
103 """Classify *base_url* as ``local`` / ``lan`` / ``remote`` / ``blocked``.
104
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"``.
108 """
109 func = "classify_lan_policy"
110 parsed = urlparse(base_url)
111 host = (parsed.hostname or "").strip().lower()
112 if not host:
113 return "blocked"
114 if host in _LOCAL_HOSTS:
115 _dbg("OC_CHAT.MAIN", func, f"{host} -> local", 3)
116 return "local"
117 # M8.0 WU-4c: link-local (incl. IMDS 169.254.169.254) / multicast / reserved
118 # are never a legitimate endpoint -> blocked (was wrongly "lan": link-local is
119 # in ipaddress.is_private). Checked before the is_private->lan branch.
120 if is_forbidden_provider_host(host):
121 _dbg("OC_CHAT.MAIN", func, f"{host} -> blocked (forbidden class)", 2)
122 return "blocked"
123 try:
124 ip = ipaddress.ip_address(host)
125 if ip.is_loopback:
126 return "local"
127 if ip.is_private:
128 _dbg("OC_CHAT.MAIN", func, f"{host} -> lan", 3)
129 return "lan"
130 except ValueError:
131 pass
132 policy: LANPolicy = "remote" if _remote_allowed() else "blocked"
133 _dbg("OC_CHAT.MAIN", func, f"{host} -> {policy}", 3)
134 return policy
135
136
138 """Collects named :class:`ProviderEndpoint` entries for one provider.
139
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.
143 """
144
145 def __init__(self, provider: "ProviderPlugin") -> None:
146 func = "EndpointRegistry.__init__"
147 self.provider = provider
148 self.entries: dict[str, ProviderEndpoint] = dict(_provider_endpoints(provider))
149 if "default" in self.entries:
150 self.active_name = "default"
151 elif self.entries:
152 self.active_name = next(iter(self.entries))
153 else:
154 self.active_name = ""
155 # Plan 152 R11-03: the ACTIVE endpoint is the committed OBJECT, not just a
156 # name. Resolving `entries[active_name]` afresh on every call let an
157 # allowed post-freeze overlay swap the object under a committed
158 # activation. `None` here means "not yet explicitly activated" — `active()`
159 # falls back to name resolution until the first activation commits.
160 self._active_endpoint: ProviderEndpoint | None = None
161 self._default_endpoint: ProviderEndpoint | None = None
162 self._default_frozen = False
163 _dbg(
164 "OC_CHAT.MAIN",
165 func,
166 f"provider={provider.name} entries={list(self.entries)} active={self.active_name}",
167 2,
168 )
169
170 # ── Committed-object activation + frozen default (plan 152 ADR-2) ───────
171
172 def _check_policy(self, endpoint: ProviderEndpoint, label: str) -> ProviderEndpoint:
173 """Re-run both M8.0 WU-4c checks on *endpoint*. Fails closed.
174
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.
179 """
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."
184 )
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."
189 )
190 return endpoint
191
192 def resolve(self, name: str) -> ProviderEndpoint:
193 """Resolve + policy-check *name* WITHOUT activating it (PREPARE).
194
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.
201
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.
205 """
206 func = "EndpointRegistry.resolve"
207 if not self.entries:
208 raise KeyError(f"No endpoints registered for provider {self.provider.name!r}")
209 target_name = name or self.default_name
210 if target_name not in self.entries:
211 available = ", ".join(sorted(self.entries)) or "(none)"
212 raise KeyError(
213 f"Unknown endpoint {target_name!r} for provider {self.provider.name!r}. "
214 f"Available: {available}"
215 )
216 endpoint = self.entries[target_name]
217 _dbg("OC_CHAT.MAIN", func, f"resolved {target_name!r} (non-mutating)", 3)
218 return self._check_policy(endpoint, "Resolved")
219
220 def freeze_default(self) -> None:
221 """Capture the default endpoint OBJECT once, after the overlays.
222
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.
228
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.
233
234 An empty registry freezes to ``None``; :meth:`default_name` and
235 :meth:`resolve` then fail closed with ``KeyError``, matching
236 :meth:`active`.
237 """
238 func = "EndpointRegistry.freeze_default"
239 if self._default_frozen:
240 return # first-call-wins
241 self._default_frozen = True
242 if "default" in self.entries:
243 self._default_endpoint = self.entries["default"]
244 elif self.entries:
245 self._default_endpoint = next(iter(self.entries.values()))
246 else:
247 self._default_endpoint = None
248 name = self._default_endpoint.name if self._default_endpoint is not None else ""
249 _dbg("OC_CHAT.MAIN", func, f"froze default={name!r}", 2)
250
251 @property
252 def default_name(self) -> str:
253 """The frozen default endpoint name.
254
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.
258 """
259 if self._default_frozen:
260 if self._default_endpoint is None:
261 raise KeyError(
262 f"No endpoints registered for provider {self.provider.name!r}"
263 )
264 return self._default_endpoint.name
265 if not self.entries:
266 raise KeyError(f"No endpoints registered for provider {self.provider.name!r}")
267 return self.active_name or next(iter(self.entries))
268
269 def commit_endpoint_activation(self, endpoint: ProviderEndpoint) -> None:
270 """Publish *endpoint* as the committed active object — no-fail.
271
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.
276 """
277 self._active_endpoint = endpoint
278 self.active_name = endpoint.name
279
280 def active(self) -> ProviderEndpoint:
281 """Return the currently active endpoint.
282
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).
296
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.
307
308 Policy is still re-run on the committed object on EVERY call, so the
309 WU-4c "no allow caching" property is preserved.
310 """
311 if not self.entries:
312 raise KeyError(f"No endpoints registered for provider {self.provider.name!r}")
313 if self._active_endpoint is not None:
314 return self._check_policy(self._active_endpoint, "Active")
315 # No explicit activation yet — fall back to name resolution (startup).
316 if self.active_name and self.active_name in self.entries:
317 endpoint = self.entries[self.active_name]
318 else:
319 self.active_name = next(iter(self.entries))
320 endpoint = self.entries[self.active_name]
321 return self._check_policy(endpoint, "Active")
322
323 def switch(self, name: str) -> ProviderEndpoint:
324 """Activate the endpoint named *name*.
325
326 Raises :class:`KeyError` when no such endpoint exists, and
327 :class:`PermissionError` when the endpoint's LAN policy is
328 ``"blocked"``.
329 """
330 func = "EndpointRegistry.switch"
331 if name not in self.entries:
332 available = ", ".join(sorted(self.entries)) or "(none)"
333 raise KeyError(
334 f"Unknown endpoint {name!r} for provider {self.provider.name!r}. "
335 f"Available: {available}"
336 )
337 target = self.entries[name]
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."
342 )
343 # An explicit activation: commit the OBJECT together with its name so
344 # `active()` cannot be re-pointed by a later overlay (plan 152 R11-03).
345 self.commit_endpoint_activation(target)
346 _dbg(
347 "OC_CHAT.MAIN",
348 func,
349 f"provider={self.provider.name} -> {name} ({target.base_url})",
350 2,
351 )
352 return target
353
354 def add(self, endpoint: ProviderEndpoint) -> None:
355 """Insert or replace *endpoint* by name.
356
357 Classification is the caller's responsibility — pass a
358 :class:`ProviderEndpoint` with ``lan_policy_status`` already
359 set.
360 """
361 self.entries[endpoint.name] = endpoint
362
363 def load_env(self, env: Mapping[str, str]) -> int:
364 """Merge entries parsed from ``env['OLLAMA_BASE_URLS']``.
365
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.
369 """
370 func = "EndpointRegistry.load_env"
371 raw = env.get(_ENV_VAR_BASE_URLS, "").strip()
372 if not raw:
373 return 0
374 count = 0
375 for part in raw.split(","):
376 part = part.strip()
377 if not part:
378 continue
379 match = _ENV_ENTRY_RE.match(part)
380 if not match:
381 _dbg(
382 "OC_CHAT.MAIN",
383 func,
384 f"malformed entry {part!r} skipped",
385 1,
386 override=True,
387 )
388 continue
389 name, url = match.group(1), match.group(2)
390 policy = classify_lan_policy(url)
391 self.entries[name] = ProviderEndpoint(name=name, base_url=url, lan_policy_status=policy)
392 count += 1
393 _dbg(
394 "OC_CHAT.MAIN",
395 func,
396 f"loaded {count} entries from {_ENV_VAR_BASE_URLS}",
397 2,
398 )
399 return count
400
401 def load_toml(self, path: Path) -> int:
402 """Merge entries from a ``[endpoints]`` TOML table at *path*.
403
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.
407
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.
416
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.
421 """
422 try:
423 return self._load_toml_entries(path)
424 finally:
425 self.freeze_default()
426
427 def _load_toml_entries(self, path: Path) -> int:
428 """Parse + apply the ``[endpoints]`` table. See :meth:`load_toml`.
429
430 Split out so the freeze can wrap every exit in a ``finally``; the three
431 early returns below are exactly why.
432 """
433 func = "EndpointRegistry.load_toml"
434 if not path.exists():
435 return 0
436 try:
437 with open(path, "rb") as fh:
438 data = tomllib.load(fh)
439 except (tomllib.TOMLDecodeError, OSError) as exc:
440 _dbg(
441 "OC_CHAT.MAIN",
442 func,
443 f"malformed TOML at {path}: {exc!r}",
444 1,
445 override=True,
446 )
447 return 0
448 section = data.get("endpoints", {})
449 if not isinstance(section, dict):
450 _dbg(
451 "OC_CHAT.MAIN",
452 func,
453 f"[endpoints] is not a table at {path}",
454 1,
455 override=True,
456 )
457 return 0
458 count = 0
459 for name, url in section.items():
460 if not isinstance(url, str) or not url:
461 _dbg(
462 "OC_CHAT.MAIN",
463 func,
464 f"non-string url for entry {name!r} skipped",
465 1,
466 override=True,
467 )
468 continue
469 policy = classify_lan_policy(url)
470 self.entries[name] = ProviderEndpoint(name=name, base_url=url, lan_policy_status=policy)
471 count += 1
472 _dbg(
473 "OC_CHAT.MAIN",
474 func,
475 f"loaded {count} entries from {path}",
476 2,
477 )
478 return count
479
480
481__all__ = [
482 "EndpointRegistry",
483 "ProviderEndpoint",
484 "classify_lan_policy",
485 "LANPolicy",
486]
ProviderEndpoint _check_policy(self, ProviderEndpoint endpoint, str label)
None __init__(self, "ProviderPlugin" provider)
None commit_endpoint_activation(self, ProviderEndpoint endpoint)
None add(self, ProviderEndpoint endpoint)
LANPolicy classify_lan_policy(str base_url)