Model Catalog and Provider Configuration#
This document describes the runtime logic of the model catalog: where data lives, how files interact with code, and how the backend and frontend each consume it. Thinking-effort parameter details are covered in thinking-effort.md.
1. Architecture in one sentence#
The system only remembers the models the user enabled. The settings page is hierarchical — providers first, then models: the first level shows the provider list; only after opening one provider does the page query, live, which models that provider offers — and that query is never persisted. The act of enabling copies a model's full spec, as of that moment, into config.json. The runtime registry ENABLED_MODELS is those few dozen config rows — what get_model() resolves, what the chat page shows, and what the user checked are physically the same data.
Core invariant: selectable in chat = enabled = resolvable by the backend. Not because a merge pipeline keeps two lists aligned, but because there is only one list.
Properties that follow automatically:
- No big files: no full catalog is stored (models.dev has 151 providers, thousands of largely duplicated models); config holds only the handful the user enabled.
- Nothing goes stale: staleness requires storage. The available list is queried live, so it is always current; enabled models' specs are overwritten on demand via the settings page "Refresh" — refreshing only what the user actually uses.
- git stays clean: the program writes only the user's config; the repository holds only hand-written provider.json; the installed package directory is read-only at runtime.
2. Data layout (split by author)#
| Author | Location | Content | Size |
|---|---|---|---|
| Humans (git) | providers/<p>/provider.json (+ <p>.py for dedicated protocols) |
endpoints, thinking, cache, per-model overrides | a few lines each |
| The program (user machine) | config.json → providers.<p>.models |
full specs of enabled models + keys and other user state | tens of lines |
| Third party (network) | models.dev + official /v1/models |
live sources for settings-page browsing | never persisted |
openprogram/providers/ ← all git-tracked, read-only at runtime
├── deepseek/
│ ├── provider.json ← all hand-written config for this provider (section 3)
│ └── deepseek.py ← wire/stream implementation (only for dedicated protocols)
├── enabled_models.py ← ENABLED_MODELS: loads config + endpoint fill + thinking derivation
└── models.py ← get_model / get_providers / get_models
~/.openprogram/
└── config.json ← the only user-side persistence
Naming rule: nothing here is called a "catalog". One word for five different things is what made the naming confusing, so each module is named for what it holds: enabled_models.py for the registry, storage.py for config persistence, thinking_spec.py for the thinking declarations, and webui's _model_listing/ for the presentation layer. The name ENABLED_MODELS is accurate only because the dict holds enabled models and nothing else.
Providers without a directory (fireworks, together, …): models.dev lists them live; the user enters a key, browses, enables — no file in the package is ever needed.
3. provider.json: the only hand-written file#
All human configuration for a provider in one file; every field optional:
{
"id": "deepseek",
"endpoints": {
"default": {"api": "openai-completions", "base_url": "https://api.deepseek.com/v1"}
},
"thinking": {
"wire_format": "effort_string",
"effort_map": {"minimal": "minimal", "low": "low", "medium": "medium", "high": "high", "max": "max"},
"default_effort": "medium"
},
"cache": {"mode": "none"},
"model_overrides": {
"some-model": {"headers": {"X-Foo": "1"}, "compat": {"no_stream_options": true}}
},
"models_from": null
}
| Field | Purpose | Default behaviour |
|---|---|---|
endpoints |
api/base_url groups referenced by name (opencode has 4, copilot 3; single-wire has just default) |
models.dev base_url + OpenAI-compatible protocol |
thinking |
wire_format / effort maps / per-model levels (formerly thinking.json; see thinking-effort.md) | OpenAI-compatible fallback (low/medium/high) |
cache |
prompt-caching declaration (formerly cache.json) | no explicit cache control |
model_overrides |
per-model headers, compat, endpoint reference, key_prefix — fields machines cannot obtain, folded into the spec at enable time |
none |
models_from |
browsing-source borrowing for subscription providers (claude-code → anthropic) | no borrowing |
The test: if a machine can obtain a field, a human doesn't write it. provider.json contains no model list — the list is browsed live, the spec is copied at enable time.
Directory names use underscores (amazon_bedrock/); id keeps the hyphenated name (amazon-bedrock). Same-service-multiple-protocols (Bailian's OpenAI-compatible + Anthropic-compatible endpoints) = two endpoints of one provider, not two providers.
4. The two actions: browse, enable#
4.1 Browse (live, never persisted)#
Browsing has two levels. Level one: the provider list (settings landing view) = providers with a local provider.json ∪ models.dev's provider index — names and configuration status only, no model names. Level two: the model list — only after the user opens one specific provider does the page query that provider:
list_available_models(provider_id)
= the provider's official source (one fetcher, see below)
⊕ models.dev (fills price/capabilities; full fallback when there is no key)
→ merged in memory, returned straight to the frontend
Fetcher placement rule: a provider whose interface departs from the standard
OpenAI shape carries its own fetcher in its own directory. Each source has a
different shape but returns the same contract: success → list[dict]
(each row has at least id/name), failure → {"error": ...}.
| Source shape | fetcher location | example |
|---|---|---|
Standard /v1/models (OpenAI-compatible) |
generic _model_listing/fetchers/openai_compat.py (shared fallback, owned by no single provider) |
openai, openrouter, groq, custom gateways |
Anthropic GET /v1/models + per-model capabilities |
providers/anthropic/list_models.py |
anthropic, claude-code, minimax |
Account-level private endpoint (/v1/models is Cloudflare-blocked; read the subscription's model table instead) |
providers/openai_codex/list_models.py (see fast-tier.md §2.1) |
openai-codex |
| Vendor-specific list API (shape / auth differ from OpenAI-compatible) | providers/<name>/list_models.py: google (query-param key + models/<id> prefix) / amazon_bedrock (boto3 SigV4, not HTTP) / github_copilot (session bearer + capabilities envelope) / deepseek (id-only, enriched after) |
the matching provider |
Convention loading: a provider whose interface departs from standard ships
a list_models.py in its own directory exposing fetch(provider_id, timeout);
the dispatcher's _load_fetcher __import__s it by directory name — the exact
mechanism probe_thinking.probe() uses, so adding a provider needs no central
edit. A standard-interface provider ships no such file and uses the generic
openai_compat. Whether the file exists = whether the provider's interface
departs from standard — a natural, on-demand test.
Whatever the source, fetch_and_normalize is the single normalisation choke
point: it collapses each fetcher's disparate keys (context_length /
context_window / contextWindow, …) into one entry dict, then layers
models.dev on top. Downstream only ever sees the normalised row — never the
source differences.
Ordinary provider results use the short in-memory browse cache. Account-scoped subscription catalogues also keep an atomic last-known-good copy under the profile state directory. A failed request can therefore show the last good rows as stale data without treating them as a successful authoritative refresh. An empty result or an unconfigured provider preserves saved model selections; only a non-empty current official catalogue can retire subscription models.
4.2 Enable (copy the spec into config)#
The user checks a model in the browse list:
enable_model(provider_id, row)
→ spec = browse row ⊕ provider.json.model_overrides[id] ⊕ api/base_url from endpoints
→ thinking levels derived from provider.json.thinking, written alongside
→ appended to config.json providers.<p>.models
→ ENABLED_MODELS reloads
- Disable = delete the row from config. Subscription providers also record an id tombstone so an automatic refresh does not re-enable it.
- Refresh = re-run browse for enabled models and overwrite their specs (handles spec drift over time; touches only what the user actually uses).
- Manually adding a model (one the provider doesn't list) = the user fills in a row in the same form — it writes to the same list; the old
custom_modelsconcept dissolves. - Subscription catalogues = after login, at worker startup when stale, on
the six-hour background cadence, and on manual Refresh, read the account's
official model table. New ids are enabled automatically, changed capability
rows are refreshed, removed ids retire, and explicit disables stay disabled.
Adding a future Codex or Grok model does not require a source-code list edit.
When the persisted enabled rows actually change, the worker broadcasts a
provider_models_changedinvalidation hint. Connected web and desktop clients then re-read the provider and enabled-model endpoints; reconnecting clients invalidate the same queries to cover events missed while offline.
5. How the backend uses it#
# openprogram/providers/enabled_models.py
ENABLED_MODELS: dict[str, Model] # key = "<prefix>/<id>", content = config specs + derived fields
Loaded from config at startup (tens of rows, instant), reloaded when config changes. The three query functions get_model / get_providers / get_models keep their interfaces — the 20+ runtime callers (agent, runtime, failover, …) change nothing. get_model falls back through auth.aliases on a miss.
Contract: the system only knows enabled models. Failover chains and agent configs must reference enabled models; referencing a non-enabled model is a configuration error whose message points to the settings page. Old sessions referencing a deleted model still display history; they just cannot continue with that model.
6. How the frontend uses it#
| Frontend surface | API route | Data source |
|---|---|---|
| Settings landing view: provider list (no model names) | GET /api/providers |
providers with a provider.json + those models.dev lists live (community providers configurable directly) |
| Provider detail view: browse/check that provider's models | GET /api/providers/<id>/available |
live: the level-two browse result of 4.1 + enabled flags |
| Chat model picker | GET /api/models/enabled |
config: ENABLED_MODELS as-is |
| Thinking level picker | (_thinking.py) |
thinking_levels from the ENABLED_MODELS rows |
The webui presentation layer (_model_listing/) does no merging or derivation — browse merging is one function (4.1), spec merging happens at enable time. webui imports providers; providers never import webui.
End to end: enter a key → browse (live list shows deepseek-v4-flash) → check it (full spec written to config; ENABLED_MODELS["deepseek/deepseek-v4-flash"] appears) → pick it in chat and send (get_model hits the same config row). At every moment the system holds exactly one copy of model data.
7. Invariants (check before changing code)#
- Persist only what's enabled: the only persisted model data is the enabled specs in config. Any second persisted list (full snapshot, fetch cache file, hand-written catalog) is a violation.
- Browsing never persists: the available list is a live query + in-memory cache, never written to a file.
- Split by author: human data in git (provider.json); program data in user config; the package directory is read-only at runtime.
- Minimal hand-writing: provider.json stores only what machines cannot obtain, and never a model list.
- One-way layering:
openprogram.providersnever importsopenprogram.webui. - Key compatibility:
"<prefix>/<id>", alias fallback,key_prefix(gemini-subscription dual keys) preserved; the registry stays one mutable dict. - Many sources, one shape: whether a row comes from an official
/v1/models, an account-level private endpoint (codex), the models.dev community catalogue, or a hand-typed entry, it is normalised into one row shape by the singlefetch_and_normalizefunction; enabling routes through the single_upsert_spec_rowchoke point into config (_normalize_spec_rowfills the Model-schema keys); reading routes through the single_build_model_from_rowconverter into aModel. Each of the three choke points is exactly one; nothing may bypass them to mint its own shape.
8. Implementation status#
The design above is what the code does. The registry is ENABLED_MODELS, defined in enabled_models.py; the webui presentation layer is _model_listing/; enabling a model copies its full spec into config; browsing is live with no disk persistence; and thinking.json and cache.json are folded into provider.json, so _default_api_for / _resolve_base_url read endpoints directly and the providers layer no longer reads the registry back.
Properties that any change here must preserve:
- Enablement survives: a config row must resolve through
get_modelto the sameModelit did before, for every provider. - Aliases and dual keys: gemini-subscription's
google-gemini-cli/*andgemini-subscription/*are 10 keys with distinct names. Enabled rows carry their own key and name, and the alias fallback stays in place. - The claude-code borrowing chain: browse data borrowed from anthropic via
models_from, 3 models auto-enabled after login, and its own fetcher. - Field-by-field fidelity: nested
cost, multimodalinput,headers(copilot depends on it), andcompatall travel in the spec written to config at enable time. - Verification granularity: multi-wire providers need one exec per
(api, base_url, headers, compat)combination. tests/unit/providers/registry/test_provider_wire_invariants.pyandtests/unit/providers/registry/test_model_fetch_routing.pystay green.