# ADR-0044: Unified AI Backend and Model Resolution with a Single-Source-of-Truth Precedence Chain

## Status

✅ Accepted (2026-07-07)

## Context

omni-dev drives an AI model from several entry points — the CLI subcommands,
the MCP server tools, and the preflight credential check that validates an
environment *before* a command spends money. Each of those has to answer the
same three questions: which **backend** to dispatch to, which **model** to
send, and which **beta header** (if any) to attach.

[ADR-0002](adr-0002.md) established the dispatch *mechanism* — a
`Box<dyn AiClient>` trait with in-process implementations selected at startup
by environment variable. It did not, however, own the *selection*: the switch
that read `USE_OPENAI` / `USE_OLLAMA` / `CLAUDE_CODE_USE_BEDROCK` and picked a
concrete client lived in `create_default_claude_client`
([src/claude/client.rs](../../src/claude/client.rs)), and a **second copy** of
the same ordering lived in preflight
([src/utils/preflight.rs](../../src/utils/preflight.rs)) so it could report the
backend it *would* pick. [ADR-0028](adr-0028.md) added the sandboxed
`claude-cli` backend and, in doing so, called out the resulting liability
explicitly: "Adding a future backend must update both sites in lock-step." By
the time this decision was taken the two switches had already drifted, and
model selection had accreted its own tangle — per-family environment variables
(`CLAUDE_MODEL`, `CLAUDE_CODE_MODEL`, `ANTHROPIC_MODEL`, `OPENAI_MODEL`,
`OLLAMA_MODEL`), per-subcommand `--model` / `--beta-header` flags, and no single
place that documented which one won.

Three forces made "just tidy the switch" insufficient:

- **The precedence is a public contract.** Users script omni-dev in CI and
  shell rc files, exporting `USE_OPENAI`, `ANTHROPIC_MODEL`, and friends. The
  order in which those are consulted — and what happens when several are set at
  once — is behaviour they depend on, not an implementation detail. It has to
  be stated once, authoritatively, and be expensive to change by accident.

- **Two readers must never disagree.** Preflight exists to tell the user what a
  real invocation will do. The instant its selection logic diverges from the
  factory's, preflight becomes a liar — it green-lights one backend while the
  command runs another. Correctness here is *equality between two call sites*,
  which duplicated code cannot guarantee.

- **Testability without touching the process environment.** The legacy switch
  read `std::env` directly, so exercising the precedence meant mutating
  process-global state and serialising tests. The env-injection seam
  (`EnvSource`, STYLE-0028) already existed elsewhere; resolution should read
  through it so the full precedence matrix can be unit-tested against an
  in-memory map.

[ADR-0022](adr-0022.md) is a neighbouring but distinct axis: it governs which
models *exist* and their token limits (the catalogue), not which model a given
invocation *selects* at runtime. This decision is about selection; it consults
the catalogue only for a provider's default.

## Decision

We will make [src/claude/backend.rs](../../src/claude/backend.rs) the single
source of truth for backend, model, and beta-header resolution. It exposes
three pure functions — `resolve_backend`, `resolve_model`,
`resolve_beta_header` — plus the `AiBackend` enum, and it reads the environment
**only** through an injected `EnvSource` (STYLE-0028). Both consumers call the
same functions: the client factory (`create_default_claude_client`,
[src/claude/client.rs](../../src/claude/client.rs)) and the preflight check
(`check_ai_credentials_with`, [src/utils/preflight.rs](../../src/utils/preflight.rs))
resolve first, then `match` on the returned `AiBackend`. The two can no longer
drift because there is only one decision, made in one place (issue #1118).

### Backend precedence

`resolve_backend` applies a fixed order:

1. `OMNI_DEV_AI_BACKEND` (set directly, or by the global `--ai-backend` flag)
   **wins outright**. This includes the value `default`, which forces the
   direct Anthropic API *even when a legacy `USE_*` flag is set* — the escape
   hatch a user needs to override an ambient shell export for one invocation.
   An unknown value is a **hard error** that lists the valid values, not a
   silent fall-through to some default.
2. When `OMNI_DEV_AI_BACKEND` is unset, the legacy flags apply, first match
   wins: `USE_OLLAMA` → `USE_OPENAI` → `CLAUDE_CODE_USE_BEDROCK`, each selecting
   its backend only when equal to the literal `true`.
3. Otherwise the direct Anthropic API (`AiBackend::Default`).

The `AiBackend` enum backs both the `--ai-backend` clap `ValueEnum` and the
resolved dispatch, so the flag's accepted values and the dispatch arms cannot
disagree (STYLE-0019). `--ai-backend` covers all five backends
(`default` | `claude-cli` | `openai` | `ollama` | `bedrock`); it is no longer
limited to `claude-cli` as it was under ADR-0028.

### Model precedence

`resolve_model` stops at the first non-empty value:

1. An explicit parameter — for callers that carry their own model, such as MCP
   tools and the `run_*` helpers.
2. `OMNI_DEV_MODEL` (set by the global `--model` flag).
3. The backend family's own variables: the Claude family
   (`Default` / `ClaudeCli` / `Bedrock`) reads `CLAUDE_MODEL` →
   `CLAUDE_CODE_MODEL` → `ANTHROPIC_MODEL`; OpenAI reads only `OPENAI_MODEL`;
   Ollama reads only `OLLAMA_MODEL`. The Claude-family variables are consulted
   **only** for Claude-family backends, so a Claude model id can never leak into
   an OpenAI or Ollama request.
4. The registry default for the provider ([ADR-0022](adr-0022.md)), falling back
   to a hard-coded model id if the registry has no entry.

Throughout, a variable set to the empty string is treated as unset, so an
exported variable can be neutralised for a single invocation with `VAR=`.

### Global flags via an env bridge

`--model` and `--beta-header` are promoted to **global** flags alongside
`--ai-backend`; the per-subcommand copies are removed. `Cli::propagate_global_flags`
([src/cli.rs](../../src/cli.rs)) forwards each set flag into the corresponding
environment variable (`OMNI_DEV_AI_BACKEND` / `OMNI_DEV_MODEL` /
`OMNI_DEV_BETA_HEADER`) before any factory runs. This keeps factory and MCP
signatures stable — no new argument is threaded through every command — at the
cost of a stateful clap↔env bridge: the flags are realised by mutating
process-global env, which is why the resolver reads through `EnvSource` and the
bridge is unit-tested in isolation.

### Adding a backend

The contract for extending the system is: one new `AiBackend` enum variant, its
arms in `resolve_backend` / `resolve_model`, one arm in the client factory, and
one arm in preflight. There is no third site to keep in sync.

### Out of scope

- We do not unify the *beta-header* semantics across backends. `claude-cli`
  ignores `--beta-header` because the `claude` binary negotiates betas itself
  through a differently-shaped `--betas` flag; the resolver still parses and
  exposes the header, but the `claude-cli` client drops it.
- We do not validate a resolved model id against the registry at resolution
  time. `claude-cli` in particular accepts registry-unknown aliases, because the
  nested `claude` process owns model naming.

## Consequences

**Positive:**

- The client factory and preflight are provably in agreement: they call the
  same function. Preflight can no longer green-light a backend the command will
  not use.
- The precedence is documented once, on the resolver, and is directly
  unit-testable against an in-memory `EnvSource` — the full backend and model
  matrix (legacy-flag ordering, `default` override, unknown-value error, empty
  reads as unset, no cross-family leak) is covered without mutating process
  state.
- Adding a backend is a mechanical four-site change with a checklist, not an
  archaeology exercise across two drifted switches.
- The `claude-cli` liability that [ADR-0028](adr-0028.md) recorded as open is
  closed: the "two sites in lock-step" it warned about are now one site.

**Negative:**

- The precedence order is now a load-bearing public contract. Reordering it —
  say, making a per-family variable outrank `OMNI_DEV_MODEL` — would silently
  change which model existing scripts send. Changing it is a breaking change,
  and the tests encode the current order precisely to make an accidental change
  loud.
- Global flags are implemented by mutating process-global environment in
  `propagate_global_flags`. This is a deliberate clap↔env bridge rather than
  threaded arguments; it keeps signatures stable but means the flags' effect is
  observable only through the environment, and tests that exercise it must
  serialise on the process env.

**Neutral:**

- `claude-cli` remains a special case on two axes — it ignores `--beta-header`
  and it accepts registry-unknown model aliases. The unification is of the
  *selection precedence*, not of every backend's downstream behaviour.
- Setting several selectors at once is not an error but a resolved outcome:
  `OMNI_DEV_AI_BACKEND` beats the legacy `USE_*` flags, and among the legacy
  flags `USE_OLLAMA` beats `USE_OPENAI` beats `CLAUDE_CODE_USE_BEDROCK`. The
  order is the contract; collisions are decided, not diagnosed.
