# Developer Guide: Model Registry and Provider Catalogs
This guide explains how model selection works inside `cli_subprocess_core`.
It is written for maintainers and technical reviewers of the core itself.
## Why the Model Registry Exists
`cli_subprocess_core` owns model policy for the shared CLI stack.
That means the core decides:
- which provider catalog is authoritative
- how requested models are resolved
- how provider defaults are chosen
- how reasoning effort is validated
- which visible error contract downstream code receives
Consumer repos should not re-implement any of those decisions.
The core model boundary is now schema-backed as well:
- `CliSubprocessCore.ModelRegistry.Model` validates catalog entries through the
shared `Zoi` conventions and preserves future-compatible metadata in `extra`
instead of discarding it.
- `CliSubprocessCore.ModelRegistry.Selection` is the canonical normalized model
payload surface returned downstream.
- `CliSubprocessCore.ModelInput.normalize/3` is the mixed raw-versus-payload
ingress boundary. Downstream repos should consume it instead of inventing a
second model arbitration layer.
## The Core Files
The model-selection internals live in:
- `lib/cli_subprocess_core/model_catalog.ex`
- `lib/cli_subprocess_core/model_registry.ex`
- `lib/cli_subprocess_core/model_registry/model.ex`
- `lib/cli_subprocess_core/model_registry/selection.ex`
- `lib/cli_subprocess_core/ollama.ex`
- `priv/models/codex.json`
- `priv/models/claude.json`
- `priv/models/amp.json`
## What the Catalogs Contain
Each provider catalog is a core-owned source of truth.
For Amp, that source is static JSON only.
For Claude and Codex, the source is split:
- static core catalog for the canonical Claude model surface
- explicit backend-aware external validation for the Ollama path
- static core catalog for the canonical Codex/OpenAI model surface
- explicit backend-aware external validation for the Codex local OSS/Ollama
path
The static catalog defines, per model:
- `id`
- `aliases`
- `visibility`
- `family`
- whether it is the provider default
- optional reasoning-effort mappings
- provider metadata
This gives the core one place to answer:
- “is this model known?”
- “what is the default?”
- “what visibilities are exposed?”
- “which reasoning values are valid for this model?”
## Current Codex Catalog Evidence
The bundled Codex catalog was verified on 2026-07-10 with an authenticated
`codex-cli 0.144.1` `model/list` request using `includeHidden: true`. The
current public lineup is `gpt-5.6-sol` (default), `gpt-5.6-terra`,
`gpt-5.6-luna`, `gpt-5.5`, `gpt-5.4`, `gpt-5.4-mini`, and the ChatGPT Pro
research-preview `gpt-5.3-codex-spark`; `codex-auto-review` is internal.
The pulled upstream source registry can lead the live backend. In the same
checkout it placed Sol first and still included `gpt-5.2`, while the live
backend made Sol the default, exposed Spark, and did not return `gpt-5.2` even
as a hidden entry. Maintainers must use the authenticated live result for the
bundled Codex CLI catalog and record the CLI version and probe date when it
changes.
The GPT-5.6 variants are explicit Codex CLI IDs; this catalog does not add the
OpenAI API's `gpt-5.6` family alias. Sol and Terra support `low`, `medium`,
`high`, `xhigh`, `max`, and `ultra`; Luna supports the same set except
`ultra`. The live response reports Sol's default as `low` and Terra/Luna as
`medium`. Spark is text-only, supports `low` through `xhigh`, defaults to
`high`, and is not available through the OpenAI API during its preview.
## Resolution Sequence
The authoritative resolution order is:
1. explicit request
2. environment override
3. provider default
4. remote default
5. hard failure
That resolution happens in `CliSubprocessCore.ModelRegistry.resolve/3`.
The output is a resolved selection that includes:
- provider
- requested model
- resolved model
- resolution source
- provider backend
- model source
- payload env overrides
- backend metadata
- reasoning and normalized reasoning effort
- model family
- catalog version
- visibility
- error list
## Validation Responsibilities
The registry has separate responsibilities that should stay separate:
- `resolve/3` chooses the final model and backend path
- `validate/2` checks whether a requested model is valid for the resolved backend
- `default_model/2` reads the effective provider default
- `normalize_reasoning_effort/3` validates reasoning input against the chosen
model
- `build_arg_payload/3` returns the resolved selection used by provider command
builders
That separation matters because downstream code often needs one of those steps
without needing the entire resolution flow.
## Error Contract
The core exposes a single visible error vocabulary:
- `{:error, {:unknown_model, requested_model, suggestions, provider}}`
- `{:error, {:invalid_reasoning_effort, requested, allowed, provider}}`
- `{:error, {:model_unavailable, provider, reason}}`
- `{:error, {:empty_or_invalid_model, reason, provider}}`
This is important for maintainability. The provider profiles and consumer repos
can handle a stable contract instead of inventing provider-specific error rules.
## Where the Selection Is Used
After the registry resolves the model, the built-in provider profiles read that
selection and format CLI arguments and env.
The provider profiles are:
- `lib/cli_subprocess_core/provider_profiles/codex.ex`
- `lib/cli_subprocess_core/provider_profiles/claude.ex`
- `lib/cli_subprocess_core/provider_profiles/amp.ex`
Those modules should not make a second policy decision. Their job is to turn
the resolved selection into transport arguments such as `--model ...` and the
backend-owned env attached to the payload.
## Minimal Integration Example
An integrating caller should do this:
```elixir
{:ok, selection} =
CliSubprocessCore.ModelRegistry.build_arg_payload(
:codex,
"gpt-5.6-sol",
reasoning_effort: :max
)
selection.resolved_model
# => "gpt-5.6-sol"
```
After that, provider-specific command building can safely use the resolved
selection without re-deciding the model.
When a caller may receive either raw model knobs or an already-resolved
selection, use `CliSubprocessCore.ModelInput.normalize/3` as the canonical
mixed-input boundary. It accepts raw attrs or `model_payload`, validates
consistency when both are present, and returns normalized attrs with the
authoritative payload attached.
Across the current first-party provider SDK repos, that means:
- `claude_agent_sdk` and `codex_sdk` should route mixed
raw-versus-payload model input through `CliSubprocessCore.ModelInput`
- repo-local env defaults are fallback inputs only when no explicit payload was
supplied
- `amp_sdk` is intentionally different today because it does not expose a raw
model-selection surface; it carries an optional payload-only model contract
instead of inventing a second model-input path
For Claude/Ollama, a caller can keep canonical Claude names while mapping them
to an installed external model:
```elixir
{:ok, selection} =
CliSubprocessCore.ModelRegistry.build_arg_payload(
:claude,
"haiku",
provider_backend: :ollama,
anthropic_base_url: "http://localhost:11434",
external_model_overrides: %{"haiku" => "llama3.2"}
)
selection.resolved_model
# => "llama3.2"
```
That example still depends on a live Ollama-compatible endpoint at
`anthropic_base_url` and on `llama3.2` actually being installed there. In a
test environment without a running instance, expect the build call to fail
instead of resolving to `"llama3.2"`.
For Codex local OSS via Ollama, the caller should pass the backend intent into
the core and let the registry validate that the local model exists:
```elixir
{:ok, selection} =
CliSubprocessCore.ModelRegistry.build_arg_payload(
:codex,
"llama3.2",
provider_backend: :oss,
oss_provider: "ollama"
)
selection.provider_backend
# => :oss
```
If the local model is not one of Codex's validated defaults, the shared core
still accepts it. The distinction is carried as metadata rather than as a hard
rejection.
If the caller also needs a non-default local Ollama endpoint, pass
`ollama_base_url:` when building the payload. The normalized Codex/Ollama
payload carries that transport choice in `selection.env_overrides` as
`CODEX_OSS_BASE_URL`. Raw Ollama roots such as `http://localhost:11434` are
normalized to the OpenAI-compatible `/v1` base, so downstream CLI renderers
and SDK transports can rely on the payload alone after normalization.
## Reviewer Checklist
When reviewing model-selection changes in core, verify these invariants:
- the provider catalogs remain core-owned
- new provider/model policy enters through the registry, not a profile
- provider profiles only format arguments from resolved state
- no placeholder, blank, or invalid model silently falls through
- the visible error contract remains stable