oc_chat
Loading...
Searching...
No Matches
lane_overlay.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/core/lane_overlay.py
4
5"""
6The untracked lane overlay — operator-added lanes, shipped rows untouched.
7
8Purpose
9-------
10Plan 216 P2. The onboarding wizard never opens the shipped catalogue for
11writing. It writes ``.oc_chat/lanes_overlay.json`` instead — the
12``agent_pool.json`` override precedent — and this module merges that file
13over ``providers.openai_compat.catalogue.LANES`` at load time.
14
15An overlay row is a superset of a :class:`Lane`: the Lane fields the
16shipped catalogue already validates, plus the four things plan 216 adds
17that a Lane has no home for — the cross-pool capability tier, the
18good-at / bad-at notes (P3 screen two), the recorded budget (P4), and the
19cached model list with the timestamp of the fetch that produced it.
20
21Responsibilities
22----------------
23- :class:`OverlayRow` — the row shape, Lane projection plus P3/P4 fields.
24- :func:`load_overlay` / :func:`write_overlay_row` — strict read, atomic write.
25- :func:`merge_lanes` — join by ``name`` with explicit override semantics.
26- :func:`register_overlay_lanes` — the startup hook.
27
28Diagnostics
29-----------
30Domain: OC_CHAT.MAIN
31Levels:
32 L2 — lifecycle events (overlay loaded, row written, lanes registered)
33 L3 — semantic details (row names, counts)
34 L4 — deep tracing
35
36Contracts
37---------
38- **The shipped catalogue is never opened for writing.** Nothing in this
39 module holds a writable handle on ``providers/openai_compat/**``; W3
40 pins that by hashing the file across every operation here.
41- Overlay rows satisfy the SAME validator the shipped rows do
42 (:func:`validate_catalogue`), so an overlay lane cannot express
43 something a checked-in lane could not.
44- Loudness (inherited from plan 215 P5): a malformed row, an unknown
45 field, a duplicate name, or a shipped-name collision without
46 ``"override": true`` aborts the WHOLE merge, naming the row. There is
47 no partially-merged state and no silently-dropped lane.
48- This deliberately differs from ``agent_pool.json``, which degrades to
49 an empty dict on a parse error. A silently-ignored lane overlay would
50 make a lane the operator just added vanish with no explanation, which
51 is the failure mode the wizard exists to remove.
52- The overlay carries env-var NAMES only. No key value, and no field
53 that could hold one, is part of the schema.
54- Importing the provider bundle is OPTIONAL and lazy: without it there is
55 no catalogue to overlay, and every entry point here degrades to a no-op
56 rather than raising. That is what keeps a deepseek/ollama-only install
57 byte-identical (plan 215's additive property).
58
59Dependencies
60------------
61- oc_diagnostics (structured debug logging via _dbg)
62- providers.openai_compat.catalogue (lazy; read-only: Lane / LaneModel / validator)
63- option_c_agent_contracts (lazy; ModelCaps)
64- providers.openai_compat.provider (lazy; OpenAICompatProvider registration of the overlay lanes)
65"""
66from __future__ import annotations
67
68import json
69import os
70import tempfile
71from dataclasses import dataclass
72from pathlib import Path
73from typing import Any, Iterable, Mapping, Sequence, TYPE_CHECKING
74
75from oc_diagnostics import _dbg
76
77if TYPE_CHECKING: # pragma: no cover — typing only
79
80_dbg("OC_CHAT.MAIN", "module", "loaded", 4)
81
82
83#: The untracked overlay file (P2). Override with ``OC_CHAT_LANES_OVERLAY``.
84DEFAULT_OVERLAY_PATH: Path = Path(
85 os.environ.get("OC_CHAT_LANES_OVERLAY", ".oc_chat/lanes_overlay.json")
86)
87
88#: Schema version of the overlay document. Bumped only by a migration.
89OVERLAY_VERSION = 1
90
91#: P3 screen two — the cross-pool capability tier. Operator judgment, and
92#: deliberately NOT the same vocabulary as the per-provider role tiers.
93CAPABILITY_TIERS: tuple[str, ...] = ("frontier", "strong", "fast")
94
95#: Every field a row may carry. Anything else is a typo worth reporting.
96_REQUIRED_FIELDS = frozenset({
97 "name", "display_label", "default_base_url", "base_url_env_var",
98 "api_key_env_var", "models", "tiers", "free", "verification", "notes",
99 "capability_tier",
100})
101_OPTIONAL_FIELDS = frozenset({
102 "good_at", "bad_at", "budget", "cached_models", "models_fetched_at",
103 "override", "extra_headers",
104})
105KNOWN_FIELDS = _REQUIRED_FIELDS | _OPTIONAL_FIELDS
106
107
108class OverlayError(ValueError):
109 """An overlay row or document is invalid — the merge aborts, naming it.
110
111 VALIDATION_ERROR (S6 L3): the overlay is operator-authored data, so
112 the repair is to fix the row, never to drop it and carry on.
113 """
114
115
116@dataclass(frozen=True)
118 """One operator-added lane: a :class:`Lane` plus the P3/P4 fields."""
119
120 lane: "Lane"
121 capability_tier: str
122 good_at: str = ""
123 bad_at: str = ""
124 budget: str | None = None
125 cached_models: tuple[str, ...] = ()
126 models_fetched_at: str | None = None
127 override: bool = False
128
129 @property
130 def name(self) -> str:
131 """The lane name this row joins on."""
132 return self.lane.name
133
134
136 """Import the shipped catalogue module, or ``None`` when not installed."""
137 try:
138 import providers.openai_compat.catalogue as module
139 except ImportError:
140 return None
141 return module
142
143
144def _require(condition: bool, row: str, message: str) -> None:
145 """Raise :exc:`OverlayError` naming *row* when *condition* is false."""
146 if not condition:
147 raise OverlayError(f"overlay row {row!r}: {message}")
148
149
151 """The honest capability record for an operator-added model id.
152
153 The same as-shipped position the shipped catalogue takes for an
154 unmeasured model: tools attemptable, nothing claimed about
155 thinking/reasoning, and the contract's conservative context default
156 rather than a figure nobody measured. The wizard never asks the
157 operator to guess a context window, so it never records a guess.
158 """
159 from option_c_agent_contracts import ModelCaps
160
161 return ModelCaps(
162 supports_thinking=False,
163 supports_reasoning=False,
164 supports_tools=True,
165 max_context_tokens=65_536,
166 tool_schema_dialect="openai_chat",
167 )
168
169
170def _str_list(value: Any, row: str, field: str) -> tuple[str, ...]:
171 """Coerce *value* to a tuple of non-empty strings, or raise."""
172 _require(isinstance(value, list), row, f"{field} must be a list of strings")
173 out: list[str] = []
174 for item in value:
175 _require(
176 isinstance(item, str) and item.strip(),
177 row,
178 f"{field} must hold non-empty strings, found {item!r}",
179 )
180 out.append(item.strip())
181 return tuple(out)
182
183
184def row_from_json(data: Mapping[str, Any]) -> OverlayRow:
185 """Build an :class:`OverlayRow` from one overlay JSON row.
186
187 Structural validation only — shape, types, and vocabulary. The lane's
188 own semantic rules (tier map completeness, https base URL, env-var
189 name spelling) are left to :func:`validate_catalogue` during the
190 merge, so overlay rows and shipped rows are judged by one validator.
191 """
192 catalogue = _catalogue()
193 if catalogue is None:
194 raise OverlayError(
195 "the openai_compat provider bundle is not installed, so there is "
196 "no lane catalogue for the overlay to extend"
197 )
198
199 _require(isinstance(data, Mapping), "<unnamed>", "must be a JSON object")
200 name = data.get("name")
201 row = name if isinstance(name, str) and name.strip() else "<unnamed>"
202
203 unknown = sorted(set(data) - KNOWN_FIELDS)
204 _require(not unknown, row, f"unknown field(s): {', '.join(unknown)}")
205 missing = sorted(_REQUIRED_FIELDS - set(data))
206 _require(not missing, row, f"missing required field(s): {', '.join(missing)}")
207
208 for field in ("name", "display_label", "default_base_url",
209 "base_url_env_var", "api_key_env_var", "notes", "verification"):
210 _require(
211 isinstance(data[field], str),
212 row,
213 f"{field} must be a string, found {type(data[field]).__name__}",
214 )
215 _require(
216 isinstance(data["free"], bool),
217 row,
218 f"free must be a bool, found {type(data['free']).__name__}",
219 )
220 _require(isinstance(data["tiers"], Mapping), row, "tiers must be a JSON object")
221 for tier_name, model_id in data["tiers"].items():
222 _require(
223 isinstance(tier_name, str) and isinstance(model_id, str),
224 row,
225 "tiers must map tier names to model ids",
226 )
227 _require(
228 data["capability_tier"] in CAPABILITY_TIERS,
229 row,
230 f"capability_tier must be one of {list(CAPABILITY_TIERS)}, "
231 f"found {data['capability_tier']!r}",
232 )
233
234 models = _str_list(data["models"], row, "models")
235 cached = _str_list(data.get("cached_models", []), row, "cached_models")
236
237 budget = data.get("budget")
238 _require(
239 budget is None or isinstance(budget, str),
240 row,
241 "budget must be a string or null",
242 )
243 fetched = data.get("models_fetched_at")
244 _require(
245 fetched is None or isinstance(fetched, str),
246 row,
247 "models_fetched_at must be a string or null",
248 )
249 override = data.get("override", False)
250 _require(isinstance(override, bool), row, "override must be a bool")
251
252 headers = data.get("extra_headers", {})
253 _require(isinstance(headers, Mapping), row, "extra_headers must be a JSON object")
254
255 caps = _model_caps()
256 lane = catalogue.Lane(
257 name=data["name"],
258 display_label=data["display_label"],
259 default_base_url=data["default_base_url"],
260 base_url_env_var=data["base_url_env_var"],
261 api_key_env_var=data["api_key_env_var"],
262 models=tuple(catalogue.LaneModel(model_id, caps) for model_id in models),
263 tiers=dict(data["tiers"]),
264 free=data["free"],
265 verification=data["verification"], # validated by validate_catalogue
266 notes=data["notes"],
267 extra_headers={str(k): str(v) for k, v in headers.items()},
268 )
269 return OverlayRow(
270 lane=lane,
271 capability_tier=data["capability_tier"],
272 good_at=str(data.get("good_at", "")),
273 bad_at=str(data.get("bad_at", "")),
274 budget=budget,
275 cached_models=cached,
276 models_fetched_at=fetched,
277 override=override,
278 )
279
280
281def row_to_json(row: OverlayRow) -> dict[str, Any]:
282 """Serialise one :class:`OverlayRow` back to its JSON shape."""
283 lane = row.lane
284 data: dict[str, Any] = {
285 "name": lane.name,
286 "display_label": lane.display_label,
287 "default_base_url": lane.default_base_url,
288 "base_url_env_var": lane.base_url_env_var,
289 "api_key_env_var": lane.api_key_env_var,
290 "models": [model.model_id for model in lane.models],
291 "tiers": dict(lane.tiers),
292 "free": lane.free,
293 "verification": lane.verification,
294 "notes": lane.notes,
295 "capability_tier": row.capability_tier,
296 "good_at": row.good_at,
297 "bad_at": row.bad_at,
298 "budget": row.budget,
299 "cached_models": list(row.cached_models),
300 "models_fetched_at": row.models_fetched_at,
301 "override": row.override,
302 }
303 if lane.extra_headers:
304 data["extra_headers"] = dict(lane.extra_headers)
305 return data
306
307
308def overlay_rows_to_json(rows: Iterable[OverlayRow]) -> dict[str, Any]:
309 """Serialise *rows* as a whole overlay document."""
310 return {"version": OVERLAY_VERSION, "lanes": [row_to_json(row) for row in rows]}
311
312
313def load_overlay(path: str | Path = DEFAULT_OVERLAY_PATH) -> tuple[OverlayRow, ...]:
314 """Read the overlay document at *path*; ``()`` when the file is absent.
315
316 Raises :exc:`OverlayError` on a corrupt or wrongly-shaped document —
317 never silently treated as "no overlay" (see the module contract).
318 """
319 func = "load_overlay"
320 source = Path(path)
321 if not source.is_file():
322 return ()
323 try:
324 data = json.loads(source.read_text(encoding="utf-8"))
325 except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
326 raise OverlayError(
327 f"could not parse the lane overlay at {source}: "
328 f"{type(exc).__name__}: {exc}"
329 ) from exc
330 if not isinstance(data, Mapping):
331 raise OverlayError(f"lane overlay at {source} must contain a JSON object")
332 if "lanes" not in data:
333 raise OverlayError(f"lane overlay at {source} has no 'lanes' list")
334 lanes = data["lanes"]
335 if not isinstance(lanes, list):
336 raise OverlayError(
337 f"lane overlay at {source}: 'lanes' must be a list, "
338 f"found {type(lanes).__name__}"
339 )
340 rows = tuple(row_from_json(entry) for entry in lanes)
341 _dbg("OC_CHAT.MAIN", func,
342 f"loaded {len(rows)} overlay row(s) from {source}: "
343 f"{', '.join(row.name for row in rows) or '(none)'}", 3)
344 return rows
345
346
348 row: OverlayRow,
349 *,
350 path: str | Path = DEFAULT_OVERLAY_PATH,
351) -> Path:
352 """Add or replace *row* in the overlay at *path*; atomic, never partial.
353
354 Re-running the wizard for an existing lane UPDATES that row rather
355 than stacking a duplicate the merge would then reject.
356 """
357 func = "write_overlay_row"
358 target = Path(path)
359 existing = list(load_overlay(target)) if target.is_file() else []
360 merged = [item for item in existing if item.name != row.name]
361 merged.append(row)
362
363 target.parent.mkdir(parents=True, exist_ok=True)
364 text = json.dumps(overlay_rows_to_json(merged), indent=2, ensure_ascii=False) + "\n"
365 with tempfile.NamedTemporaryFile(
366 mode="w",
367 encoding="utf-8",
368 newline="\n",
369 dir=target.parent,
370 prefix=".lanes_overlay_",
371 suffix=".tmp",
372 delete=False,
373 ) as tmp:
374 tmp.write(text)
375 tmp_path = Path(tmp.name)
376 os.replace(tmp_path, target)
377 _dbg("OC_CHAT.MAIN", func,
378 f"wrote overlay row {row.name!r} to {target} "
379 f"({len(merged)} row(s) held)", 2)
380 return target
381
382
384 shipped: Sequence["Lane"],
385 rows: Sequence[OverlayRow],
386) -> tuple["Lane", ...]:
387 """Merge overlay *rows* over *shipped*, then validate the whole set.
388
389 Join is by lane ``name``. A shipped-name collision is an ERROR unless
390 the row carries ``"override": true``, in which case the shipped row is
391 replaced IN PLACE so the menu does not reorder. New lanes append in
392 overlay order. The merged set is then put through the shipped
393 validator, so one bad row aborts everything (plan 215 P5).
394 """
395 func = "merge_lanes"
396 if not rows:
397 return tuple(shipped)
398
399 catalogue = _catalogue()
400 if catalogue is None:
401 raise OverlayError(
402 "the openai_compat provider bundle is not installed, so there is "
403 "no lane catalogue for the overlay to extend"
404 )
405
406 seen: set[str] = set()
407 for row in rows:
408 _require(row.name not in seen, row.name, "appears twice in the overlay")
409 seen.add(row.name)
410
411 shipped_names = {lane.name for lane in shipped}
412 for row in rows:
413 if row.name in shipped_names and not row.override:
414 raise OverlayError(
415 f"overlay row {row.name!r}: collides with a shipped catalogue "
416 f"lane of the same name. Rename the lane, or set "
417 f'"override": true to shadow the shipped row deliberately.'
418 )
419
420 by_name = {row.name: row for row in rows}
421 merged: list["Lane"] = []
422 for lane in shipped:
423 row = by_name.pop(lane.name, None)
424 merged.append(row.lane if row is not None else lane)
425 merged.extend(row.lane for row in rows if row.name in by_name)
426
427 validated = catalogue.validate_catalogue(merged)
428 _dbg("OC_CHAT.MAIN", func,
429 f"merged {len(rows)} overlay row(s) over {len(shipped)} shipped "
430 f"lane(s) -> {len(validated)} lane(s)", 2)
431 return validated
432
433
435 path: str | Path = DEFAULT_OVERLAY_PATH,
436) -> tuple[str, ...]:
437 """Register every overlay lane into the provider registry (startup hook).
438
439 Returns the lane NAMES registered. Total and quiet by design: with no
440 bundle, no overlay file, or no rows, it registers nothing and returns
441 ``()``. A malformed overlay is reported through :func:`_dbg` and
442 surfaced by the caller — it must not stop OC Chat from starting, but
443 it must not be silently indistinguishable from "no overlay" either.
444 """
445 func = "register_overlay_lanes"
446 catalogue = _catalogue()
447 if catalogue is None:
448 return ()
449 try:
450 rows = load_overlay(path)
451 except OverlayError as exc:
452 # VALIDATION_ERROR (S6 L3) — operator-authored data; report, don't crash.
453 _dbg("OC_CHAT.MAIN", func, f"VALIDATION_ERROR: {exc}", 1, override=True)
454 raise
455 if not rows:
456 return ()
457
458 from providers.openai_compat.provider import OpenAICompatProvider
459
460 merged = merge_lanes(catalogue.LANES, rows)
461 overlay_names = {row.name for row in rows}
462 registered: list[str] = []
463 for lane in merged:
464 if lane.name not in overlay_names:
465 continue
466 OpenAICompatProvider.register_lane(lane)
467 registered.append(lane.name)
468 _dbg("OC_CHAT.MAIN", func,
469 f"registered {len(registered)} overlay lane(s): "
470 f"{', '.join(registered) or '(none)'}", 2)
471 return tuple(registered)
472
473
474__all__ = [
475 "CAPABILITY_TIERS",
476 "DEFAULT_OVERLAY_PATH",
477 "KNOWN_FIELDS",
478 "OVERLAY_VERSION",
479 "OverlayError",
480 "OverlayRow",
481 "load_overlay",
482 "merge_lanes",
483 "overlay_rows_to_json",
484 "register_overlay_lanes",
485 "row_from_json",
486 "row_to_json",
487 "write_overlay_row",
488]
tuple[str,...] register_overlay_lanes(str|Path path=DEFAULT_OVERLAY_PATH)
dict[str, Any] overlay_rows_to_json(Iterable[OverlayRow] rows)
tuple[str,...] _str_list(Any value, str row, str field)
None _require(bool condition, str row, str message)
Path write_overlay_row(OverlayRow row, *, str|Path path=DEFAULT_OVERLAY_PATH)
OverlayRow row_from_json(Mapping[str, Any] data)
tuple["Lane",...] merge_lanes(Sequence["Lane"] shipped, Sequence[OverlayRow] rows)
tuple[OverlayRow,...] load_overlay(str|Path path=DEFAULT_OVERLAY_PATH)
dict[str, Any] row_to_json(OverlayRow row)