Where a model's configuration lives

A model's configuration used to live in three places that could not see each other, and the symptoms — a pinned model declared unusable, a config key no template rendered, duplicate provider nodes nobody could repair, a funded key on a node and a dead one in Key Vault — were four faces of that one gap rather than four bugs. This page is the resolution: one administered home per fact, and a named path from every other place into it.

Read this before touching BuiltInLanguageModelProvider, ProviderCredentialSeed, ChatClientCredentialResolver, ModelStaticRepoSource or ModelProviderService. The operational procedures — set a key, rotate one, decouple a partition, the AKS Key Vault layering — are the /provider-keys skill; this page is why they are shaped that way.

The decision

The NODE is the administered home. Deployment configuration is SEED-ONLY. Maintainer, 2026-08-22.

resolve:  the node, and only the node
seed:     configuration → node, converging, never read again at resolve time

One place to look, one place to rotate. That is not a preference, it is what removes a whole class of failure: two live sources that disagree silently, which is exactly what cost an afternoon on 2026-08-21 when a funded OpenRouter key sat on the node while env and Key Vault carried a stale unfunded one, and every full round on ns memex returned HTTP 402.

One administered source per fact

Fact Administered home Bootstrap seed
Provider endpoint Provider/{name} → endpoint {Section}:Endpoint, fill-if-absent
Provider API key Provider/{name} → apiKey (enc: at rest) {Section}:ApiKey, fill-if-absent
A FUNCTION's own key (review, dispatch, chat — One key per function) Provider/{name} → functionKeys[{Function}] (enc: at rest) {Section}:{Function}:ApiKey, converging — the vault secret is its only administered home
The provider's model list the LanguageModel children of Provider/{name} {Section}:Models[], fully synced
Model order / price / tier label the LanguageModel node catalog defaults
Reasoning effort sent on every call the LanguageModel node → reasoningEffort none — provider default until set
Tier registry, tier → model Provider/Tier/{id} ModelTierDefaults, create-if-absent
A user's own key {user}/_Memex/{provider} none — the user pastes it

A deployment's {Section}:* values are the bootstrap fallback for a FRESH deployment, and nothing else. Nothing in the resolution path reads them. ChatClientCredentialResolver walks node rungs only — providerRef → Provider/{name} → the legacy per-model fields — and a model whose provider node has no key is honestly unusable. (A driver factory still applies its own IOptions fallback when it actually calls out; that is the driver's business, not a source of truth this platform arbitrates.)

The seam that had to change: a seed allowed to run TWICE

The catalog's static-repo import marks each Provider/{name} SyncBehavior.ExcludeThisOnly, so the importer CREATES it once and never revisits it. That claim is what protects an admin's key edit from the next boot's re-seed — and it is also why a key added to a deployment AFTER the node existed reached every factory and never reached the node.

That is measured, not hypothetical: on the control instance, Provider/Anthropic was created keyless by system-security on 2026-08-14, Anthropic__ApiKey was configured afterwards, and the node stayed keyless until a human pasted the key in by hand on 2026-08-21. For that week the deployment could serve claude-* rounds and the platform believed it could not.

A seeder that only runs at CREATION cannot converge that. So the credential is no longer that seam's business at all: the import strips it (ModelStaticRepoSource.StripApiKey) and ProviderCredentialSeed runs on every boot of a DB-synced deployment. Its rules:

Two replicas booting together both run it, and that is fine: whichever writes first wins and the other reports AlreadyAdministered. In the narrow window where both decide to write, both values decrypt to the same key.

The read boundary — cryptographic, because nothing else can hold

Moving a secret onto a node changes its security class. A Helm value is a deployment secret; a node's content is application data, readable by whoever can read the node — through the GUI, the REST API, MCP, exports, sync targets and any GitSync binding on the partition.

And the Provider partition must be publicly readable. Its _Policy sets publicRead: true because the model picker queries namespace:Provider nodeType:LanguageModel under the user's own identity; without it RLS filters out every model and the picker is empty. On the public instance that policy was measured still set on 2026-09-01, over seven ModelProvider nodes.

So the question is not "who may read the node" but "what does the node hand back". Three candidate controls were examined and two of them cannot work here:

Candidate Why it does not hold
An access rule that hides the field Access control in MeshWeaver is per-NODE, never per-field, and NodeTypeGate only OPENS surfaces. A policy that opens a provider's LanguageModel children opens the provider node too — the models are its children, and a policy grants at its scope and below. Structural.
Redaction at the read boundary There is deliberately no per-caller content-projection seam in the mesh: a single-node read is a SHARED subscription — the owning hub broadcasts ONE node value to every subscriber on a silo, so it cannot project per reader (see UserPiiRedaction's class doc, which says exactly this and is applied at exactly one call site). Redacting only the identity-carrying surfaces would leave QueryNodes, CatalogDownload, GetVersion, Export and the whole sync path untouched — and a partial redaction is worse than none, because it reads as safety. Nor can it be done in the serializer: hub.JsonSerializerOptions is the same instance the storage adapters persist with, so a converter that dropped apiKey would blank it in Postgres.
Ciphertext at rest ✅ Holds on every surface at once — MCP get, the REST read, the sync stream, export, catalog download, version history — without enumerating any of them, because the master key is deployment configuration and never node data.

The rule, stated once: a secret's administered home may be a node; its VALUE may never be what a read returns. On this platform that is enforced by encryption, not by an access rule and not by a projection.

[Browsable(false)] on ModelProviderConfiguration.ApiKey is not part of this. It suppresses the GUI property grid and nothing else — its own doc comment says the value still serializes for the factory and for persistence. A GUI that stops rendering a field is not a read boundary.

The invariant, and how it is kept

ModelProvider.apiKey is enc:-tagged at rest. Always, on every path.

Two halves, and refusing to write plaintext only ever covered the first:

  1. Nothing new writes plaintext. Protect THROWS with no master key rather than passing the value through — the passthrough is what put a live OpenRouter key in cleartext into production on 2026-08-24. NoPlaintextCredentialFallbackGuard fails the build on the protector?.Protect(k) ?? k shape.
  2. What is ALREADY stored converges. Refusing future writes says nothing about the past, and the platform wrote plenty before the refusal existed: on the public instance, Provider/Azure and Provider/AzureFoundry have carried the same key in the clear since 2026-08-03/04. So ProviderCredentialSeed's second pass encrypts a stored plaintext key in place — the same credential, a different storage form, so Unprotect returns exactly what it returned before and the resolver's next read serves the same key.

That second pass walks every provider node in the catalog, whether or not a catalog source still names it. The qualifier is load-bearing: the duplicate Provider/Azure node is precisely one a source-driven pass walks past, and duplicates are why a rotation's blast radius is not fixed but grows with the duplication.

Its boundary is explicit. With no master key the pass can neither repair the node nor pretend it is fine, so it logs at Error naming the exposure and leaves the credential working — a repair that took chat down to fix a storage form would be worse than the exposure, and the actual answer to an exposure is a rotation.

🚨 A key that has been exposed is rotated — encrypting it is not the remedy

Re-protecting repairs the storage defect. It does not undo the window in which the value was readable. Rotate any provider key that has been stored in the clear on a publicly-readable partition, on the exposure window alone — evidence of use is not the test. The re-protect pass logs at Warning saying exactly this, per node, so an operator finds it rather than having to know.

Thinking level — a per-model datum, sent on every call

A reasoning model's provider decides how long every round thinks before its first token, and that default is the provider's, not ours. z-ai/glm-5.3 on OpenRouter ships with reasoning always on at max; an Executive Assistant round of nine cheap tool calls took 3 min 11 s on it, 74 s of thinking before the first tool call, four times over — and the platform had no knob at all. The composer's "effort" reaches the CLI harnesses only; nothing sent a reasoning effort to a provider.

The knob is reasoningEffort on the LanguageModel node — none, low, medium, high or max (aliases minimal, xhigh). The round reads it through ChatClientCredentialResolver.ReasoningEffortFor and sets ChatOptions.Reasoning once, in the one place the round builds its options, so every provider call of the round — including the tool-loop iterations the function invoker makes — carries it; the OpenAI adapter puts it on the wire as reasoning_effort, which OpenRouter forwards to the model. Absent, nothing is sent and the provider's default stays in force, which is exactly the pre-field behaviour. An unknown spelling parses to "send nothing" and never throws, for the reason tier gives: a typo on a node costs the setting, never the model.

Tuning a model down is therefore a mesh edit, not a deploy:

patch @Provider/OpenRouter/z-ai/glm-5.3  {"content":{"reasoningEffort":"low"}}

Provider support varies: a model without a reasoning phase ignores the field; one whose reasoning cannot be disabled treats none as its lowest level. Pinned by ReasoningEffortRoundTest (node → request) and ReasoningEffortWireTest (request → wire).

Output cap — always declared, sized by the task, bounded by the model

OpenRouter reserves a call's requested max_tokens against the key's remaining daily budget before the call starts, and a call that sends no max_tokens is reserved at the model's whole output maximum (131072 for GLM-5.3). Rounds that sent nothing were refused with HTTP 402 — You requested up to 131072 tokens, but can only afford 12427 — while most of the day's budget was left, and on 2026-10-12 every agent round on two instances failed that way (#3498).

So every round sends a DECLARED cap (OutputCap), set once with the round's options, so it holds for every provider call of the round, tool-loop iterations included:

  1. The task's cap: the round's own (ThreadMessage.MaxOutputTokens, e.g. the review ensemble), else the answering agent's (maxOutputTokens front matter), else the agent tier's maxOutputTokens on its ModelTier node. An agent with no tier is a chat round. A tier node that leaves the field blank inherits the shipped value for its id: utility 4096, chat 16384, reasoning 32768, review 65536, coding 32768.
  2. Bounded by the model: a LanguageModel node's maxOutputTokens is the model's output limit, and no rung exceeds it.

The model's context window is never used to size a cap. Pinned by OutputCapTest and ReasoningEffortRoundTest.DeclaredOutputCap_* (node → request).

A credit refusal that names what the key can still afford is re-issued once. ProviderCallRetryChatClient reads the can only afford N figure off a 402 that arrives before any output, and when N is below what was asked it issues the call again with the cap bounded to N. There is no back-off and the transient-retry budget is untouched, but the call must still start inside the retry window. A second 402, or one without the figure, ends the round as quota-exhausted. Pinned by ProviderBudgetBoundedRetryTest.

Context window — the request is sized in tokens against the model's declared window

A round on memex sent a request of about 1.3M tokens to a model whose context window is 1,048,576, and the provider refused it after the round had paid for the attempt (#3517). Two holes let it through: the history cap counted CHARACTERS of message text only — a tool call's arguments and a tool result are not text — and it ran once per turn, while the tool loop (FunctionInvokingChatClient) appends a call and a result per iteration inside the turn.

Pinned by ContextWindowBudgetTest — composed with the real FunctionInvokingChatClient, its negative control is the same loop without the guard sending a request past the window — and TruncateToContextBudgetTest.

Customer-governed instances: the record is a seed and a ceiling

Everything above is how an instance reads its record by default: the record's {Section}:Models list IS the child set of Provider/{name}. On a customer-governed instance, policy model-settings-seed-and-ceiling (MeshWeaver.Plugins#2604) reads model settings the other way: the record SEEDS the instance's model settings and CAPS them. The instance's own administrator may narrow within the ceiling and never exceed it, and the record wins whenever the two meet, as it does for the update policy (self-update-record-authoritative).

How an instance knows. Core renders AI__ModelGovernance=SeedAndCeiling for a record that sets operator.customerGoverned: true and names at least one model in its ai block (DeploymentPortalConfig.IsModelCeiling). Every other record renders no key, and the instance reads it as Record, which changes nothing. The key is reserved for the typed fields, so an extraPortalConfig entry can neither impose a ceiling nor lift one. The vocabulary is ModelGovernance (open string constants).

The ceiling (ModelCeiling) is what the record renders, read from the same effective catalog sources the catalog seeds from: for Provider/{p}, the {Section}:Models list of the source that seeds {p}. A source whose section is named differently from its provider (CustomOpenAI seeding Provider/OpenAI), or is nested (AI:Providers:X), caps exactly the provider it seeds. The seed and the ceiling are therefore one declaration and cannot disagree. A provider the record names no models for has an empty ceiling. requiredDataResidency stays a separate, fail-closed ceiling beside this one.

A model is judged by what it runs, never by its address alone: its path, its wire id (ModelDefinition.Id, what is sent to the provider) and the provider its credential binds through (ProviderRef, else Provider/{Provider}). A node at an admitted address whose content names another id, or binds a provider the record names nothing for, is refused. Two kinds of model pass:

What the instance may change, all of it data on nodes the instance owns, so a re-import never overwrites it:

Narrowing Where it lives Why there
Switch a named model off (and on again) Provider/{p} → disabledModels the provider node is created once and then owned by the instance (ExcludeThisOnly); the LanguageModel children are re-imported from the record and an edit on them would not survive
Choose a named model for a tier Provider/Tier/{id} → model (a path or an id) tier nodes are create-if-absent and owned by the instance

Enforcement, on write and on read.

Tier resolution gains one rung ahead of the others (ModelTierCatalog.ResolveWithInstanceChoice): the instance's choice, then the label on a model node, then the record's seed (ModelTier:*), then the deployment default. Every rung sees the same candidates, those the ceiling and the switch-offs admit. A choice the record no longer covers is therefore clamped: the next read skips it and the record's seed answers. Nothing is rewritten. If the record names the model again, the choice is honoured again.

When the record changes, the next read rebuilds the ceiling from live configuration. A model the record drops is pruned from the catalog by the import and refused at once by every read. A model it adds is seeded and offered until the administrator switches it off.

Pinned by ModelSeedAndCeilingTest. Each test has a negative control, the same instance read as Record. The tests cover the pure rule with named reasons in both languages, narrowing, the tier choice and its clamp, seeding only the record's models, the person's refused write, the resolver through seed, narrow and clamp, and a round on a model outside the ceiling that never reaches it. They also cover the ceiling keyed by the provider a source seeds (distinct and nested section names), a node whose content sends an id the record does not name (a real round that never reaches it), an ordinary model under Provider/Auto, and a personal model on the instance's credential. The fleet app /Hosting/Models says on such a record's page that it edits the ceiling, and links the instance's own providers.

What is still open

This page describes the seam. Three pieces of the administered surface are not built yet, and they are independent of everything above:

Three known gaps in the invariant itself, each narrower than the above and stated here so they are not rediscovered: