ADR-040: Curated Single-Source Model Registry¶
Status: Accepted
Date: 2026-06-25
Related: agentic_v2/config/defaults/model_registry.yaml (new),
agentic_v2/models/model_registry.py (new), agentic_v2/models/router.py
(DEFAULT_CHAINS), agentic_v2/langchain/models.py (_TIER_FALLBACK_CHAINS,
_TIER_DEFAULTS), agentic_v2/scoring/judge.py,
agentic_v2/langchain/model_builders.py. Builds on the discovery story of
ADR-037/038/039; includes the probe-time drift detection it set up.
Context¶
Model identifiers were declared independently in three places that drifted apart:
models/router.py—DEFAULT_CHAINS(native engine, perModelTier).langchain/models.py—_TIER_FALLBACK_CHAINS/_TIER_DEFAULTS(LangChain engine).config/defaults/agents.yaml— per-agentdefault_model/fallback_models.
This triplication caused a production incident: the retired gemini-2.0-flash
and gemini-2.0-flash-lite ids began returning 404 ("model no longer
available"). The fix (PR #126) had to edit all three files by hand, and nothing
flagged that the ids were retired — it surfaced only as runtime 404s.
The three sources had also silently diverged on more than the retired ids:
router.pypinnedanthropic:claude-sonnet-4-5-20250929whilelangchain/models.pypinnedanthropic:claude-sonnet-4-6-20260219for the same logical tier.- The two engines encoded different routing philosophies: the native router
escalated high tiers (
gemini-2.5-pro,claude-opus-4-6); the LangChain engine stayed ongemini-2.5-flash/claude-sonnetfor tiers 3–5 and added a local Ollama tail. Neither divergence was a deliberate, recorded decision.
Decision¶
Introduce one curated registry — a YAML data file
(config/defaults/model_registry.yaml) plus a typed loader
(models/model_registry.py) — as the single source of truth for tier fallback
chains, the special-purpose model ids, and a per-token price table. Both engines
and the judge/NotebookLM call sites read from it.
Guiding principle — dynamic for facts, curated for judgments:
- Dynamic (verified at runtime): availability, health/circuit state,
rate-limit headroom. These already come from the discovery probes
(ADR-037/038/039),
rate_limit_tracker, andmodel_stats. - Curated (human-owned, reviewed): which models belong in which tier, capability classification, and per-token price. A machine cannot safely decide these, so they live in the registry and change only by a reviewed edit.
Chain reconciliation¶
The two divergent chain sets are unified into one canonical chain per tier:
cloud-capability-first with a local Ollama tail, escalating tiers 4–5 to
gemini-2.5-pro / claude-opus-4-6. The sonnet-4-5 vs sonnet-4-6
divergence is reconciled to claude-sonnet-4-6-20260219. Net effect: the native
router gains the richer chains (anthropic + local tail); the LangChain engine
gains capability escalation at tiers 4–5. This is a deliberate choice favoring
capability over cost at the top tiers; it is a one-line-per-tier data edit to
revert to cost-saving flash/sonnet if desired.
Pricing¶
price_in / price_out (USD per Mtok) and context_window live in the
registry, seeded null. Cost-per-token cannot be probed — provider
/models endpoints return ids only (ADR-039; CloudModelInfo carries only
id) — so price is necessarily a maintained table. compute_spend() turns
observed token usage into a dollar figure, or None when a price is uncurated
(never a guessed zero). Populating real prices and wiring compute_spend into
spend tracking are follow-ups.
Scope boundaries (what the registry does NOT own)¶
GH_BACKUP_MODELSstays inlangchain/model_utils.pynext to the provider gate (PROVIDER_ENV_KEYS) it belongs to. Pulling it into the registry would create amodel_utils↔model_registryimport cycle for a two-id fallback whose ids are already declared in the registry catalog.- Named-agent model fields (
agents.yamldefault_model/fallback_models) are not migrated. An audit found them vestigial: agent model selection flows through the agent's tier (now registry-sourced), and the only reader of those fields (/api/agents) surfaces justname/description/tier. Adding an agents block to the registry would be dead, misleading data.
Validation & provider authority¶
The loader validates that every id referenced by a tier chain or special slot
has a models: entry (catches dangling references), and that every model's
provider is in PROVIDER_ENV_KEYS — the authoritative routable-provider set,
not _KNOWN_PREFIXES (a display superset that includes keyless namespaces
like azure: / windows-ai:). Env-var precedence is preserved unchanged:
per-step model_override → AGENTIC_MODEL_TIER_{n} → probed default → registry
chain → GH_BACKUP_MODELS.
Consequences¶
- A retired or mis-typed id is now a one-file edit, and a dangling reference fails loudly at load time (a unit test loads the production file and asserts no dangling references).
- Behavior change: both engines now use the reconciled chains; tiers 4–5 escalate
to
gemini-2.5-pro/claude-opus-4-6(a cost/capability shift for the LangChain engine's high tiers). - Probe-time drift detection (implemented):
detect_registry_drift()runs insideprobe_and_update_tier_defaults()(so at server startup and on every/api/models/probe). It diffs the curated catalog against the livediscover_cloud_models()listings and warns + quarantines any pinned id a keyed provider no longer lists — automatically catching the nextgemini-2.0-flash. Quarantined ids are dropped from routing by both engines (native router skips them; the LangChain candidate list filters them). It warns and filters, never auto-promotes a newly discovered id into a chain. A provider that returns no listing is treated as unknown, never "all retired", so an absent key cannot mass-quarantine. No-op underAGENTIC_NO_LLM;AGENTIC_REGISTRY_STRICT=1raisesRegistryDriftErrorinstead of warning so a CI/probe job fails loudly. TheDriftReportis surfaced as an additivedriftkey in the probe response (no wire-format change).
Alternatives considered¶
- Keep the three hand-maintained sources — rejected; this is exactly what caused the incident and the silent divergence.
- Fully dynamic model list (discover everything at runtime, no curated file) — rejected: pricing is not discoverable, tier/capability assignment is a human judgment, and unattended auto-routing to a newly discovered, unvetted model is unsafe.