The model-provider docs at a glance: Model Providers — the architectural pattern · Provider Configuration — framework config & chat-client factories · Model Provider Setup — operational setup & troubleshooting · Model Provider Settings — the settings UI. This page: framework config & chat-client factories.
MeshWeaver speaks to multiple LLM providers — Claude via Anthropic, GPT-class and open-weight models via the Azure AI Services multi-model gateway, embedding models, and more — but the configuration surface is intentionally small: one shared key, a handful of endpoints, and a short per-provider model list. This page explains the credential/endpoint wiring and the factory routing. For getting models to actually appear in the picker — provider/model mesh nodes, the space/user layers, and the install-time gotchas — read the operational guide first: Setting Up Model Providers.
Why read this? This page is about credentials, endpoints, and which factory handles a model. If your question is "why is the model picker empty / how do I add models," start with Setting Up Model Providers — the picker is fed by
ModelProvider/LanguageModelmesh nodes, which this deployment seeds from the config sections below.
One shared Azure Foundry key backs multiple providers; the agent definition selects the model; AgentChatClient routes to the matching factory via Supports() predicates.
One Azure Foundry Key, Two Providers
A single Aspire parameter — azure-foundry-key, declared in ../MeshWeaver.Plugins/src/Memex.AppHost/Program.cs:51 — backs both the Anthropic and AzureAIS credentials:
| Env var | Provider | Endpoint path |
|---|---|---|
Anthropic__ApiKey |
Claude (Anthropic) | /anthropic/... |
AzureFoundry__ApiKey |
Multi-model gateway (open-weight: DeepSeek, Llama, Mistral, Phi…) | /models/... |
Both routes share one credential under the Azure Foundry resource. You do not need a separate Anthropic key in this deployment. A dedicated Anthropic key only makes sense if you route directly to api.anthropic.com rather than through Foundry.
Endpoints Are Always Parametrised
Endpoints are never literal strings in source code. In development they come from dotnet user-secrets on the AppHost; in production they are injected as GitHub Actions secrets and Azure Container Apps environment variables.
| Aspire parameter | Environment variable |
|---|---|
anthropic-endpoint |
Anthropic__Endpoint |
azure-foundry-endpoint |
AzureFoundry__Endpoint |
embedding-endpoint |
Embedding__Endpoint |
embedding-model |
Embedding__Model |
Section-name caveat (latent bug): the code binds the
AzureFoundry:section (AzureFoundryConfiguration,AddAzureFoundry, and the catalog source). The Aspire AppHost currently emitsAzureAIS__Endpoint/AzureAIS__ApiKey, which nothing insrc/binds — so that config is dead. UseAzureFoundry__*. The Helm chart (deploy/helm) already uses the correct names; the AppHost (../MeshWeaver.Plugins/src/Memex.AppHost/Program.cs) should be renamedAzureAIS__* → AzureFoundry__*.
The embedding pair establishes the canonical pattern — a sibling endpoint + model parameter per provider. Chat providers follow the same shape.
Model Selection — Composer First, Tier as Optional Fallback
Two things select a model, at two different layers. A deployment advertises which models exist by listing them in each provider's {Section}:Models config, which BuiltInLanguageModelProvider turns into LanguageModel mesh nodes for the picker (see Setting Up Model Providers). What the AppHost does not do is hardcode model ids in framework C#.
Which model a conversation actually runs on resolves in this order (see ChatClientAgentFactory.ResolveTierModel and the concrete factories):
- The chat composer selection (
ThreadComposer.ModelName→CurrentModelName) — the user's explicit pick always wins. The one exception is Auto, the default selection for a new thread: Auto is a router, so it is dispatched rather than served (see below). - The agent's
AgentConfiguration.ModelTier— a USAGE tier (utility/chat/reasoning/coding), resolved against thetierlabel on the model NODES. This is also what Auto dispatches on. Optional: with no tier declared, or a tier no model carries, it falls through. See Model Tiers. - The deprecated
ModelTier:*config (ModelTier__Heavy/Standard/Light/Utility) — still read so an existing deployment keeps its mapping, and only ever consulted for a tier no model node carries. - The deployment default — the lowest-
ordermodel whose credentials resolve.
Every step after the first skips models with no usable credential, and skips the router. Resolution never fails: the only outcome with no model is an entirely-unusable catalog, which fails the round audibly.
When a Selected Model Is Unusable — the Fallback Is Honest, Never Silent
A pinned model can stop resolving (its provider node lost its key, the catalog was refactored,
the model was deleted). AgentChatClient.ApplyStaleModelFallback then swaps it for a working
model so the thread keeps running. Three rules make that swap honest:
- The substitute is health-checked. The fallback ranks the catalog through
ChatClientCredentialResolver.HasUsableCredential— a NON-EMPTY ApiKey, not merely a non-Missingresolution — the same predicateAgentPickerProjectionuses for the composer default. A keyless, endpoint-only entry is skipped even when it sorts first byOrder, so the round never lands on a model every factory refuses with "ApiKey is missing". - The round records what ACTUALLY answered.
ThreadMessage.ModelNamecarries the effective model on every terminal path (Completed, Cancelled and Error), and the per-modelTokenUsagesatellite is keyed by it — so cost is attributed to the model that ran. When the effective model differs from the pick,ThreadMessage.RequestedModelNamecarries the requested id alongside it. That pair is the substitution marker: an automation detects "I did not get the model I asked for" from the node, without reading the chat text. An operator additionally gets aMODEL_SUBSTITUTEDwarning naming both models. There is deliberately no user-facing chat notice — an unusable pin is a configuration problem the user cannot act on mid-round. - Nothing usable ⇒ the round FAILS. If the selection is unusable and the catalog offers no
usable replacement and no agent could be built, the round terminates with
ThreadMessageStatus.Errorand a localized message naming the situation (chat.noUsableModel, resolved off the round's ownAccessContext.Locale); the raw factory error stays in the log. It does not proceed to produce a raw provider error under aCompletedstatus, which any automation would read as success. (Exhausted-fallback alone is not fatal: a deployment whose keys live in factory config is invisible to the credential resolver yet builds agents and runs normally.)
The credential check cannot see quota — so the refusal has to read well
A usable credential means the deployment will answer, not that it will serve. A model with a perfectly good key can still refuse every round because it is out of quota (HTTP 429) or because the deployment itself is faulting (HTTP 5xx). No local check can predict that — only the provider's answer reveals it — so the requirement is not "never fall back onto a throttled model", it is fail legibly when the provider refuses.
ProviderFailureClassifier names the condition by walking the whole exception chain — the streaming
pipeline wraps provider faults, so the typed transport exception is rarely the outermost one — and it
has three probes, in order of authority: HttpRequestException.StatusCode (typed, and what the
plain-HTTP providers raise), then two different message banners.
🚨 There are two because the SDKs do not agree, and assuming one is what made every OpenAI/OpenRouter
refusal fall through unclassified for a while. Azure.Core writes Status: 429 (Too Many Requests);
System.ClientModel writes HTTP 402 (: ) — i.e. HTTP {status} ({reason}: {code}), which contains
no Status: at all. Both are matched on text rather than on those exception types, deliberately,
so this file needs no provider-SDK dependency and keeps working for any client rendering the same
conventional banner. Parsing is anchored on the digits immediately after the marker and bounded to
three of them, so a header dump's HTTP/1.1, a body that merely mentions a status, or a longer digit
run (a token count, an id) yields nothing rather than a bogus code.
ThreadExecution then builds the prose at write time off the round's own AccessContext.Locale:
- 402 →
chat.modelQuotaExhausted. Separate from 429 because waiting does not help: the account is out of credit, so the remedy is a top-up or a model on a provider that still has budget. - 404 →
chat.modelNotFound. A stale or mistyped model id in the deployment's configuration — an actionable configuration fault, so it must never read as "submit again later". - 429 →
chat.modelRateLimited, naming the model that actually served. - 5xx →
chat.modelProviderError, naming the model and the status. - A credential the provider REJECTS →
chat.modelCredentialRejected, naming the model and the status. This is a permanent verdict, not a transient one, which is what distinguishes it from every entry above: the key has to be replaced before any round on that model can succeed, so the prose says so and deliberately does not offer "submit again later". ⚠️ Not yet wired — unlike every other entry in this list, the platform string exists and the engine-side branch that selects it does not. Read the gap note below before relying on this row. - The stream ended abnormally — no HTTP status is involved, so these are named ahead of the
status switch, from the exception rather than from a code: the provider went silent mid-answer
(
chat.modelStreamStalled) or sent a payload the wire protocol cannot represent (chat.modelStreamFaulted, e.g. an OpenAI-compatible gateway ending the stream with afinish_reasonthe protocol does not define). Both carry aPROVIDER_STREAM_*warning so monitoring can separate a recurring upstream stall from a one-off fault. - The thread's own history could not be loaded →
chat.historyLoadFailed. Not a provider condition at all, but it shares the discipline and is the reason it is listed here: a history load that did not complete is reported, never substituted. Answering on an empty or holed history would be a silent wrong answer under aCompletedstatus, which is strictly worse than a failed round — so the fault rides through to the terminalErrorwrite. - Substituted rounds add one sentence (
chat.modelSubstitutionNote) naming the requested model and the one used instead. This is the only place the swap is spelled out to the user, and it earns its place: the failure names a model they never picked, which is otherwise inexplicable. - Anything unclassified keeps its own message verbatim — for a tool fault or a bug in our code that message is the diagnosis, and generic prose would erase it. 🚨 Read the boundary precisely: that rule is right for a fault carrying no recognisable transport status, and wrong for one that does. Once the classifier has established that a provider answered with an HTTP status, the SDK's raw banner is never the right text for a reader — so a status that no branch above claims is a gap in the branch table, not an invitation to fall through.
The raw transport text is never discarded, only relocated: it stays on the LogError(ex, …) that
precedes the terminal write, alongside a PROVIDER_REFUSED warning carrying the status, the serving
model and the requested one. What changed is what the user reads — previously ex.Message went
straight into the cell's Text and Summary, which for these failures is the status line plus the
response body plus the complete HTTP header block.
Where this code lives — and why the catalog key leads
🚨 The classifier and the switch that consumes it are NOT in this repository. The AI engine is a
module hosted in MeshWeaver.Plugins (MeshWeaver.AI, MeshWeaver.AI.Anthropic,
MeshWeaver.AI.OpenAI, MeshWeaver.AI.ClaudeCode); core holds only the localization catalog
these conditions render through, plus LocalizationCatalog itself
(src/MeshWeaver.Messaging.Hub/Localization/). A grep of core's src/ for AgentChatClient,
ProviderFailureClassifier or ThreadExecution finds comments and XML-doc cross-references and no
executable code at all, and core's solution declares no MeshWeaver.AI* project — so a search over
this repository alone can neither confirm nor refute anything about how a provider failure is
handled. Establish the subject's home before reading absence here as absence.
That split sets the order of the two halves, and it is inverted from the usual dependency rule:
the platform's catalog key lands FIRST and the engine-side branch follows. The engine tolerates the
gap in that direction by construction — it checks LocalizationCatalog.Keys.Contains(key) before
resolving and falls back to its own purpose-written English, because a module can ship a condition
before the image carrying the string does. Rendering a raw chat.… token to a user is the failure
that check exists to prevent. The reverse order has no such tolerance: a branch selecting a key the
loaded platform does not define is what that guard is defending against.
The gap this table currently has: a rejected credential
🕐 This subsection is provisional and names its own expiry. It describes a half-landed pair — the platform string exists here, the engine-side branch does not yet. Delete it the moment
ProviderFailureClassifiergains a 401 predicate and the switch gains its arm, and foldchat.modelCredentialRejectedinto the list above as an ordinary entry. Left standing past that point it becomes an actively false claim about a repository this page cannot see, which is the failure mode the subsection above warns about — so do not re-measure it here, measure it there.
A provider that rejects the credential answers 401 (Anthropic renders it PermissionDenied).
Measured against MeshWeaver.Plugins@main, nothing claims it: ProviderFailureClassifier has
predicates for 402, 404, 429 and 5xx and none for 401; the providerStatus switch in
ThreadExecution has cases for exactly those four ranges and a _ => null default; the Anthropic
client maps 401 to no typed condition; and the only mention of 401 anywhere in ThreadExecution is
a comment about the CLI harness path. So the round falls to the unclassified default and pastes the
SDK's own sentence — Response status code does not indicate success: 401 (PermissionDenied). —
into the user's cell: raw, English-only whatever the viewer's locale, and naming no remedy. That is
precisely the defect the rest of this section exists to prevent, still live for the one condition an
operator is most able to fix.
Two things follow, and they are easy to conflate:
- The retry half is a different question from the legibility half, and only one of them is a
defect.
AnthropicChatClient.SendWithRetryAsyncretries500,502,503and429only, so a 401 is already treated as terminal and goes straight toEnsureSuccessStatusCode()— correctly, because a rejected credential is a permanent verdict and retrying it could never do anything but waste the round. A report that a 401 is retried does not survive reading that set. What is wrong is what the user is shown afterwards. - Adding a retry would be the wrong fix twice over — it is a band-aid, and it contradicts the
verdict the status carries. The fix is classification: name the condition, render
chat.modelCredentialRejected, keep the provider's own text on theLogErrorwhere an operator reads it.
What this note does not establish. Only 401 is measured, from production occurrences. Whether
403 and other 4xx statuses should also be named — and under which prose — is deliberately left
open: 403 means different things at different providers (permission, region, content policy), and a
confidently wrong name is worse for a reader than a generic one. chat.modelProviderError already
interpolates its status and is the obvious home for a widened default, but its current wording ends
in "Submit again later", which is true of a 5xx and false of most 4xx — so widening the branch is a
wording decision, not a mechanical one, and it is not made here.
How the Model Picker Is Populated
The picker is node-based, not factory-based. AgentPickerProjection runs nodeType:LanguageModel|ModelProvider queries over the platform Provider catalog, the context's {path}/Provider subtrees, and the user's own {user}/_Memex namespace, and shows the resulting LanguageModel nodes, grouped by provider. Those nodes come from two places: the system catalog BuiltInLanguageModelProvider materialises from each {Section}:Models config list (imported into the Provider partition on boot and served from the DB), and space/user ModelProvider nodes authored in the mesh.
So an empty picker means no provider/model nodes are visible to the user — almost always because the deployment carries no {Section}:Models config signal (the classic Helm/AKS gap) or the user's {user}/_Memex/Selection points at a provider that doesn't exist. The full diagnosis + fix is in Setting Up Model Providers → Troubleshooting.
Don't try to mirror "everything the provider sells." List a short, curated set in
{Section}:Models(the deployment's catalog) — the user picks from it in the composer.
Model-to-Factory Routing
When an agent needs a chat client for a given model name, AgentChatClient.GetFactoryForModel iterates the registered IChatClientFactory implementations in Order (lower first) and calls Supports(string) on each. Routing works without any populated Models[] array because the concrete factories implement shape-aware predicates:
| Factory | Supports predicate |
|---|---|
AzureClaudeChatClientAgentFactory |
name.StartsWith("claude", IgnoreCase) |
AzureFoundryChatClientAgentFactory |
catch-all for non-claude names (gpt-*, o*, Mistral-*, DeepSeek-*, …) |
The default IChatClientFactory.Supports falls back to the legacy Models[] lookup, so factories that don't override still work through explicit Models config — useful for tests or for serving a curated subset.
Adding a New Provider
To wire in a new provider (a second Azure OpenAI deployment, a hosted local model, etc.):
- Implement
IChatClientFactoryand register it via DI (services.AddAzureOpenAI(...)or similar). - Bind its options from a new section in
MemexConfiguration.cs— endpoint and auth fields only, not model names. - Add Aspire parameters in
../MeshWeaver.Plugins/src/Memex.AppHost/Program.csfor the endpoint (and a key, if it doesn't shareazure-foundry-key). - Label the new model's node with a
tier(ModelDefinition.Tier), so agents reach it by declaringmodelTierin their front matter. An agent names a tier, never a model id — there is no per-agent "preferred model" field.
Do not hardcode model identifiers in framework code. If you find yourself writing
"gpt-4o"or"claude-sonnet-4-5"in a.csfile outside an agent definition, that is precisely the pattern this page exists to prevent.
Related
- Agentic AI — what agents are and how they're composed
- MCP Authentication — how external clients authenticate to MeshWeaver