6The untracked lane overlay — operator-added lanes, shipped rows untouched.
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.
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.
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.
32 L2 — lifecycle events (overlay loaded, row written, lanes registered)
33 L3 — semantic details (row names, counts)
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).
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)
66from __future__
import annotations
71from dataclasses
import dataclass
72from pathlib
import Path
73from typing
import Any, Iterable, Mapping, Sequence, TYPE_CHECKING
75from oc_diagnostics
import _dbg
80_dbg(
"OC_CHAT.MAIN",
"module",
"loaded", 4)
84DEFAULT_OVERLAY_PATH: Path = Path(
85 os.environ.get(
"OC_CHAT_LANES_OVERLAY",
".oc_chat/lanes_overlay.json")
93CAPABILITY_TIERS: tuple[str, ...] = (
"frontier",
"strong",
"fast")
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",
101_OPTIONAL_FIELDS = frozenset({
102 "good_at",
"bad_at",
"budget",
"cached_models",
"models_fetched_at",
103 "override",
"extra_headers",
105KNOWN_FIELDS = _REQUIRED_FIELDS | _OPTIONAL_FIELDS
109 """An overlay row or document is invalid — the merge aborts, naming it.
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.
116@dataclass(frozen=
True)
118 """One operator-added lane: a :class:`Lane` plus the P3/P4 fields."""
124 budget: str |
None =
None
125 cached_models: tuple[str, ...] = ()
126 models_fetched_at: str |
None =
None
127 override: bool =
False
131 """The lane name this row joins on."""
132 return self.
lane.name
136 """Import the shipped catalogue module, or ``None`` when not installed."""
144def _require(condition: bool, row: str, message: str) ->
None:
145 """Raise :exc:`OverlayError` naming *row* when *condition* is false."""
151 """The honest capability record for an operator-added model id.
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.
159 from option_c_agent_contracts
import ModelCaps
162 supports_thinking=
False,
163 supports_reasoning=
False,
165 max_context_tokens=65_536,
166 tool_schema_dialect=
"openai_chat",
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")
176 isinstance(item, str)
and item.strip(),
178 f
"{field} must hold non-empty strings, found {item!r}",
180 out.append(item.strip())
185 """Build an :class:`OverlayRow` from one overlay JSON row.
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.
193 if catalogue
is None:
195 "the openai_compat provider bundle is not installed, so there is "
196 "no lane catalogue for the overlay to extend"
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>"
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)}")
208 for field
in (
"name",
"display_label",
"default_base_url",
209 "base_url_env_var",
"api_key_env_var",
"notes",
"verification"):
211 isinstance(data[field], str),
213 f
"{field} must be a string, found {type(data[field]).__name__}",
216 isinstance(data[
"free"], bool),
218 f
"free must be a bool, found {type(data['free']).__name__}",
220 _require(isinstance(data[
"tiers"], Mapping), row,
"tiers must be a JSON object")
221 for tier_name, model_id
in data[
"tiers"].items():
223 isinstance(tier_name, str)
and isinstance(model_id, str),
225 "tiers must map tier names to model ids",
228 data[
"capability_tier"]
in CAPABILITY_TIERS,
230 f
"capability_tier must be one of {list(CAPABILITY_TIERS)}, "
231 f
"found {data['capability_tier']!r}",
234 models =
_str_list(data[
"models"], row,
"models")
235 cached =
_str_list(data.get(
"cached_models", []), row,
"cached_models")
237 budget = data.get(
"budget")
239 budget
is None or isinstance(budget, str),
241 "budget must be a string or null",
243 fetched = data.get(
"models_fetched_at")
245 fetched
is None or isinstance(fetched, str),
247 "models_fetched_at must be a string or null",
249 override = data.get(
"override",
False)
250 _require(isinstance(override, bool), row,
"override must be a bool")
252 headers = data.get(
"extra_headers", {})
253 _require(isinstance(headers, Mapping), row,
"extra_headers must be a JSON object")
256 lane = catalogue.Lane(
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"]),
265 verification=data[
"verification"],
267 extra_headers={str(k): str(v)
for k, v
in headers.items()},
271 capability_tier=data[
"capability_tier"],
272 good_at=str(data.get(
"good_at",
"")),
273 bad_at=str(data.get(
"bad_at",
"")),
275 cached_models=cached,
276 models_fetched_at=fetched,
282 """Serialise one :class:`OverlayRow` back to its JSON shape."""
284 data: dict[str, Any] = {
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),
293 "verification": lane.verification,
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,
303 if lane.extra_headers:
304 data[
"extra_headers"] = dict(lane.extra_headers)
309 """Serialise *rows* as a whole overlay document."""
310 return {
"version": OVERLAY_VERSION,
"lanes": [
row_to_json(row)
for row
in rows]}
313def load_overlay(path: str | Path = DEFAULT_OVERLAY_PATH) -> tuple[OverlayRow, ...]:
314 """Read the overlay document at *path*; ``()`` when the file is absent.
316 Raises :exc:`OverlayError` on a corrupt or wrongly-shaped document —
317 never silently treated as "no overlay" (see the module contract).
319 func =
"load_overlay"
321 if not source.is_file():
324 data = json.loads(source.read_text(encoding=
"utf-8"))
325 except (OSError, UnicodeDecodeError, json.JSONDecodeError)
as exc:
327 f
"could not parse the lane overlay at {source}: "
328 f
"{type(exc).__name__}: {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):
337 f
"lane overlay at {source}: 'lanes' must be a list, "
338 f
"found {type(lanes).__name__}"
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)
350 path: str | Path = DEFAULT_OVERLAY_PATH,
352 """Add or replace *row* in the overlay at *path*; atomic, never partial.
354 Re-running the wizard for an existing lane UPDATES that row rather
355 than stacking a duplicate the merge would then reject.
357 func =
"write_overlay_row"
359 existing = list(
load_overlay(target))
if target.is_file()
else []
360 merged = [item
for item
in existing
if item.name != row.name]
363 target.parent.mkdir(parents=
True, exist_ok=
True)
365 with tempfile.NamedTemporaryFile(
370 prefix=
".lanes_overlay_",
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)
384 shipped: Sequence[
"Lane"],
385 rows: Sequence[OverlayRow],
386) -> tuple[
"Lane", ...]:
387 """Merge overlay *rows* over *shipped*, then validate the whole set.
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).
397 return tuple(shipped)
400 if catalogue
is None:
402 "the openai_compat provider bundle is not installed, so there is "
403 "no lane catalogue for the overlay to extend"
406 seen: set[str] = set()
408 _require(row.name
not in seen, row.name,
"appears twice in the overlay")
411 shipped_names = {lane.name
for lane
in shipped}
413 if row.name
in shipped_names
and not row.override:
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.'
420 by_name = {row.name: row
for row
in rows}
421 merged: list[
"Lane"] = []
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)
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)
435 path: str | Path = DEFAULT_OVERLAY_PATH,
437 """Register every overlay lane into the provider registry (startup hook).
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.
445 func =
"register_overlay_lanes"
447 if catalogue
is None:
451 except OverlayError
as exc:
453 _dbg(
"OC_CHAT.MAIN", func, f
"VALIDATION_ERROR: {exc}", 1, override=
True)
461 overlay_names = {row.name
for row
in rows}
462 registered: list[str] = []
464 if lane.name
not in overlay_names:
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)
476 "DEFAULT_OVERLAY_PATH",
483 "overlay_rows_to_json",
484 "register_overlay_lanes",
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)