Skip to main content

guides/developer-guide-model-registry.md

# 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