Skip to content

0106 — Providers are a registry; every model reference names its provider ​

Status: Accepted (supersedes the single-lead-provider half of ADR 0097)

Context ​

Model configuration had exactly one provider identity:

yaml
model:
  provider: openai            # openai | anthropic-oauth | openai-codex
  name: protolabs/reasoning   # bare — means "in whatever provider is"
  api_base: https://…/v1      # THE gateway
  api_key: …                  # THE gateway's key

Every other model slot (aux_model, compaction.model, goal.eval_model, subagent models) either inherited that provider or carried a qualified <provider>:<model> name (#2574) drawn from a hardcoded triple — graph/llm.py: _SLOT_PROVIDERS = ("gateway", "anthropic-oauth", "openai-codex").

Three consequences, all reported by the operator rather than found in review:

  • "Isn't the current default" is nonsense to a user. OAuthAccountSection rendered "Your Claude subscription is connected but isn't the current default" for every connected account except one. #3097 made all connected accounts visible; it could not make them equal, because the config has a lead provider by construction.
  • Two OpenAI-compatible gateways are unrepresentable. gateway is a single lane backed by a single api_base/api_key pair. A production gateway plus a local vLLM, or two gateways with different key scopes, cannot both exist.
  • A pile of machinery exists only to defend the singleton._MODEL_IDENTITY_KEYS, _drop_host_model_identity, _reconcile_slot_providers, _native_provider_without_gateway, _is_gateway_alias — roughly 150 lines of graph/config.py whose entire job is keeping other slots coherent with the lead provider. Their most expensive failure (fleet members inheriting anthropic-oauth on top of a gateway alias and crash-looping at boot) is a direct product of name and provider being separable in the first place.

Decision ​

Providers become a registry of connections. Every model reference is qualified.

yaml
providers:
  - id: gateway               # slug, operator-chosen, IMMUTABLE
    type: openai-compat
    label: Production gateway # display only, freely editable
    base_url: https://api.proto-labs.ai/v1
    api_key: …                # overlaid from secrets.yaml, like model.api_key was
  - id: local-vllm
    type: openai-compat
    base_url: http://localhost:8000/v1
  - id: claude
    type: anthropic-oauth     # credential lifecycle unchanged (ADR 0097)

model:
  name: gateway:protolabs/reasoning   # qualified, always

type is the kind of connection (openai-compat, anthropic-oauth, openai-codex); id is which one. Multiple entries may share a type — that is the entire point, and what makes several gateways possible.

The grammar is unchanged, its whitelist is not ​

split_slot_target already parses <prefix>:<model> and already refuses to claim a prefix that isn't a known provider — which is what keeps bedrock:anthropic.claude a model name. The only change is that the whitelist becomes the registered provider ids instead of a hardcoded tuple. Provider ids are therefore constrained to [a-z0-9][a-z0-9_-]* (no colon, no slash), so the grammar stays unambiguous by construction.

Migration is an identity function ​

The three legacy lane names are kept as the three default ids: a config with no providers: key synthesizes gateway from model.api_base/api_key, and anthropic-oauth / openai-codex entries when a credential store exists for them. Every already-stored gateway:… / anthropic-oauth:… / openai-codex:… slot value therefore keeps resolving to the same place, and a bare model.name is qualified with the legacy model.provider's lane. No operator action, no rewrite of fleet member configs.

Ids are immutable; labels are not ​

An id appears inside stored model values, and via the fleet host layer those values can live in another instance's config, which a rename cannot reach. So an id is chosen once at creation and frozen; renaming is remove-and-re-add. The label is display-only and freely editable, which is where the ergonomics live.

The legacy fields become derived, deprecated aliases ​

config.model_provider, config.api_base and config.api_key are read by forks, plugins, snapshot import/export and the fleet host layer. They remain as read-only properties derived from the primary model's provider, marked deprecated, and are scheduled for removal no earlier than v0.152.0. Nothing in core reads them.

What is deleted, not adapted ​

_reconcile_slot_providers, _native_provider_without_gateway, _is_gateway_alias, _MODEL_IDENTITY_KEYS and _drop_host_model_identity are removed. A slot cannot be incoherent with the lead provider when there is no lead provider: a qualified name either names a registered provider or is a plain model id. The fleet crash-loop these defended against becomes unreachable rather than defended.

The console surface ​

Adding a Connections panel beside the pre-registry fields made Settings ▸ Model contradict itself: it says "there is no default to switch between" and then, lower on the same page, offers a Provider dropdown and API base URL / API key — the same data as the gateway connection, with a different write path. So the panel is not an addition, it is a replacement, and the fields it replaces have to go.

Settings ▸ Model becomes three blocks:

  1. Connections — the only place an endpoint or key is entered. Add / Test / Remove per row. Absorbs model.api_base, model.api_key, model.provider, and the header "Get models" / "Test connection" buttons (both were single-gateway questions: with several connections, "which one?" has no answer).
  2. Model selection — which connection and model each slot uses. The primary model becomes a grouped picker (provider heading, bare model names — the convention chosen in #2581) storing the qualified value. The other slots already speak slot_models; they simply widen to every connection.
  3. Behavior — temperature, max tokens, thinking, reasoning effort, vision, iterations, timeouts. Untouched: none of it is provider-scoped.

Connections keep today's scope split ​

model.api_base is host-scoped and model.api_key is agent-scoped (ADR 0047), so a box shares one endpoint while each agent holds its own key. A single instance-level providers: list would have silently dropped that, which on a fleet member is a capability regression rather than a simplification. The registry therefore mirrors it:

  • A connection's identity and endpoint (id, type, label, base_url) may be declared in the Host layer (host-config.yaml) and is inherited by every instance on the box. An instance-level entry with the same id overrides it, per-field.
  • A connection's key is always read from the instance's own secrets.yaml under providers.<id>, never from the Host layer — the same reason model.api_key is agent-scoped today.

The replaced fields are retired over one release, not cut ​

model.provider, model.api_base and model.api_key stay in the settings schema for one release but stop being rendered — they carry ui_hidden, the flag this codebase already uses for "a key a dedicated panel now owns". Deleting them outright would break anything reading them over /api/settings — forks, snapshot import, the fleet host layer — with no warning, and ui_hidden keeps every one of those paths intact while removing the second editor. They are removed no earlier than v0.152.0, alongside the config attributes they mirror.

Hiding them from build_schema would also have removed them from the cascade diagnostic, which walks the same builder: "where did this value come from?" must still answer for a key that still drives behaviour and is still inherited through the Host layer. build_schema(include_hidden=True) keeps the explainer whole; rendering paths keep the default.

First run adds a connection ​

The setup wizard asks for provider + base URL + key today, writing the three legacy fields. It writes one connection instead, and nothing else — first run is where the registry's shape is easiest to teach, and a wizard that still wrote the legacy fields would recreate the divergence this section exists to remove.

Consequences ​

  • Settings ▸ Model's account section becomes Settings ▸ Providers — a list you add to, test and remove from, with no "active" state. The generic flat-key settings schema cannot express a list of objects, so this gets its own panel and routes, as Delegates and Plugins already do.
  • Model pickers group by provider, reusing the heading + MenuSeparator convention chosen in #2581: rows show bare model names, the heading carries the account.
  • Visible change: dropdowns and stored values read gateway:protolabs/reasoning rather than protolabs/reasoning, including for single-provider operators. This was already true of slot pickers (#2580); it now extends to the primary model. The qualified form is the stored value; the UI never asks anyone to type it.
  • A provider whose credential is missing or expired is listed with configured: false and a reason, never omitted — "sign in to use Claude" beats silence (the #2580 rule, now applying to every entry rather than three fixed lanes).
  • Adding a raw-API-key Anthropic or OpenAI connection (no gateway, no OAuth) is a new type and nothing else. Explicitly out of scope here, deliberately cheap later.

ADR 0097 (native OAuth providers — credential lifecycle unchanged), ADR 0047 (host-scoped model config), ADR 0089 (intra-instance trust), #2574/#2580/#2581 (the qualified grammar and its pickers), #3097 (listing every connected account), #3104 (provider/model coherence at load — its check is subsumed by qualification).

Part of the protoLabs autonomous development studio.