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:

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:

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.ContainsModelCreditMessages.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.

Reconnecting…
The server was updated. Reloading the page to pick up the latest version.