oc_chat
Loading...
Searching...
No Matches
lane_tree.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/core/lane_tree.py
4
5"""
6The selection tree — pure classification over lanes, models and families.
7
8Purpose
9-------
10Plan 217 T1. First live use of the plan-215 lanes put 396 OpenRouter
11models in one flat list. This module holds the decisions that turn that
12list into a drill-down (P1): which lanes are selectable, whether a lane
13has both a free and a paid class, how model ids group into families, and
14when a level has so few answers that asking would be noise.
15
16Everything here is a PURE function over data already owned elsewhere —
17the shipped catalogue (``providers.openai_compat.catalogue``), the
18operator overlay (``oc_chat.core.lane_overlay``), and whatever live list
19the 215/216 machinery cached. Nothing in this module reads a key value,
20opens a socket, or touches the filesystem, which is what lets the
21navigation layer above it be trivially reversible.
22
23Responsibilities
24----------------
25- :class:`Choice` — one selectable (or refused) answer at a level.
26- :class:`LaneOption` — one level-one row, projected from a provider.
27- :func:`classify_models` / :func:`model_classes` / :func:`models_in_class`
28 — the free/paid decision (P1).
29- :func:`family_of` / :func:`families` / :func:`group_by_family` /
30 :func:`models_in_family` — the family decision (P1).
31- :func:`collapse_choices` — the single-answer rule (P1).
32- :func:`lane_options` — project registry providers into level-one rows.
33- :func:`vendor_of` / :func:`vendor_detail` / :func:`serving_host_of` —
34 the attribution decisions (plan 220 P4).
35- :func:`section_text` — join a run of lines into ONE presenter call
36 (plan 220 P2).
37
38Diagnostics
39-----------
40Domain: OC_CHAT.MAIN
41Levels:
42 L2 — lifecycle events (lane rows projected).
43 L3 — semantic details (classification and grouping outcomes).
44 L4 — deep tracing.
45
46Contracts
47---------
48- **Two things classify, and they do not fight.** Where a lane uses the
49 OpenRouter ``:free`` suffix convention, the SUFFIX classifies — within
50 such a lane the suffix is the discriminator, and the lane-level ``free``
51 flag says nothing about an individual id. Where no id carries the
52 suffix, the catalogue flag classifies every model uniformly. A lane
53 with neither (deepseek, ollama) classifies as
54 :data:`CLASS_UNKNOWN` rather than guessing; that is a single class, so
55 the level collapses and nothing is ever claimed on screen.
56- Ordering is TOTAL and deterministic everywhere: classes free-then-paid,
57 families alphabetical with :data:`NO_FAMILY` last, models in the input
58 order they arrived in (catalogue declaration order, or cache order).
59 A picker that reflows because a dict enumerated differently is a picker
60 the operator cannot learn.
61- :func:`collapse_choices` counts SELECTABLE choices only, and refuses to
62 collapse when there are none: a dead end must be shown with its reason,
63 never silently auto-answered.
64- Key handling is presence-only (plan 215 P6). :class:`LaneOption` carries
65 the env-var NAME and a boolean; the VALUE never enters this module.
66- **Attribution never guesses (plan 220 P4).** :func:`vendor_of` reads the
67 ``vendor/model`` convention where an id spells it and returns ``""``
68 where it does not — unlike :func:`family_of`, which needs a bucket for
69 the unprefixed ids and so answers :data:`NO_FAMILY`. A bucket is the
70 right answer for grouping and exactly the wrong one for attribution.
71 :func:`serving_host_of` reads only what a lane REPORTED, because a
72 routed request's serving host is knowable per response and varies call
73 to call (the plan-215 catalogue's provenance caveat).
74- :func:`section_text` is the module's one presentation helper, and it is
75 still a pure function of strings — it returns the block; it does not
76 print it. It lives here because ``startup`` and ``lane_picker`` are its
77 two callers and this is the only module both already sit above.
78
79Dependencies
80------------
81- oc_diagnostics (structured debug logging via _dbg)
82"""
83from __future__ import annotations
84
85import os
86from dataclasses import dataclass
87from typing import Mapping, Sequence
88
89from oc_diagnostics import _dbg
90
91_dbg("OC_CHAT.MAIN", "module", "loaded", 4)
92
93
94#: The three access classes a model id can carry.
95CLASS_FREE = "free"
96CLASS_PAID = "paid"
97CLASS_UNKNOWN = "unknown"
98
99#: Presentation order for the free/paid level. Free first: those are the
100#: models an operator without credit can actually run today.
101CLASS_ORDER: tuple[str, ...] = (CLASS_FREE, CLASS_PAID, CLASS_UNKNOWN)
102
103#: Human labels for the class level.
104CLASS_LABELS: Mapping[str, str] = {
105 CLASS_FREE: "free models",
106 CLASS_PAID: "paid models",
107 CLASS_UNKNOWN: "all models",
108}
109
110#: The OpenRouter convention: a ``:free`` suffix marks a no-cost route.
111FREE_SUFFIX = ":free"
112
113#: Bucket for ids that carry no ``vendor/model`` family convention.
114NO_FAMILY = "(other)"
115
116
117@dataclass(frozen=True)
118class Choice:
119 """One answer offered at one level of the drill-down.
120
121 A choice that is *not* selectable is still RENDERED — with *reason*
122 saying why — because a lane the operator cannot see is a lane they
123 cannot fix (plan 215 P5, applied to navigation).
124 """
125
126 key: str
127 label: str
128 detail: str = ""
129 selectable: bool = True
130 reason: str = ""
131
132
133@dataclass(frozen=True)
135 """One level-one row: a lane the operator could switch to.
136
137 ``free`` is ``None`` for a provider that publishes no access tier
138 (deepseek, ollama). ``models`` is the curated list shown by default;
139 ``cached_models`` is whatever live list the 215/216 machinery last
140 wrote, which the "show all" escape opens (P2).
141 """
142
143 name: str
144 display_label: str
145 free: bool | None
146 verification: str
147 api_key_env_var: str
148 has_key: bool
149 selectable: bool
150 models: tuple[str, ...] = ()
151 cached_models: tuple[str, ...] = ()
152 models_fetched_at: str | None = None
153
154 @property
155 def badge(self) -> str:
156 """``FREE`` / ``PAID`` / ``-`` — the lane's own access tier."""
157 if self.free is None:
158 return "-"
159 return "FREE" if self.free else "PAID"
160
161
162# ── Free / paid classification ──────────────────────────────────────────────
163
164
165def uses_free_suffix(model_ids: Sequence[str]) -> bool:
166 """Whether *model_ids* uses the ``:free`` suffix convention at all."""
167 return any(str(model_id).endswith(FREE_SUFFIX) for model_id in model_ids)
168
169
171 model_ids: Sequence[str],
172 *,
173 lane_free: bool | None,
174) -> dict[str, str]:
175 """Map every id in *model_ids* to its access class.
176
177 The suffix convention wins where it is present, because within such a
178 lane it is the only thing that distinguishes two ids; elsewhere the
179 catalogue flag classifies uniformly. See the module contract.
180 """
181 func = "classify_models"
182 ids = [str(model_id) for model_id in model_ids]
183 if uses_free_suffix(ids):
184 classified = {
185 model_id: (CLASS_FREE if model_id.endswith(FREE_SUFFIX) else CLASS_PAID)
186 for model_id in ids
187 }
188 _dbg("OC_CHAT.MAIN", func, f"suffix convention over {len(ids)} id(s)", 4)
189 return classified
190 if lane_free is None:
191 default = CLASS_UNKNOWN
192 else:
193 default = CLASS_FREE if lane_free else CLASS_PAID
194 _dbg("OC_CHAT.MAIN", func, f"lane flag -> {default} over {len(ids)} id(s)", 4)
195 return {model_id: default for model_id in ids}
196
197
199 model_ids: Sequence[str],
200 *,
201 lane_free: bool | None,
202) -> tuple[str, ...]:
203 """The distinct classes present, free first. One entry → level skipped."""
204 present = set(classify_models(model_ids, lane_free=lane_free).values())
205 return tuple(name for name in CLASS_ORDER if name in present)
206
207
209 model_ids: Sequence[str],
210 *,
211 lane_free: bool | None,
212 model_class: str,
213) -> tuple[str, ...]:
214 """The ids in *model_class*, in input order."""
215 classified = classify_models(model_ids, lane_free=lane_free)
216 return tuple(
217 model_id for model_id in (str(m) for m in model_ids)
218 if classified.get(model_id) == model_class
219 )
220
221
222# ── Family grouping ─────────────────────────────────────────────────────────
223
224
225def family_of(model_id: str) -> str:
226 """The id's family — the prefix before ``/``, or :data:`NO_FAMILY`.
227
228 Only the FIRST separator counts: ``a/b/c`` is family ``a``, matching
229 how every routed lane in the catalogue spells its ids.
230 """
231 text = str(model_id)
232 head, sep, _ = text.partition("/")
233 if not sep or not head.strip():
234 return NO_FAMILY
235 return head
236
237
238def families(model_ids: Sequence[str]) -> tuple[str, ...]:
239 """The distinct families, alphabetical, with :data:`NO_FAMILY` last."""
240 present = {family_of(model_id) for model_id in model_ids}
241 named = sorted(name for name in present if name != NO_FAMILY)
242 if NO_FAMILY in present:
243 named.append(NO_FAMILY)
244 return tuple(named)
245
246
247def models_in_family(model_ids: Sequence[str], family: str) -> tuple[str, ...]:
248 """The ids belonging to *family*, in input order."""
249 return tuple(
250 str(model_id) for model_id in model_ids if family_of(model_id) == family
251 )
252
253
255 model_ids: Sequence[str],
256) -> tuple[tuple[str, tuple[str, ...]], ...]:
257 """Group *model_ids* by family — families ordered, members in input order."""
258 func = "group_by_family"
259 grouped = tuple(
260 (family, models_in_family(model_ids, family))
261 for family in families(model_ids)
262 )
263 _dbg("OC_CHAT.MAIN", func,
264 f"{len(model_ids)} id(s) -> {len(grouped)} family/families", 4)
265 return grouped
266
267
268# ── Attribution ─────────────────────────────────────────────────────────────
269
270
271#: The response fields a lane may use to name the host that actually served a
272#: turn. OpenRouter answers with ``provider``; the other two are the spellings
273#: in circulation elsewhere. Order is preference order.
274SERVING_HOST_FIELDS: tuple[str, ...] = ("provider", "served_by", "serving_host")
275
276
277def vendor_of(model_id: str) -> str:
278 """The id's VENDOR — the prefix before ``/`` — or ``""`` where it has none.
279
280 Deliberately not :func:`family_of` under another name. Family grouping
281 needs a bucket for the ids that carry no prefix, and answers
282 :data:`NO_FAMILY` for them; attribution must not, because an id that
283 does not name its vendor has an UNKNOWN vendor and TUI-02 finding 4 is
284 precisely about not claiming one. Only the FIRST separator counts, the
285 same rule every routed lane in the catalogue spells its ids by.
286 """
287 head, sep, _ = str(model_id).partition("/")
288 if not sep or not head.strip():
289 return ""
290 return head
291
292
293def vendor_detail(model_id: str) -> str:
294 """``"vendor: <name>"`` for a row that names one, ``""`` for one that does not.
295
296 The empty string is what keeps a lane without the convention (deepseek,
297 ollama) from rendering a bare ``vendor:`` label with nothing after it.
298 """
299 vendor = vendor_of(model_id)
300 return f"vendor: {vendor}" if vendor else ""
301
302
303def serving_host_of(report: object) -> str:
304 """The serving host a lane REPORTED for one response, or ``""``.
305
306 *report* is whatever the lane left behind after its turn, read
307 duck-typed (anything with a ``get``) so this module keeps its promise
308 never to import a provider bundle. An absent, non-mapping, blank or
309 non-string value is ``""``, and the caller then renders nothing at all
310 — a missing line is the designed outcome, not a degraded one.
311 """
312 func = "serving_host_of"
313 get = getattr(report, "get", None)
314 if not callable(get):
315 return ""
316 for field in SERVING_HOST_FIELDS:
317 value = get(field)
318 if isinstance(value, str) and value.strip():
319 _dbg("OC_CHAT.MAIN", func, f"{field} reported {value.strip()!r}", 4)
320 return value.strip()
321 return ""
322
323
324# ── Section rendering ───────────────────────────────────────────────────────
325
326
327def section_text(lines: Sequence[str]) -> str:
328 """Join *lines* into ONE block, dropping the blank lines at either end.
329
330 Every presenter opens ``show_info`` with a blank line. Spent once per
331 ROW that is TUI-02 finding 2 — a startup menu using twice the vertical
332 space it needs; spent once per SECTION it is exactly the separator the
333 layout wants. Callers therefore render a section by handing this block
334 to a single ``show_info``, and the edge blanks go because the presenter
335 already supplies the one above. Blank lines INSIDE the run survive:
336 those are the caller's own sub-section breaks.
337
338 Returns ``""`` for a run with no content, which callers treat as
339 "nothing to show" — that is what keeps the plan-215 lane panel
340 invisible on an install with no catalogue-backed bundle.
341 """
342 body = list(lines)
343 while body and not body[0].strip():
344 body.pop(0)
345 while body and not body[-1].strip():
346 body.pop()
347 return "\n".join(body)
348
349
350# ── The single-answer rule ──────────────────────────────────────────────────
351
352
353def collapse_choices(choices: Sequence[Choice]) -> Choice | None:
354 """The one selectable choice when a level has exactly one, else ``None``.
355
356 Unselectable siblings do not block a collapse — they are displayed
357 information, not answers — but a level with NO selectable choice never
358 collapses, because the operator has to see why they are stuck.
359 """
360 selectable = [choice for choice in choices if choice.selectable]
361 if len(selectable) == 1:
362 return selectable[0]
363 return None
364
365
366# ── Level-one rows ──────────────────────────────────────────────────────────
367
368
369def _lane_row(provider: object) -> object | None:
370 """The catalogue lane row behind *provider*, if it carries one.
371
372 Duck-typed exactly as the plan-215 menu panel is: a bundle
373 contributes lane metadata by exposing a ``lane`` attribute, so this
374 module never imports the optional provider bundle.
375 """
376 lane = getattr(provider, "lane", None)
377 if lane is None:
378 return None
379 if not all(hasattr(lane, field) for field in ("display_label", "free", "verification")):
380 return None
381 return lane
382
383
384def _curated_models(provider: object, lane: object | None) -> tuple[str, ...]:
385 """The lane's curated ids, in declaration order."""
386 if lane is not None:
387 return tuple(model.model_id for model in getattr(lane, "models", ()))
388 try:
389 return tuple(provider.models().keys()) # type: ignore[attr-defined]
390 except Exception: # noqa: BLE001 — a provider that cannot list is simply empty
391 return ()
392
393
395 providers: Sequence[object],
396 *,
397 env: Mapping[str, str] | None = None,
398 cached_models: Mapping[str, Sequence[str]] | None = None,
399 fetched_at: Mapping[str, str | None] | None = None,
400) -> tuple[LaneOption, ...]:
401 """Project *providers* into level-one rows (P1).
402
403 EVERY registered provider contributes a row — the catalogue lanes and
404 the plain deepseek / ollama bundles alike — because level one is "which
405 lane", and a lane that is missing from the list is a lane the operator
406 cannot choose. A lane whose key variable is unset is included and
407 marked NOT selectable, carrying the variable name so the refusal can
408 point at the wizard.
409
410 Only the PRESENCE of each key variable is read; the value never enters
411 the returned rows (plan 215 P6). *env* defaults to the live
412 environment; callers in tests pass an explicit mapping.
413 """
414 func = "lane_options"
415 environ = os.environ if env is None else env
416 cache = dict(cached_models or {})
417 stamps = dict(fetched_at or {})
418
419 rows: list[LaneOption] = []
420 for provider in providers:
421 name = getattr(provider, "name", "") or ""
422 if not name:
423 continue
424 lane = _lane_row(provider)
425 key_var = getattr(provider, "api_key_env_var", "") or ""
426 has_key = bool((environ.get(key_var) or "").strip()) if key_var else True
427 rows.append(LaneOption(
428 name=name,
429 display_label=(getattr(lane, "display_label", "") if lane is not None else "") or name,
430 free=(getattr(lane, "free", None) if lane is not None else None),
431 verification=(getattr(lane, "verification", "") if lane is not None else ""),
432 api_key_env_var=key_var,
433 has_key=has_key,
434 selectable=(not key_var) or has_key,
435 models=_curated_models(provider, lane),
436 cached_models=tuple(str(m) for m in cache.get(name, ())),
437 models_fetched_at=stamps.get(name),
438 ))
439
440 # Free lanes lead — the same presentation policy the 215 panel applies,
441 # and for the same reason: those are the lanes a new operator can try.
442 ordered = tuple(sorted(rows, key=lambda row: (row.free is not True, row.name)))
443 _dbg("OC_CHAT.MAIN", func, f"projected {len(ordered)} lane row(s)", 3)
444 return ordered
445
446
447__all__ = [
448 "CLASS_FREE",
449 "CLASS_LABELS",
450 "CLASS_ORDER",
451 "CLASS_PAID",
452 "CLASS_UNKNOWN",
453 "Choice",
454 "FREE_SUFFIX",
455 "LaneOption",
456 "NO_FAMILY",
457 "SERVING_HOST_FIELDS",
458 "classify_models",
459 "collapse_choices",
460 "families",
461 "family_of",
462 "group_by_family",
463 "lane_options",
464 "model_classes",
465 "models_in_class",
466 "models_in_family",
467 "section_text",
468 "serving_host_of",
469 "uses_free_suffix",
470 "vendor_detail",
471 "vendor_of",
472]
str vendor_detail(str model_id)
Definition lane_tree.py:293
tuple[str,...] _curated_models(object provider, object|None lane)
Definition lane_tree.py:384
tuple[tuple[str, tuple[str,...]],...] group_by_family(Sequence[str] model_ids)
Definition lane_tree.py:256
object|None _lane_row(object provider)
Definition lane_tree.py:369
tuple[str,...] families(Sequence[str] model_ids)
Definition lane_tree.py:238
str serving_host_of(object report)
Definition lane_tree.py:303
str section_text(Sequence[str] lines)
Definition lane_tree.py:327
bool uses_free_suffix(Sequence[str] model_ids)
Definition lane_tree.py:165
str vendor_of(str model_id)
Definition lane_tree.py:277
tuple[str,...] models_in_family(Sequence[str] model_ids, str family)
Definition lane_tree.py:247
tuple[str,...] models_in_class(Sequence[str] model_ids, *, bool|None lane_free, str model_class)
Definition lane_tree.py:213
tuple[str,...] model_classes(Sequence[str] model_ids, *, bool|None lane_free)
Definition lane_tree.py:202
str family_of(str model_id)
Definition lane_tree.py:225
tuple[LaneOption,...] lane_options(Sequence[object] providers, *, Mapping[str, str]|None env=None, Mapping[str, Sequence[str]]|None cached_models=None, Mapping[str, str|None]|None fetched_at=None)
Definition lane_tree.py:400
Choice|None collapse_choices(Sequence[Choice] choices)
Definition lane_tree.py:353
dict[str, str] classify_models(Sequence[str] model_ids, *, bool|None lane_free)
Definition lane_tree.py:174