# ADR-0022: Layered Model Catalog with User and Project Overrides

## Status

✅ Accepted (2026-05-06) — supersedes [ADR-0011](adr-0011.md)

## Context

[ADR-0011](adr-0011.md) records the decision to embed `src/templates/models.yaml`
at compile time and surface it through a singleton `ModelRegistry`. That gave
omni-dev a fast, offline-capable source of truth for per-model `max_tokens`,
input context limits, and beta-header unlocks, and it solved the Bedrock /
regional identifier-normalisation problem.

The negative consequence ADR-0011 explicitly accepted — *"the catalog is
static"* — has bitten in practice:

- A newly released Claude / OpenAI / Gemini model cannot be used at full
  capacity until a new omni-dev release ships. Unknown identifiers fall through
  to provider defaults (e.g. 4 096 output tokens for Claude), silently
  under-capping responses.
- Beta-header unlocks (1M context, 128k output) are keyed to the spec, so
  unknown models lose those too.
- Users running custom or self-hosted Ollama / OpenAI-compatible models with
  non-standard limits have no way to declare them.

Three options were considered:

- **Replace the embedded catalog with a runtime-only file.** Removes the
  offline guarantee — a missing or invalid file would break every AI command.
  Also forces every user to manage a file they otherwise never need to think
  about. Discarded.
- **A single user-side file that wholesale replaces the embedded catalog.**
  Simpler to implement but means every user override has to redeclare every
  model (or accept a much smaller catalog). Discarded — the scenario is mostly
  *additive* (one new model) or *targeted* (one limit corrected), not a full
  replacement.
- **Layered loader: user/project YAML files merged over the embedded
  catalog.** Adds genuine optionality without sacrificing the offline default.
  Selected.

## Decision

`ModelRegistry::load()` builds the registry by merging up to three layers,
from lowest to highest precedence:

1. **Embedded** — `src/templates/models.yaml` via `include_str!`. Always
   present. A parse failure here remains a hard error (compile-time invariant).
2. **User** — `~/.omni-dev/models.yaml` if present.
3. **Project** — `./.omni-dev/models.yaml` if present.

An explicit override path can be supplied via the `OMNI_DEV_MODELS_YAML`
environment variable or the global `--models-yaml <PATH>` CLI flag. When set,
it short-circuits the user/project lookup and is loaded as a single
"override" layer over the embedded catalog. The flag/env var pair mirrors the
pattern already used by `OMNI_DEV_AI_BACKEND`, `OMNI_DEV_CLAUDE_CLI_*`, etc.

### Merge semantics

Layers are deep-merged at the YAML value level before deserialisation:

- **`models` sequence** — entries are merged by `api_identifier`. An entry
  with a matching identifier in a higher-precedence layer is deep-merged onto
  the lower-precedence entry; an entry with a new identifier is appended.
  Entries lacking `api_identifier` are skipped with a warning.
- **`providers` mapping** — each provider block is deep-merged. A user can
  override `providers.claude.default_model` without redeclaring `tiers`,
  `defaults`, `name`, or `api_base`.
- **All other top-level keys** (notably `version`) — last-writer-wins.

### Source tracking

Each `ModelSpec` and `ProviderConfig` carries a `source: ModelSource` field
populated by the loader (never read from YAML). `omni-dev config models show`
serialises the merged configuration with this field exposed and prepends a
header counting models per layer, so users can verify which override is
actually winning. A `--embedded-only` flag emits the embedded catalog
verbatim for diffing against the merged view.

### Schema versioning

The YAML schema gains a top-level `version:` field. The current value is `"1"`
(constant `MODELS_SCHEMA_VERSION`). The embedded catalog declares the version;
user/project files may declare it explicitly:

- Same version → no warning.
- Missing field → accepted with a warning suggesting users add `version: "1"`.
- Different version → accepted with a warning that fields may be ignored or
  misinterpreted.

Versioning is non-fatal by design: a future breaking schema change will still
boot the registry on the embedded catalog, and the warning gives the user
actionable guidance rather than crashing the CLI mid-workflow.

### Failure modes

- **Missing user/project file** — silent fall-through to the next layer.
  Preserves today's behaviour for users who never opt in.
- **Malformed user/project YAML** — `tracing::error!` with the file path and
  parse error; the layer is skipped and lower-precedence layers continue.
- **Missing override path** (env var or flag points at a nonexistent file) —
  `tracing::warn!` and fall through. Distinguished from the silent
  fall-through case because the user explicitly named the file.
- **Malformed embedded YAML** — hard error, as before. The embedded YAML is
  validated by the test suite and `serde_yaml::from_str` round-trips at
  load time.

## Consequences

**Positive:**

- **No release required to use a new model.** A user adds an entry to
  `~/.omni-dev/models.yaml` and the next omni-dev invocation picks it up.
- **Offline default unchanged.** Without any user files the loader is
  functionally identical to ADR-0011: a single embedded YAML deserialised
  via `include_str!`.
- **Targeted overrides are cheap.** Correcting a single field on the
  `claude` provider or one model entry needs only that field in the user
  file, not a full redeclaration.
- **Source visibility is built in.** `omni-dev config models show` makes
  the precedence chain debuggable from the CLI, no log inspection required.

**Negative:**

- **Merge logic is non-trivial.** The loader now deep-merges YAML values and
  has special handling for the `models` sequence keyed by `api_identifier`.
  This is more code than `serde_yaml::from_str(MODELS_YAML)?` and is unit-tested
  in `src/claude/model_config.rs`'s test module.
- **Source tracking is approximate for partial provider merges.** When a user
  file overrides one field of `providers.claude`, the provider's `source`
  field reports `user` even though most fields still come from the embedded
  layer. This is documented in the `ModelSource` doc comments.
- **Schema versioning is advisory only.** A user file with a wrong version
  loads anyway. We chose warnings over hard refusal so that a future
  forwards-incompatible release does not brick existing user files; users who
  want strictness can grep their logs for the warning.

**Neutral:**

- **No XDG support yet.** Unlike `resolve_config_file` for scopes/guidelines,
  the model catalog only checks `./.omni-dev/` and `~/.omni-dev/`. If a need
  for `$XDG_CONFIG_HOME/omni-dev/models.yaml` emerges, the loader can grow
  another layer without affecting existing behaviour.
- **`OnceLock` initialisation order matters.** `propagate_global_flags` runs
  before any subcommand executes, so `--models-yaml` is observable by the
  first `get_model_registry()` call regardless of when in the command tree it
  fires.
