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 from the exception chain (typed
HttpRequestException.StatusCode, else the conventional Status: NNN banner that Azure.Core and
System.ClientModel both render), and ThreadExecution builds the prose at write time off the round's
own AccessContext.Locale:
- 429 →
chat.modelRateLimited, naming the model that actually served. - 5xx →
chat.modelProviderError, naming the model and the status. - 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.
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.
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