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: the settings UI.
AI Model Provider Settings
The Settings → Models page is the user's single destination for wiring AI into Memex — adding API keys, enabling specific models, and connecting CLI-based providers like Claude Code and GitHub Copilot. This document is the actionable implementation spec: it identifies the exact files to touch, the behavioral seams to introduce, and the testing approach.
Setting up models (admin or user)? This page is the UI design spec. For the operational how-to — provider/model mesh nodes, the system/space/user layers, which query goes where in a user's namespace, the open-weight tier choices, and the install-time config gaps — read Setting Up Model Providers.
Two provider kinds, two different UIs
The fundamental insight driving this design: API providers and CLI providers need completely different layouts. Rendering both as a key/endpoint form is wrong.
Two provider kinds — API providers add a key and pick models; CLI providers delegate to their own auth flow.
| Provider kind | Examples | What the card shows |
|---|---|---|
| API (bring-your-own-key) | Azure AI Foundry, Azure OpenAI, Anthropic, OpenAI | Endpoint / key form + a fetched list of models to enable |
| CLI (co-hosted, subscription) | Claude Code, GitHub Copilot | Login status — no key form, no model list; a button that delegates to the CLI's own auth flow |
What is wired
This design has shipped — the sections below describe the code as it stands, not pending work.
- The catalog chain registers all providers in
MemexConfigurationvia.AddAnthropic().AddAzureFoundry().AddAzureOpenAI().AddOpenAI().AddClaudeCode().AddCopilot(), each gated by itsFeatures:Ai:Providers:*/Features:Ai:Clis:*flag. memex/Memex.Portal.Shared/Settings/ModelsSettingsTab.csbranches onProviderKind:BuildCliCardforClisources, the BYO-key form forApisources. CLI providers are no longer filtered out.- Per-user credentials live in
ModelProvidermesh nodes (ModelProviderService,ModelProviderNodeType), with keys encrypted viaAi:KeyProtection:MasterKey(sourced from Key Vault). - The CLI connect flow is implemented:
ConnectSessionManagerdrivesClaudeConnectStrategy/CopilotConnectStrategy(src/MeshWeaver.AI/Connect/).
Design
1 The ProviderKind seam
An explicit ProviderKind enum (Api | Cli, declared in BuiltInLanguageModelProvider.cs) sits on the provider catalog entry, replacing the implicit "has a key form" test. CLI providers (AddClaudeCode, AddCopilot) report Cli; everything else reports Api. ModelsSettingsTab switches the rendered card on ProviderKind — this single branch drives the entire different-layout requirement.
2 API providers — key/endpoint form + model list
- Render the endpoint/key form. Azure providers also take an endpoint; Anthropic and OpenAI take only a key.
- With a key entered, Fetch models calls
ProviderModelLister.ListModels(endpoint, apiKey, providerName)— anIObservable<IReadOnlyList<string>>— and shows the returned ids as checkable rows so the user enables the ones they want. The selection is persisted on theModelProvidernode by Save provider. - API providers are the only kind that produce a model list — CLI providers deliberately do not.
3 CLI providers — login status + delegate to the CLI
No key form. No model list. The card shows exactly two states:
- Logged in → green "Connected as …" indicator + a "Re-connect / Log out" affordance.
- Not logged in → a "Connect / Log in" button that drives the CLI's native auth flow.
The backend lives in src/MeshWeaver.AI/Connect/:
IConnectStrategy — one implementation per CLI.
ClaudeConnectStrategy: spawnsclaudeunder the user'sCLAUDE_CONFIG_DIR, probes the CLI (or its.credentials.json) for login status, and if absent runsclaude setup-token(or/login), scrapes the auth URL, surfaces it, and captures the pasted code/token.CopilotConnectStrategy: runs the Copilot SDK device-flow — surfaces the device URL + code, pollsGetAuthStatusAsync.- Both reuse the subprocess shape from
MeshPlugin/KernelExecutor(RedirectStandardInput). The process boundary runs on theProcessIIoPool—InvokeBlockingfor the spawn,Invokefor the stdin write — carrying only the async leaves, with the auth-URL/token scrape composed as an ordinary observable. NeverObservable.FromAsync, which is forbidden outsideIoPool.
ConnectSessionManager — a mesh-scoped singleton that holds the live Process between "show URL" and "paste code", keyed per user (instance ConcurrentDictionary, never static), with a 5-minute timeout that calls Kill(entireProcessTree:true).
Login-status check is the cheap, always-on part: each card calls strategy.IsLoggedIn(userConfigDir) on render. Only the not-logged-in branch shows the login button.
On token capture → ModelProviderService.CreateProvider(ownerPath, "ClaudeCode"|"Copilot", token) (already calls Protect() on the key); re-connect uses RotateKey. The CLI agent factory already injects this.
4 Fix the missing icon
The AI settings tab is registered without an Icon. Add one where the Settings tabs are declared (the AI/Models tab registration in the portal settings) — a FluentUI Sparkle or Bot icon, consistent with other tabs.
UI — inline login
The CLI login expands inside the provider card — no modal, no side panel. This is the lightest-weight option and keeps the user in context. The card is a small state machine.
Models tab layout
Settings ▸ ✦ AI / Models
API providers — add a key, choose models
┌─ Azure AI Foundry ──────────────────────────────── [API] ┐
│ Endpoint https://….services.ai.azure.com [Save] ✓ │
│ API key •••••••••••••••• │
│ Models ☑ gpt-4o ☑ o3-mini ☐ embed-v-4-0 │
└────────────────────────────────────────────────────────────┘
┌─ Anthropic ─────────────────────────────────────── [API] ┐
│ API key •••••••• [Save] Models ☑ claude-opus-4 │
└────────────────────────────────────────────────────────────┘
CLI providers — log in with your subscription (no key, no model list)
┌─ Claude Code ───────────────────────────────────── [CLI] ┐
│ ● Not connected — uses your Claude subscription │
│ [ Connect Claude Code ] │
└────────────────────────────────────────────────────────────┘
┌─ GitHub Copilot ────────────────────────────────── [CLI] ┐
│ ✓ Connected as @rbuergi [ Disconnect ] │
└────────────────────────────────────────────────────────────┘
CLI card — connecting states
Claude Code (paste-a-code flow):
┌─ Claude Code ──────────────── [CLI] ┐
│ ● Connecting… │
│ 1 Authorize in your browser: │
│ claude.ai/oauth/auth?… [Copy][Open]
│ 2 Paste the code Claude shows: │
│ [ __________________ ] [Submit] │
│ ⏳ waiting for code… (4:58) │
└──────────────────────────────────────┘
GitHub Copilot (device code, auto-poll — nothing to paste):
┌─ GitHub Copilot ───────────── [CLI] ┐
│ ● Connecting… enter code at │
│ github.com/login/device │
│ ┌───────────────┐ │
│ │ AB12-CD34 │ [Copy] │
│ └───────────────┘ │
│ ⏳ auto-checking… │
└──────────────────────────────────────┘
Connected: ✓ Connected as <name> [ Disconnect ] · Error/Expired: red status line + [ Retry ]
Inline state machine
NotConnected ──[Connect]──▶ Connecting ──(code submitted / device poll OK)──▶ Connected
▲ │ ▲ │
└──────[Disconnect]────────┘ └──(5-min timeout · Cancel · auth error)──▶ Error/Expired
└──────────────[Retry]──────────────────┘
- Connecting is driven by
ConnectSessionManager+ the provider'sIConnectStrategy. It holds the live CLIProcess, exposes the auth URL (and, for Copilot, the device code), and either accepts a pasted code (RequiresPastedCode = true) or polls auth status. A 5-minute timeout disposes the process viaKill(entireProcessTree:true). - On success the strategy returns the token →
ModelProviderService.CreateProvider/RotateKey(encrypted with the Key-Vault master key) → the card flips to Connected reactively. - The card body is chosen off
strategy.RequiresPastedCode(paste field vs. device code) andIsLoggedIn(userConfigDir)(Connected vs. NotConnected on first render).
Where the code lives
| File | Role |
|---|---|
memex/Memex.Portal.Shared/Settings/ModelsSettingsTab.cs |
Switches on ProviderKind; renders the API card (form + model list) or BuildCliCard (login status + connect button) |
memex/Memex.Portal.Shared/Models/ProviderModelLister.cs |
ListModels(endpoint, apiKey, providerName) behind the Fetch models button |
src/MeshWeaver.AI.ClaudeCode/ClaudeCodeExtensions.cs |
Exposes ProviderKind = Cli + its IConnectStrategy |
src/MeshWeaver.AI.Copilot/* |
Same — ProviderKind = Cli + CopilotConnectStrategy |
src/MeshWeaver.AI/Connect/ |
ConnectSessionManager, IConnectStrategy, ClaudeConnectStrategy, IConnectTokenSink |
src/MeshWeaver.AI/BuiltInLanguageModelProvider.cs |
The ProviderKind enum on the catalog entry |
memex/Memex.Portal.Shared/MemexConfiguration.cs |
Registers ConnectSessionManager + the strategies |
Testing
No mocks. Use
MonolithMeshTestBase/AITestBase.
Three test scenarios:
Rendering —
ModelsSettingsTabrenders a model list for an API provider and a connect button (no list) for a CLI provider. Assert on the control tree.Connect flow — a committed fake CLI (prints an auth URL, reads stdin, prints a token) drives
IConnectStrategy:IsLoggedInreturns false → connect → strategy captures the token → aModelProvidernode is written with anenc:-tagged key that round-trips throughChatClientCredentialResolver.Login status — with the fake CLI reporting "logged in", the card renders the connected state and shows no login button. Real-CLI end-to-end is gated by
CLAUDE_CONNECT_E2E=1(developer-run only).
Scope note
What shipped is Phase 1: per-user CLI Connect plus the Models-tab rework — the UI and the CLI login backend. The ProviderKind layout split was the quick visible win; the CLI login backend (ConnectSessionManager + strategies) was the substantive part.
Model picker: provider-first selection and empty state
Providers and models are mesh nodes discovered via a nodeType: fan-out query — not a flat config list. The picker lists providers first; selecting one loads only that provider's models. When nothing is configured it routes the user directly to Settings.
Providers and models are nodes
- A provider is a
ModelProvidernode; its models are childLanguageModelnodes nested beneath it. - Canonical path: the platform catalog at
Provider/{Provider}/{modelId}(e.g.Provider/AzureFoundry/gpt-5), a space provider at{space}/Provider/{Provider}/{modelId}(e.g.Systemorph/Provider/AzureFoundry/gpt-5), and a user's own at{user}/_Memex/{Provider}/{modelId}(e.g.rbuergi/_Memex/ClaudeCode). - The built-in system catalog at
Provider/{provider}/{model}is a DB-synced NodeType catalog:BuiltInLanguageModelProvideris the sync source,ModelStaticRepoSourceimports it into theProviderpartition on boot, and the DB serves it thereafter, so configuration values (AzureFoundry:Models) materialise as real, queryableMeshNodes — there is no parallel, non-mesh path.
Provider-first, lazy model load
The picker does not eager-load every model from every space. It operates in two steps:
List providers — fan-out
nodeType:ModelProvider scope:descendantsover theProvidercatalog and every space the user can read. Listing providers (not models) is cheap, making it safe to broaden across spaces without loading the full model universe.Select a provider → load its models — the provider's path is appended to
{user}/_Memex/Selection.SelectedProviderPaths; the selected-path querynamespace:{providerPath} nodeType:LanguageModel scope:selfAndDescendants(AgentPickerProjection) loads just that provider's models.
Selection is the per-user selection store at {user}/_Memex/Selection. It is seeded empty at onboarding, so the RoutingGrain NotFound: {user}/_Memex/Selection read no longer occurs against a missing node.
Empty state → Settings
When the provider fan-out returns nothing (no provider configured), the model picker does not render an empty dropdown. Instead it shows an actionable empty state: "No model provider configured" with a link that navigates to Settings → Models (action://settings/models / the OnActionLink hook) where the user can add an API key, connect a CLI, or select an org provider.
Org-default provider
An admin may pre-create an org provider node — <org>/Provider/AzureFoundry with model children sourced from your Azure AI Foundry resource (endpoint https://<foundry-account>.services.ai.azure.com/models, key in Key Vault) — that every user with read access can select. This complements per-user BYO-key and Connect flows; it does not replace the empty-state link. ModelProvider is a creatable node type (search-hidden), so it can be authored in the UI by anyone with Permission.Api, not only through configuration.
Managing a provider's models
Selecting a provider in Settings → Models lists its child LanguageModel nodes, where the user can add, remove, or enable individual models (CRUD on {provider}/{modelId} nodes).
- Fetch from provider — where the provider exposes a list API (Azure Foundry / Azure OpenAI deployment list, OpenAI / Anthropic list-models), a "Fetch models" action queries it and offers the returned IDs for import as child
LanguageModelnodes, so the catalog does not need to be hand-typed. - Refresh — re-runs the fetch and reconciles against the current children (adds newly-deployed models, flags dropped ones). Manual button now; can become periodic later.