Setting a provider up
The transparent bill answers what did this cost. This page is the other half of the same app: where do the numbers in that bill come from, and who may change them.
Read it before touching ModelProviderLayoutAreas, ModelCatalogRows, or anything that renders a
provider node.
The surfaces, and which one owns what
There is no single "settings screen" for AI, and deliberately so — each thing is configured on the node that IS it, reached from the ✨ AI menu:
| Surface | Node type | What it configures |
|---|---|---|
/Provider/AiProviders → a provider node |
ModelProvider |
endpoint, API key, and the models it serves |
/Provider/AiModels → a model node |
LanguageModel |
id, prices, currency, tier, capability |
/Provider/AiModelTiers → a tier node |
ModelTier |
what each rung is for |
| Settings ▸ Administration ▸ AI Admin | — | the bill, the declared rates, and links to the three above |
Each catalog is a scope-tabbed MeshSearchControl with a + button (AiCatalogLayoutAreas), so
creating a provider or a model is the same gesture as creating anything else on the mesh.
A model is edited by the platform's own node editor. LanguageModelNodeType registers no layout
area of its own, so a model node falls through to core's generic Edit area, which binds the form
DIRECTLY to the node stream (GetMeshNodeDataContext(nodePath, bindContent: true)) and reflects the
fields off ModelDefinition's [Description] attributes. That is the framework's editor doing
exactly what it is for, and writing a second one here would be the hand-rolling the estate bans. The
provider page links to the model rather than re-implementing its form.
A provider is not, and that exception is the whole of the next section.
The credential: write-only, encrypted at rest
ModelProvider is the one node type in this family with a custom detail view, because the generic
editor rendered ModelProviderConfiguration.ApiKey in plaintext. ModelProviderLayoutAreas
replaces both the Overview and the /Edit route with a form that has:
- an endpoint field, bound to the node stream through
MeshNodeContentEditorControl— the only content field the page declares; - a key verdict —
set ✓ornot set, and nothing else. No prefix, no length, no masked echo, nothing a reader could reconstruct from; - an Enter Key dialog: a
Password = truefield,IProviderKeyProtector.Protectinside the write chain,stream.Updateunder the caller's own identity, then a force-persist. The old value is never read, and the plaintext never reaches a log — a failedProtectlogs the path and the exception, never the key that was in scope.
Why this shape and not one of the other two the estate uses
The estate has three credential shapes and they are not interchangeable:
| Shape | Where | Why not here |
|---|---|---|
Hash on the record (MeshWeaverInstance.keyHash; raw value returned once, never re-readable) |
the registry's instance keys | The platform only ever has to verify an instance key someone presents. A chat round has to replay a provider key to the provider's endpoint, and you cannot call OpenRouter with a hash. |
Key Vault via the CSI SecretProviderClass |
deployment secrets | Those are provisioned per ENVIRONMENT by an operator with cluster access. A provider key is entered by an admin at runtime, per provider node, on a portal they are already signed into — a flow that has no cluster in it. |
| Write-only, encrypted at rest | provider keys, GitHub PATs, the EA credential, the plugin catalog's signing key | ✅ the platform must be able to replay it, and a person must be able to set it from a page. |
What the third shape owes in exchange for holding a replayable secret is that the secret has exactly
one door: ShowKeyDialog in, and no way out. Everything else on the page — the title, the
verdict, the endpoint editor, the model catalog — reads the same content record, so "the projection
does not expose it" is not enough on its own; ModelProviderSetupRenderTest asserts the whole
rendered tree over a provider seeded with a real key.
🚨 ProviderKeyProtector.Protect refuses (throws) on a deployment with no master key. It used to
degrade to a plaintext passthrough, and that is how a Provider/Azure key came to sit in the clear
in a publicRead partition on 2026-08-25 — still an open rotation item. The refusal is raised inside
the write chain via Observable.Defer, so it arrives as an OnError on the subscription the write
already reports through: the key is not stored AND the reason is logged.
Reading a provider without widening access to one
Core's StorePlans refuses to read provider nodes even as System, with the comment "user-facing
surfaces never widen access to provider nodes — that partition held plaintext keys once
(2026-08-25)". Nothing on this page contradicts that:
- The models read goes through
hub.GetQuery, which caches per(id, userId)and applies per-result RLS at the source. A viewer without Read on the provider's partition gets an empty snapshot, not a leak. - Nothing here impersonates. No
RunAsSystem, noImpersonateAsHub. The bill's ledger read is System because theAdminpartition is unreadable by design and the access decision is made before it (ModelCreditBill.ResolveSubscriber); a provider's own partition needs no such exception, so it gets none. - The query is anchored on a single concrete
path:— the provider's own — narrowed bynodeType. It is a listing for display, which is a sanctioned query use: it decides nothing and gates nothing, so the "read a specific node from its stream" rule does not apply (Doc/Architecture/CqrsAndContentAccess).
Why the model catalog shows prices
The provider page used to list its models as bare ids — - \z-ai/glm-5.3`` — read off the
denormalized ModelProviderConfiguration.Models array. That was a list of names on the page whose
job is configuring what those names COST.
ModelDefinition.InputPricePerMillionTokens / OutputPricePerMillionTokens and Currency are what
the credit meter multiplies a round's tokens by. The number a subscriber is billed is decided by
those three fields, and an operator could not see them from the page that sets them up. So the
section is now a Controls.DataGrid over the provider's real LanguageModel descendants —
ModelCatalogRows.Rows, pure and therefore assertable without a mesh.
The currency rides in the CELL, never the column header, for the reason the bill's Amount
column gives: a provider may legitimately serve models priced in different currencies, and a header
naming one turns every other row into a lie. And money is a pre-formatted string in the viewer's
culture with an ISO CODE, never a decimal column with WithFormat("C2") — that resolves against
the CONTAINER's ambient culture, shared by every simultaneous viewer, and prints that culture's
currency SYMBOL, so a USD price would read 1,40 € to a German viewer.
The two setup mistakes the page marks
Both are one blank field away from a correct configuration, neither raises an error, and neither is visible from the bill — which is precisely why the configuration surface has to say them.
⚠️ Unpriced — the round is served and never billed
No input price, no output price, or only one of the two. The meter cannot turn tokens into money, so the round runs, costs the platform real money, and writes no charge row at all. The bill can never show this: an absent charge has no row to render.
"Priced" therefore means BOTH sides. A model priced on input and not on output is charged for the prompt and not the completion — an under-count that looks like a working configuration from every other angle.
¤ No currency — the bill names a currency the model never did
A priced model with a blank Currency is metered at whatever default the cost path picks. The bill
then states a currency the node never declared, and on memex a currency the plan cannot fold —
which is the whole of Systemorph/MeshWeaver#3235, where every period total reads undetermined.
The cell must not assert one either: it renders _, ModelCreditLedger.CurrencyKey's own "nothing
to key on", never a helpful USD.
Under the grid, each mark is repeated as a sentence naming the models as links (@/ Unified
Content References — a grid cell cannot be one), because a glyph tells an operator that something is
wrong and a link tells them where to go.
Why an unreadable model is kept
A model whose content cannot be deserialized is rendered with its path and an unreadable price,
not skipped. Same direction, and same reason, as ModelCreditBill.Rows: a catalog that silently
omits what it could not parse tells an operator their setup is smaller than it is — and the model it
hid is still serving rounds.
The reactive shape, and the seam it avoids
Overview composes the node stream with the models query using CombineLatest, where the models
leg starts as a null sentinel meaning "we have not looked yet" — so the endpoint and key
controls paint immediately and the catalog fills in when the query answers.
🚨 It is deliberately NOT a nested WithView(...) delegate. Overview re-runs on every node
emission, and a nested view would register one more live subscription per render under a key no area
teardown reaps — the accumulation core's MeshNodeLayoutAreas guards with ReplaceDisposable
(issue #606). One combined stream has no such seam.
A fault on the models leg renders "we could not look", never an empty catalog: an empty catalog is an ANSWER (this provider serves no models), and showing it for a failed read tells an operator their setup is empty when it may be anything at all.
Localization
Every string on the page is a catalog key. Until the platform image carries them, Text(...) falls
back to English through LocalizationCatalog.Keys.Contains — ModelCreditMessages.Render's guard,
for the same reason: the catalog is embedded in the PLATFORM image, this module ships ahead of it,
and an unguarded Localize would print ui.providerColInput at a user. The fallback formats its
arguments in the VIEWER's culture, so the untranslated window is not also a wrong-number window.
The setup marks are glyphs (⚠️, ¤) so the cells need no translation; the localized column
header carries the meaning.