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.

Personal CLI harnesses live in the shared Threads app

The shared Threads app, shipped by the AI engine, is also the catalogue for packages that add a harness to a user's thread picker — Claude Code, Codex, Copilot, and similar CLIs. It embeds the Store's own Get action for each package. Getting a harness copies only its Harness/* node into that user's picker; it never installs a personal account integration for everyone on the mesh.

The catalogue owns that common installation step. Each package owns its next step: its page shows the provider-specific connection or login flow, and its credential remains under that user's account. Do not add a second installer or a generic login flow to the Threads app; add the package to the personal-harness catalogue and let its existing Store CTA and connection surface do their respective jobs.

Provider tiles and the AI menu's Threads entry point to this same app. Credential administration remains at AI → Providers; it is not a prerequisite for installing a personal harness.

Verification and development findings

On 2026-09-22 the Claude Code Store action reported installation on the public instance, while the harness picker still offered only MeshWeaver. These are separate boundaries: the personal node must exist, the picker must query the personal/space/global registry, the harness assembly must be loaded, and the hosted CLI must complete its own authentication. Neither a Store success message nor a browser already signed into Claude proves hosted CLI login.

The provider tiles previously pointed at Providers/ProvidersApp/area/ProviderSetup; that route failed with Area not found on the target deployment. The shared user app now lives in the AI assembly and uses framework search and node-card controls, the existing personal composer, and each package's own CoverCta. It carries no credential form, install-state replica, or save loop.

For acceptance, open the shared app as the intended user, Get Claude Code, confirm it appears in the picker, select it, run /login, finish browser authorization in the separate provider tab, and send a harmless round through Claude. Record the deployed module version and successful round; do not label an unexercised flow verified. Signing into a local desktop CLI does not test this path.

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:

Boot credential persistence stays off the router

ProviderCredentialSeed runs with the root mesh hub during startup. Its two writes—filling an empty provider credential and encrypting an existing plaintext credential—update the canonical node stream, then force persistence through NodeOperationIssuingHub(), targeting the provider's own address. Posting the save directly from the root produces ROUTER_TRAFFIC sender reports even when the credential reaches storage. The node-operation hub keeps this lifecycle write off the router without changing the target, encryption, or write identity.

ProviderCredentialSeedRouterTest exercises both branches on a real, isolated mesh, verifies the encrypted value decrypts to the original test credential, and captures the synchronous router origin guard. A deliberate root save is the positive control for the capture. Against the old implementation both branch tests fail; the capture control passes. With the issuing hub corrected, all 27 provider seed tests pass with no skips (3.9 seconds), and the AI test closure builds in Release with warnings treated as errors (zero warnings and errors).

This is native AI engine code. A NodeType recycle cannot replace it: delivery requires the normal AI module publication and a process that actually loads that publication. Production verification must identify the loaded AI assembly and exercise the changed save path before issue #5747 closes.

What a key the provider REJECTS looks like

set ✓ is the platform's verdict, not the provider's: a stored key can be revoked, expired or simply wrong, and the provider answers the round with HTTP 401 (Anthropic renders it 401 (PermissionDenied)). That is a different condition from "no usable key" — the local one chat.noUsableModel names when nothing RESOLVES — and the engine classifies it on its own (ProviderFailureClassifier.IsCredentialRejected, the 401 arm of ThreadExecution's status switch): the round ends in Status = Error with the platform's localized chat.modelCredentialRejected (policy thread-graceful-error) naming the model that served and saying the key must be replaced — deliberately not the 429 / 5xx "submit again later", which is false of a rejected credential. Nothing retries it: the transport treats 401 as terminal on the first attempt (AnthropicChatClient retries 429/5xx only), and a retry on a rejected secret would be a band-aid. The SDK's own sentence stays on the LogError; it never reaches the thread. 403 is deliberately NOT classified — it means permission, region or content policy depending on the provider, and a confidently wrong name is worse than the generic one. The full condition → key table is the platform's Doc/AI/ProviderConfiguration (Systemorph/MeshWeaver#4954).

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.

Where prompts are processed, and who made the model

The model grid also carries a Processing column (🇪🇺 EU-only, 🌍 global, ❔ unknown) and an Origin column (the maker's flag and name). Both read the model node's own dataResidency / modelOrigin, the same fields the picker badges and the EU-only refusal read. An unmarked model reads Unknown and never counts as EU. Where a model processes prompts, and who made it covers the marks, the fail-closed requirement and the fleet audit.

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.

A round decides its model only after the catalog has been read

A round that names no model (every headless round: a log-incident triage, a CI triage, the PR steward's reviewer) is dispatched on its agent's declared tier, with the deployment default as the fallback. That dispatch reads the mesh's shared ChatClientCredentialResolver. The resolver warms asynchronously once it is built. Until its catalog query first answers, its snapshot is an empty placeholder, and on that snapshot "no model is usable" and "the catalog has not been read yet" look exactly the same.

Measured (MeshWeaver#5883), memex.systemorph.com, 2026-09-29/30. There were 17 occurrences of [ThreadExec] NO_USABLE_MODEL … requested=(none selected) on 17 distinct pods: exactly one per replica. Each was the first headless round that replica ran after a roll, and each failed with "no model in the catalogue has usable credentials". Minutes later, a thread with the same tier and no model ran on z-ai/glm-5.3. The catalogue was full the whole time, so the fallback had not regressed. What went wrong is that the round decided on the placeholder.

The rule. The resolver exposes WhenCatalogRead. It emits once, and replays to late subscribers, when the snapshot holds a real reading. That reading can be empty: an empty first emission is an answer, not a wait. It also fires on an authoritative read-through, and on a fault before the first emission, so a waiter is never stranded. AgentChatClient.Initialize releases the agent list only after that signal, because building the agents is what decides the model. It is not a timer: a catalog that never answers is still reported by the round's own 60 s readiness bound in ThreadExecution. A resolver its owner holds cold never fires, as HasReadableCatalog documents. ModelDispatchWaitsForTheCatalogTest holds that window open and closes it deliberately.

This is not a fallback policy change. A round with no usable model on its tier still falls back to the deployment default, exactly as before. The data-residency filter still applies to every rung (ResolveForTierWithResidency). A round whose agent or instance requires a residency that no usable model satisfies still fails audibly, and is never moved to a model outside it.

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.