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 / LanguageModel mesh nodes, which this deployment seeds from the config sections below.

Azure Foundry azure-foundry-key + endpoint params Anthropic__ApiKey Claude provider AzureAIS__ApiKey Multi-model gateway Embedding__* endpoint + model Agent Definition ModelTier AgentChatClient GetFactoryForModel AzureClaude ChatClientFactory claude-* → Anthropic AzureFoundry ChatClientFactory gpt-*, o*, Mistral-* Custom Factory Supports() predicate Credentials & Endpoints Agent Config Factory Routing

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 emits AzureAIS__Endpoint / AzureAIS__ApiKey, which nothing in src/ binds — so that config is dead. Use AzureFoundry__*. The Helm chart (deploy/helm) already uses the correct names; the AppHost (../MeshWeaver.Plugins/src/Memex.AppHost/Program.cs) should be renamed AzureAIS__* → 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):

  1. 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).
  2. The agent's AgentConfiguration.ModelTier — a USAGE tier (utility / chat / reasoning / coding), resolved against the tier label 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.
  3. 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.
  4. The deployment default — the lowest-order model 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 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:

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 ProviderFailureClassifier gains a 401 predicate and the switch gains its arm, and fold chat.modelCredentialRejected into 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:

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.):

  1. Implement IChatClientFactory and register it via DI (services.AddAzureOpenAI(...) or similar).
  2. Bind its options from a new section in MemexConfiguration.cs — endpoint and auth fields only, not model names.
  3. Add Aspire parameters in ../MeshWeaver.Plugins/src/Memex.AppHost/Program.cs for the endpoint (and a key, if it doesn't share azure-foundry-key).
  4. Label the new model's node with a tier (ModelDefinition.Tier), so agents reach it by declaring modelTier in 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 .cs file outside an agent definition, that is precisely the pattern this page exists to prevent.