Skip to content

Scope Overrides

The deployment has one catalogue of providers and one catalogue of models. An organization does not get a copy of it. It gets the same catalogue with a patch applied — a small record saying only what that scope changes, keyed on the scope and the provider slug.

That distinction is the whole design, and it is worth stating why before the mechanics.

Why a patch and not a copy

Had an override been a copy of the provider, every overriding organization would get its own provider ID. Model configurations are keyed on {providerConfigId, modelId} and carry no scope of their own, so the entire model catalogue would have had to be duplicated beneath each organization, or become unreachable from it. Routing configurations point at provider IDs too, so they would have become per-organization as a side effect.

So there is exactly one provider document per slug, exactly one model document per (provider, model), and a scope adjusts them.

It also means the ceiling is real: an organization cannot add a provider the deployment does not have, and a department cannot reach a provider its organization has turned off. A scope narrows what it inherits; it never widens it.

The two records

Provider overrideModel override
AnswersHow this scope reaches a providerWhether this scope may use a model
Keyed onscope + providerSlugscope + providerSlug + modelId
Can setcredential, base URL, connection settings, rate limits, priority, enabledenabled
Carries pricingNoNo

They are deliberately not one endpoint. They look alike and are not: changing a credential and withdrawing a model are different decisions, and sharing a body shape would mean one payload carrying two meanings and a discriminator to tell them apart.

Neither carries pricing. A rate is a property of the model at the provider, so it lives on the catalogue entry once and does not vary by scope.

Sparse patch semantics

Only the keys you send change. An absent key means inherit.

Absent is not the same as false, and not the same as null:

  • A provider override with only auth.apiKey set leaves the deployment’s rate limits, priority, timeouts and free-tier configuration exactly as they were.
  • A model override that is absent means the scope follows whatever the level above decides. enabled: false is a scope deliberately withdrawing a model; enabled: true is a scope taking one the deployment left off.

The route assigns field by field for the same reason. Replacing the document wholesale would make editing a rate limit silently drop the scope’s stored credential.

Resolution order

At request time the gateway resolves the provider the caller actually gets:

Base catalogue entry
▼ organization patch applied (only the fields it sets)
▼ department patch applied (only the fields it sets)
What this request uses

The narrowest scope is written last and wins, matching the settings cascade. Both patches are loaded in one query and then sorted by level explicitly — relying on the driver’s return order would make a department’s patch lose to its organization’s about half the time, intermittently.

If the resolved result is disabled, the request fails with PROVIDER_NOT_CONFIGURED, including when it was a scope that disabled it. That is how an organization withdraws a provider from its own people.

Model overrides resolve the same way, and only ever answer whether a model is reachable. The catalogue row itself is never duplicated.

Per-scope credentials

An organization can point a provider at its own key, so its traffic bills to its own account rather than the deployment’s.

The credential is encrypted with the scope bound into the encryption’s associated data — scopeKind:scopeId. Provider keys on the base catalogue document are bound to the tenant, and because there is exactly one tenant that value is a constant, which authenticates nothing: a ciphertext lifted from one organization’s override would otherwise decrypt cleanly under another’s. Binding to the scope restores the property.

Two consequences worth knowing:

  • A stored key is never returned. The API reports auth.apiKeySet: true and nothing else. Whether one is set is the part that tells an operator whether the field is safe to leave alone; the value itself has no reason to leave the server.
  • Decryption failure is loud. A key that cannot be decrypted raises rather than falling through with ciphertext in the credential field, which would present as a rejected key from the vendor and send someone to rotate a key that was fine.

Departments do not set credentials. They inherit whichever key their organization resolved.

In the admin console

The Providers and models panel appears on both the organization and the department detail pages. It is the same component at both levels, because it is the same rule twice.

Each provider row shows one of four states:

StateMeaning
Inherited — onNo patch here; the deployment has it enabled
Inherited — offNo patch here; the deployment has it disabled
Turned on hereThis scope enabled it
Turned off hereThis scope disabled it

Expand a row for its models and, on an organization, its credential field. Rows carrying a decision are highlighted, and Only what this scope changed filters the catalogue down to them — a provider counts as changed if it has a patch of its own or any of its models do.

Inherit again removes the patch and returns the row to whatever the level above decides.

These controls do not save with the surrounding form. Each toggle is its own request against its own record, so a change has either happened or it has not. Putting them behind the drawer’s Save button would imply the whole drawer commits atomically, which it does not.

Endpoints

Provider overrides

MethodPathPermission
GET/api/admin/provider-overridesproviders:read
PUT/api/admin/provider-overridesproviders:update
DELETE/api/admin/provider-overrides/:scopeKind/:scopeId/:providerSlugproviders:delete

GET returns the patches visible to the scope the session is currently acting in — a scoped session sees its own and no one else’s, and a deployment-scoped session sees all of them. It filters on the acting scope, not on a query parameter, which is what stops ?scopeId=<someone else's> being a way to read another organization’s configuration.

PUT creates or updates the patch for one provider in one scope:

{
"scopeKind": "organization",
"scopeId": "665f...",
"providerSlug": "openai",
"auth": { "apiKey": "sk-..." },
"enabled": true
}

scopeKind is organization or department. There is no system — the deployment’s own values live on the catalogue entry itself, and a second place to set the same thing is a second thing to disagree.

The remaining optional keys are baseUrl, priority, auth.headerName, auth.headerPrefix, connectionConfig (timeout, maxRetries, initialRetryDelay, maxConcurrency) and rateLimits (requestsPerMinute, requestsPerDay, tokensPerMinute, tokensPerDay).

DELETE drops the patch entirely and the scope inherits again.

Model overrides

MethodPathPermission
GET/api/admin/model-overridesmodels:read
PUT/api/admin/model-overridesmodels:update
DELETE/api/admin/model-overrides/:scopeKind/:scopeId/:providerSlug/:modelIdmodels:delete
{
"scopeKind": "department",
"scopeId": "665f...",
"providerSlug": "openai",
"modelId": "gpt-4o",
"enabled": false
}

The model must exist in the catalogue under the provider named. Model IDs are not unique across providers, so checking the ID alone would accept a decision about a model that provider does not serve — stored, applying to nothing, with no indication why.

What is bounded, and how

Two guards do different jobs, and both are needed.

  • Permissionproviders:update, models:update and so on decide whether the caller may perform the action at all.
  • ScopeassertWithinScope decides whether the target is theirs. An organization administrator genuinely holds providers:update, so the permission check passes on a body naming another organization’s ID. It is the target that has to be bounded, and a refusal is audited.

Both catalogues are guarded from the other direction too: creating, editing or deleting a provider or a model requires deployment scope, whatever permissions the caller holds. Without that, the models:update an organization administrator needs for its own on/off decisions would also let it rewrite what every other organization sees.

Cache invalidation

Saving or removing an override invalidates the routing cache immediately — the resolved chain was computed under the old answer. The next request re-resolves.