connections table as tool connections, distinguished by a non-null model_config. They are managed through their own routes in apps/control/app/api/model_providers.py.
Supported providers
The base URL is fixed by the provider definition for the first three; only
openai_compatible reads base_url from the request, and it is validated hard: HTTPS only, no userinfo, no query, no fragment, a port in range if present, and a path ending in /v1. The authority is lowercased and the trailing slash removed before storage.
provider_kind is what the gateway’s translation table keys on. obol-types also defines openai_responses, but no control-plane provider definition produces it today.
Create a connection
Connect a model provider
connection.manage operator action. The response projects the connection without the credential:
Response
What happens in order
1
Validate
The pattern and upstream model are checked for length, control characters, and whitespace. A pattern may contain at most one
*, and only as the whole string, a prefix, or a suffix. An upstream model may contain none.2
Refuse a duplicate pattern
Under the workspace lock, a second non-disabled connection claiming the same
model_pattern is a 409. Two rows matching one pattern would make selection order-dependent.3
Insert pending and publish
The row is inserted with
status: pending and no credential, and the snapshot is republished. A pending row publishes no model, so nothing is routable yet.4
Seal
Only once the published version covers the workspace’s current version does control ask the gateway to seal the key (
POST /internal/v1/credentials/seal), under a single-use capability token bound to the workspace, the connection, the auth-profile digest, and the expected credential revision.5
Activate and republish
The sealed envelope is applied, the row goes
active, and the snapshot is published again — this time carrying the model.last4.
List and disable
List model providers
Disable one
workspace.read; disabling requires connection.manage. Disable is idempotent: an already-disabled row is projected back unchanged with no audit row and no republish. Disabling sets status: disabled, which drops the model from the next snapshot; the credential ciphertext stays on the row. To destroy the stored credential without destroying the record, see Credential brokering.
Every mutation writes an audit row — MODEL_CONNECTION_START, MODEL_CONNECTION_CREDENTIAL_STORE, MODEL_CONNECTION_DISABLE — carrying the provider, the pattern, the status, and last4, never the key.
How a model reaches the gateway
models_from_graph publishes a snapshot entry only for a connection that is active, has a model_config, has a model_provider target, and holds a credential envelope. The entry is:
Snapshot model entry
name is the pattern, not a model name. The gateway matches the client’s model string against it.
Selection
ModelRouter::resolve in apps/gateway/crates/obol-route/src/lib.rs:
- An exact match on
namewins. - Otherwise the first entry whose
namecontains*and glob-matches, in snapshot order. - Otherwise
ModelNotFound, rendered to the client as “model is not available in this workspace”.
This is not cost-and-quality scoring. Deterministic scoring on price, quality, coverage, auth mode, and surface applies to tool routing through capabilities, not to model selection. See Capabilities.
complete action decides whether it may call it. A model the key cannot see is refused with ModelNotAllowed — the same code an unavailable model produces, so the surface is not a catalogue oracle.
Fallbacks
ModelSnapshot.fallbacks is a list of other model names in the same snapshot. The gateway builds an ordered candidate list from the primary plus every fallback that passes its own CEL visibility check and its own Cedar complete decision, then attempts them in order in apps/gateway/crates/obol-gateway/src/routes/v1.rs.
A candidate is skipped and the next tried when:
- The upstream host fails the netguard egress check.
- Its connection is missing, inactive, or carries no credential envelope.
- The vault unwrap fails.
- The upstream call fails with a retryable error and this is not the last candidate.
NotReady if any eligible candidate has no published price.
Model fallback is a distinct mechanism from capability fallback. allow_fallbacks on a route qualifier is reserved and acted on by nothing (ADR-0027’s acceptance note); see Capabilities.
Pricing and budgets
Model prices live in the workspace pricebook (PUT /api/v1/workspaces/{id}/pricing) and ride in the snapshot as pricing. They feed admission reservation and the usage record, not selection. See Metering.
Related pages
LLM routes
The
/v1/chat/completions, /v1/responses, /v1/embeddings, and /v1/messages surfaces.Vault
How the sealed credential is stored and unwrapped.