6The selection tree — pure classification over lanes, models and families.
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.
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.
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
42 L2 — lifecycle events (lane rows projected).
43 L3 — semantic details (classification and grouping outcomes).
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.
81- oc_diagnostics (structured debug logging via _dbg)
83from __future__
import annotations
86from dataclasses
import dataclass
87from typing
import Mapping, Sequence
89from oc_diagnostics
import _dbg
91_dbg(
"OC_CHAT.MAIN",
"module",
"loaded", 4)
97CLASS_UNKNOWN =
"unknown"
101CLASS_ORDER: tuple[str, ...] = (CLASS_FREE, CLASS_PAID, CLASS_UNKNOWN)
104CLASS_LABELS: Mapping[str, str] = {
105 CLASS_FREE:
"free models",
106 CLASS_PAID:
"paid models",
107 CLASS_UNKNOWN:
"all models",
117@dataclass(frozen=True)
119 """One answer offered at one level of the drill-down.
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).
129 selectable: bool =
True
133@dataclass(frozen=True)
135 """One level-one row: a lane the operator could switch to.
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).
150 models: tuple[str, ...] = ()
151 cached_models: tuple[str, ...] = ()
152 models_fetched_at: str |
None =
None
156 """``FREE`` / ``PAID`` / ``-`` — the lane's own access tier."""
157 if self.
free is None:
159 return "FREE" if self.
free else "PAID"
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)
171 model_ids: Sequence[str],
173 lane_free: bool |
None,
175 """Map every id in *model_ids* to its access class.
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.
181 func =
"classify_models"
182 ids = [str(model_id)
for model_id
in model_ids]
185 model_id: (CLASS_FREE
if model_id.endswith(FREE_SUFFIX)
else CLASS_PAID)
188 _dbg(
"OC_CHAT.MAIN", func, f
"suffix convention over {len(ids)} id(s)", 4)
190 if lane_free
is None:
191 default = CLASS_UNKNOWN
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}
199 model_ids: Sequence[str],
201 lane_free: bool |
None,
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)
209 model_ids: Sequence[str],
211 lane_free: bool |
None,
214 """The ids in *model_class*, in input order."""
217 model_id
for model_id
in (str(m)
for m
in model_ids)
218 if classified.get(model_id) == model_class
226 """The id's family — the prefix before ``/``, or :data:`NO_FAMILY`.
228 Only the FIRST separator counts: ``a/b/c`` is family ``a``, matching
229 how every routed lane in the catalogue spells its ids.
232 head, sep, _ = text.partition(
"/")
233 if not sep
or not head.strip():
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)
248 """The ids belonging to *family*, in input order."""
250 str(model_id)
for model_id
in model_ids
if family_of(model_id) == family
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"
263 _dbg(
"OC_CHAT.MAIN", func,
264 f
"{len(model_ids)} id(s) -> {len(grouped)} family/families", 4)
274SERVING_HOST_FIELDS: tuple[str, ...] = (
"provider",
"served_by",
"serving_host")
278 """The id's VENDOR — the prefix before ``/`` — or ``""`` where it has none.
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.
287 head, sep, _ = str(model_id).partition(
"/")
288 if not sep
or not head.strip():
294 """``"vendor: <name>"`` for a row that names one, ``""`` for one that does not.
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.
300 return f
"vendor: {vendor}" if vendor
else ""
304 """The serving host a lane REPORTED for one response, or ``""``.
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.
312 func =
"serving_host_of"
313 get = getattr(report,
"get",
None)
314 if not callable(get):
316 for field
in SERVING_HOST_FIELDS:
318 if isinstance(value, str)
and value.strip():
319 _dbg(
"OC_CHAT.MAIN", func, f
"{field} reported {value.strip()!r}", 4)
328 """Join *lines* into ONE block, dropping the blank lines at either end.
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.
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.
343 while body
and not body[0].strip():
345 while body
and not body[-1].strip():
347 return "\n".join(body)
354 """The one selectable choice when a level has exactly one, else ``None``.
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.
360 selectable = [choice
for choice
in choices
if choice.selectable]
361 if len(selectable) == 1:
370 """The catalogue lane row behind *provider*, if it carries one.
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.
376 lane = getattr(provider,
"lane",
None)
379 if not all(hasattr(lane, field)
for field
in (
"display_label",
"free",
"verification")):
385 """The lane's curated ids, in declaration order."""
387 return tuple(model.model_id
for model
in getattr(lane,
"models", ()))
389 return tuple(provider.models().keys())
395 providers: Sequence[object],
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).
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
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.
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 {})
419 rows: list[LaneOption] = []
420 for provider
in providers:
421 name = getattr(provider,
"name",
"")
or ""
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
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,
434 selectable=(
not key_var)
or has_key,
436 cached_models=tuple(str(m)
for m
in cache.get(name, ())),
437 models_fetched_at=stamps.get(name),
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)
457 "SERVING_HOST_FIELDS",
str vendor_detail(str model_id)
tuple[str,...] _curated_models(object provider, object|None lane)
tuple[tuple[str, tuple[str,...]],...] group_by_family(Sequence[str] model_ids)
object|None _lane_row(object provider)
tuple[str,...] families(Sequence[str] model_ids)
str serving_host_of(object report)
str section_text(Sequence[str] lines)
bool uses_free_suffix(Sequence[str] model_ids)
str vendor_of(str model_id)
tuple[str,...] models_in_family(Sequence[str] model_ids, str family)
tuple[str,...] models_in_class(Sequence[str] model_ids, *, bool|None lane_free, str model_class)
tuple[str,...] model_classes(Sequence[str] model_ids, *, bool|None lane_free)
str family_of(str model_id)
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)
Choice|None collapse_choices(Sequence[Choice] choices)
dict[str, str] classify_models(Sequence[str] model_ids, *, bool|None lane_free)