# Model Registry — Provider-agnostic agent configuration

> **Scope:** stacks=["dotnet"]
> **Layer:** 0 (always-load via persona maf-expert)
> **Keywords:** model registry, provider agnostic, model alias, IChatClient factory, multi-model, model selection, model config, api per alias, responses vs chat, reasoning effort
> **Read by Claude in:** plan (cita este standard quando feature menciona >1 modelo ou multi-provider) + implement (copia o template ModelRegistry.cs e gera config no projeto)

**Verified against:** Microsoft.Agents.AI 1.20.0 + Microsoft.Extensions.AI 10.9.0 + OpenAI 2.13.0 (ai-pin 2026-09-08); provado por `templates/dotnet/ai-kit/src/Morph.AiKit/Providers/ModelRegistry.cs` e `.../Providers/ModelAlias.cs` e `.../Agents/AgentApi.cs`. Nomes de tipo do `Microsoft.Extensions.AI` e do `OpenAI` medidos por reflexão sobre as assemblies do pin em 2026-09-08. Last-verified: 2026-09-08.

---

## Pattern

The project consuming morph-spec maintains a **single JSON file** mapping aliases (`text-default`, `vision`, `reasoning`, etc.) to `{ provider, model, api, options }`. Agents reference aliases — never hardcoded model strings. Switching provider or model is a one-file change.

```
Standard (this file, in framework) — explains the pattern + canonical schema
        │ Claude reads + copies template
        ▼
Project (consumer) — config/model-registry.json + src/.../ModelRegistry.cs
```

---

## Schema (canonical)

```jsonc
// config/model-registry.json — IN THE PROJECT (not in framework)
{
  "$schema": "./model-registry.schema.json",
  "version": "2.0",
  "defaultAlias": "text-default",
  "aliases": {
    // `api` omitted → "responses" (the house default; see §API per alias)
    "text-default":  { "provider": "openai", "model": "gpt-4o-mini", "options": { "temperature": 0.7 } },
    "text-heavy":    { "provider": "openai", "model": "gpt-4o",      "options": { "temperature": 0.5 } },

    // Reasoning alias: NO temperature. See §temperature × reasoning.
    "reasoning": {
      "provider": "openai",
      "model": "gpt-5.5",
      "api": "responses",
      "options": { "reasoningEffort": "medium" }
    },

    // Chat Completions is opt-in, in writing, with the constraint declared.
    "legacy-chat": {
      "provider": "openai",
      "model": "gpt-5.5",
      "api": "chat",
      "options": {},
      "constraints": {
        "toolCallingRequiresReasoningEffortNone": true,
        "provenance": "developers.openai.com/api/docs/guides/migrate-to-responses, lido 2026-09-08"
      }
    },

    "long-form":     { "provider": "anthropic", "model": "claude-sonnet-4-6",      "options": {} },
    "vision":        { "provider": "google",    "model": "gemini-2.5-flash",       "options": {} },
    "embeddings":    { "provider": "openai",    "model": "text-embedding-3-small", "options": { "dimensions": 1536 } },
    "transcription": { "provider": "openai",    "model": "whisper-1",              "options": {} },
    "local-dev":     { "provider": "ollama",    "model": "llama3.2:3b",            "options": { "endpoint": "http://localhost:11434/v1" } }
  },
  "fallback": {
    "text-default": "anthropic/claude-haiku-4-5",
    "text-heavy":   "anthropic/claude-sonnet-4-6"
  }
}
```

> The aliases above are an **example**. Each project defines its own — provider choices vary per feature.

**Field semantics:**
- `provider`: SDK family — one of `openai`, `google`, `anthropic`, `ollama` (first-class). `azure-openai` is supported as an opt-in branch (see §providers).
- `model`: model id for that provider.
- `api`: **the protocol this alias speaks** — `responses` or `chat`. Omitting it means `responses`. See §API per alias.
- `options`: per-request extras. `temperature`, `topP`, `maxOutputTokens`, `seed`, `stopSequences`, `toolMode`, `reasoningEffort`, `serviceTier`, plus `endpoint` for Ollama. **`temperature` and `reasoningEffort` never appear in the same alias** — see §temperature × reasoning.
- `constraints`: declared limits of this alias (`toolCallingRequiresReasoningEffortNone`, `temperatureUnsupported`) — **each one requires `provenance`**, the document and date it came from. A limit with no source is folklore with the force of code.
- `fallback`: when the primary provider fails (rate limit, outage), switch to this alias. Format `provider/model`.

---

## API per alias — `responses` vs `chat`

**Every alias declares which protocol it speaks.** This is the field the v1 schema did not have, and its absence is why "which API does this agent talk to?" used to be answered by reading a conditional in the middle of the client factory.

| Value | Meaning | When |
|---|---|---|
| `responses` (**default when omitted**) | OpenAI **Responses API** | New work. `default(AgentApi)` is `Responses` on purpose |
| `chat` | OpenAI **Chat Completions** | Opt-in, written by a human, for broad model compatibility or an existing integration |

**Why Responses is the default**, in two documented facts:

1. Microsoft Learn, *Agent Framework → model providers → OpenAI* (`ms.date` 2026-09-03), verbatim: *"Responses is the recommended primary client when available"*, with a tool matrix in which Chat Completions loses **Code Interpreter, File Search and Hosted MCP**.
2. OpenAI, *Migrate to the Responses API* (developers.openai.com/api/docs/guides/migrate-to-responses, lido 2026-09-08), verbatim: *"While Chat Completions remains supported, Responses is recommended for all new projects."*

**Providers that expose a single protocol (Google, Ollama, Anthropic) ignore the field.** The enum describes the choice **between the two OpenAI protocols**; writing `"api": "chat"` on an Ollama alias would suggest a choice that provider does not offer. They therefore omit `api` and fall into `responses`, which the registry treats as "no protocol decision to make here". What must **not** happen is reaching an OpenAI-compatible endpoint (Ollama, gateway) through the Responses path: the Responses API is OpenAI's, not the compatible protocol's — the provider branch in `ModelRegistry.GetChatClient` builds a Chat Completions client for those, regardless of the field.

> Measured: `Morph.AiKit.Agents.AgentApi` has `Responses = 0` so that `default(AgentApi)` is Responses, and `ModelRegistry.ReadApi` returns `AgentApi.Responses` when `api` is absent and throws naming the alias for any value other than `responses`/`chat`. `AgentSpec.ResolveApi(aliasApi) => Api ?? aliasApi` keeps the precedence between spec and registry in exactly one place.

---

## temperature × reasoning — mutually exclusive per alias

**Rule:** an alias that carries `reasoningEffort` does **not** carry `temperature`. The rule has **two different origins, and the standard labels each one** — because they do not carry the same weight.

### Origin 1 — documented

OpenAI, *Migrate to the Responses API* (developers.openai.com/api/docs/guides/migrate-to-responses, lido 2026-09-08), verbatim:

> *"Starting with GPT-5.4, Chat Completions does not support tool calling with `reasoning_effort` values other than `none`."*

Read it narrowly, because it **is** narrow: the restriction needs **four** facts at once — protocol `chat`, tools present, an effort actually sent, and that effort ≠ `none`. It is *not* "reasoning models reject tools". Direct consequence: **a reasoning alias that uses tools needs `api: responses`.**

### Origin 2 — measured in the field, no primary source

*"Reasoning models reject `temperature != 1` when tools are present."* This is **measured in production**, not documented: GHLBrain's `ToolAgentRunner.cs:35-38` strips temperature for that path, recorded in `docs/specs/elevacao-9/brutos/scan-ghlbrain.md:106`, and corroborated by third parties (issues on `openai-python` and LibreChat). **The 2026-09-08 research did not find a primary OpenAI sentence stating it.**

So it lives in the schema as a **per-alias flag with mandatory provenance** (`constraints.temperatureUnsupported` + `constraints.provenance`), never as a universal rule hardcoded in the registry. Whoever turns it on is asserting it for one specific model and writing where they got it from.

> Measured: `ModelAliasConstraints.ValidateProvenance` refuses to load an alias with a flag set and no `provenance`, and `ModelAlias.CheckRequest` returns a Result (never an exception) listing each violation **with its provenance attached**.

### Reasoning effort values, by model, dated

| Model | Accepted efforts | Source (lido 2026-09-08) |
|---|---|---|
| `gpt-5.5` | `none`, `low`, `medium` (default), `high`, `xhigh` | developers.openai.com/api/docs/models/gpt-5.5 |
| `gpt-5.6` | the above **plus `max`**, and introduces `reasoning.mode` (`standard` / `pro`) | developers.openai.com/api/docs/guides/latest-model?model=gpt-5.6 |

---

## How this reaches .NET

**The native path is confirmed** — measured by reflection over `Microsoft.Extensions.AI.Abstractions` 10.9.0 on 2026-09-08, and compiled by the ai-kit:

```csharp
// Microsoft.Extensions.AI 10.9.0 — measured surface
//   ChatOptions.Reasoning : ReasoningOptions?
//   ReasoningOptions { ReasoningEffort? Effort; ReasoningOutput? Output; }
//   enum ReasoningEffort { None=0, Low=1, Medium=2, High=3, ExtraHigh=4 }
var options = new ChatOptions
{
    ModelId = alias.Model,
    Reasoning = new ReasoningOptions { Effort = ReasoningEffort.Medium },
};
```

**Escape hatch, and when it is still needed.** The M.E.AI enum has five members; the provider's own ladder is wider (`gpt-5.6` adds `max`, and `reasoning.mode`). For a value the enum cannot express, go through `ChatOptions.RawRepresentationFactory` and the provider type:

```csharp
// OpenAI 2.13.0 — measured surface
//   CreateResponseOptions.ReasoningOptions : ResponseReasoningOptions
//   ResponseReasoningOptions.ReasoningEffortLevel : ResponseReasoningEffortLevel?
//   ResponseReasoningEffortLevel = { None, Minimal, Low, Medium, High }   ← no xhigh/max in 2.13.0
#pragma warning disable OPENAI001   // still required — see §OPENAI001
options.RawRepresentationFactory = _ => new CreateResponseOptions
{
    ReasoningOptions = new ResponseReasoningOptions
    {
        ReasoningEffortLevel = ResponseReasoningEffortLevel.High,
    },
};
#pragma warning restore OPENAI001
```

**Honest limit:** `xhigh` and `max` are documented by OpenAI for `gpt-5.5`/`gpt-5.6` but are **not present in the `OpenAI` 2.13.0 SDK's `ResponseReasoningEffortLevel`** (measured). Sending them today means a raw JSON patch, not a typed member. Confirm against the installed package before promising a project the wider ladder.

### `OPENAI001`

**Measured, not inferred:** `OpenAI.Responses.ResponsesClient` and `OpenAI.Responses.CreateResponseOptions` still carry `[Experimental("OPENAI001")]` in **OpenAI 2.13.0** (reflection over the pinned assembly, 2026-09-08). Code that touches the Responses client types directly still needs `#pragma warning disable OPENAI001` (or `<NoWarn>OPENAI001</NoWarn>`). `OpenAI.Chat.ChatClient` carries no such attribute.

Going through `Microsoft.Extensions.AI` (`IChatClient`) or the MAF (`AIAgent`) keeps the diagnostic out of project code — the bridge package absorbs it. That is a reason to prefer the abstraction, not merely a style preference.

---

## Providers (first-class)

> **This section is the single source of truth for provider SDKs.** Provider SDKs change names/versions often — keep this section verified with a date; templates stay stable.

| Provider | `provider` value | NuGet SDK (apurado 2026-09-08) | `api` | Notes |
|----------|------------------|-------------------------------|-------|-------|
| **OpenAI** | `openai` | `OpenAI` **2.13.0** (pin) + `Microsoft.Extensions.AI.OpenAI` **10.9.0** (fora do pin, apurado 2026-09-08) | `responses` (default) / `chat` | `new OpenAIClient(key).GetResponsesClient().AsIChatClient("<model>")` |
| **Ollama** | `ollama` | `OpenAI` **2.13.0** (OpenAI-compatible endpoint) | ignora o campo | Same `ChatClient` with custom `Endpoint` + dummy key. **Never** through the Responses path |
| **Google Gemini** | `google` | `Google.GenAI` **1.21.0** (pin) | ignora o campo | Adapt provider client to `IChatClient` |
| **Anthropic Claude** | `anthropic` | Confirm current SDK on NuGet before use | ignora o campo | Adapt provider client to `IChatClient` |
| **Azure OpenAI** (opt-in, not default) | `azure-openai` | `Azure.AI.OpenAI` + `Azure.Identity` — confirm exact versions on NuGet before use | segue o OpenAI | Add only when a client requires data residency |

> **Restore trap, measured 2026-09-08:** `Microsoft.Extensions.AI.OpenAI` 10.9.0 declares `OpenAI (>= 2.12.0 && < 2.13.0)`. Pairing it with the pinned `OpenAI` 2.13.0 emits **NU1608** on restore. Either pin `OpenAI` to 2.12.x alongside the bridge, or accept the warning knowingly and write the decision in `decisions.md`. Do not discover this in CI.

**No floating ranges.** Every `PackageReference` above is an exact version. A floating `2.*` on a weekly-cadence SDK is how ProspectPRO bought a `MissingMethodException` on the Responses API **after paying for the call** (`docs/specs/elevacao-9/brutos/scan-prospectpro.md:55`).

**Adding a provider branch:** the `ModelRegistry.cs` template implements `openai` + `ollama`. To add `google`, `anthropic`, or `azure-openai`:
1. Confirm the current NuGet SDK — community SDKs evolve; check NuGet/context7 before coding.
2. Add a `case "<provider>":` branch in `ModelRegistry.GetChatClient`'s switch, following the `openai`/`ollama` pattern.
3. Add the SDK `PackageReference` to the project's `.csproj`, with an **exact** version.
4. The branch returns an `IChatClient`. If the SDK doesn't expose `IChatClient` natively, wrap it with an adapter.

**Direct models only:** never use a hosted agent service. Every provider above is reached as a direct model.

---

## Implementation pattern

```csharp
// WRONG — hardcoded model + provider, and no protocol decision anywhere
var agent = new ChatClient("gpt-4o-mini", apiKey)
    .AsIChatClient()
    .AsAIAgent(instructions: prompt);

// RIGHT — alias-based, provider-agnostic; the alias carries provider, model AND api
var agent = _modelRegistry.GetChatClient("text-default")
    .AsAIAgent(instructions: prompt);
```

The `ModelRegistry` class in the project owns the mapping. See template `templates/code/dotnet/ai-agents/ModelRegistry.cs.template`, and the compiling reference implementation in `templates/dotnet/ai-kit/src/Morph.AiKit/Providers/`.

**One client per alias, memoized.** A provider client owns a connection pool and an HTTP handler; rebuilding it per call is how socket exhaustion usually starts. `ModelRegistry.GetChatClient` calls the factory once per alias and hands back the same instance afterwards.

**Failure at load time, never as control flow.** Invalid document, alias with no pricing decision, constraint without provenance: all of it throws during composition, naming the alias and the field — never as a null that crosses three layers and shows up as a zeroed cost in a report.

---

## How Claude implements this in a project

When a task says "register the X agent" and the project has >1 model or >1 provider, Claude:

1. Read this standard.
2. Copy `templates/code/dotnet/ai-agents/ModelRegistry.cs.template` to `src/.../ModelRegistry.cs` in the project. Substitute placeholders.
3. Copy `templates/code/dotnet/ai-agents/model-registry.json.template` to `config/model-registry.json`. Adjust aliases to project needs, and **declare `api` on every OpenAI alias that is not Responses**.
4. Wire `ModelRegistry` into DI (see `AgentBootstrap.cs.template`).
5. Future agents reference `_modelRegistry.GetChatClient(alias)`.

---

## Recommended aliases & models (orientativo — setembro/2026)

> Tabela orientativa. Actual values live in the project's `model-registry.json`. Models evolve fast.

| Alias | Provider sugerido | Modelo (setembro/2026) | `api` | Use case |
|-------|-------------------|------------------------|-------|----------|
| `text-default` | openai | gpt-4o-mini | responses | Geração padrão de texto |
| `text-heavy` | openai / anthropic | gpt-4o / claude-sonnet-4-6 | responses | Análise complexa |
| `reasoning` | openai | gpt-5.5 (`reasoningEffort`, **sem** `temperature`) | responses | Decisões, planejamento, math |
| `long-form` | anthropic | claude-sonnet-4-6 | — | Escrita longa, análise de documento |
| `vision` | google / openai | gemini-2.5-flash / gpt-4o | responses | Image → Text |
| `embeddings` | openai | text-embedding-3-small | — | RAG, similarity |
| `transcription` | openai | whisper-1 | — | Audio → Text |
| `local-dev` | ollama | llama3.2:3b | — (Chat Completions no fio) | Dev local, sem custo |

---

## Anti-patterns

| Anti-pattern | Why | Right way |
|--------------|-----|-----------|
| Hardcode `"gpt-4o-mini"` in agent code | Switching model = grep + replace | Use `_modelRegistry.GetChatClient("text-default")` |
| Alias com `temperature` **e** `reasoningEffort` | Os dois não convivem no mesmo modelo; um dos dois será silenciosamente ignorado ou rejeitado | Um alias de raciocínio sem `temperature`; se o limite for do modelo, declare em `constraints` **com `provenance`** |
| `constraints` sem `provenance` | Um limite sem fonte é folclore com força de código | Escreva o documento e a data; o registry recusa a carga sem isso |
| Alias OpenAI sem decidir `api` quando o caminho é Chat Completions | Ninguém sabe qual protocolo o agente fala até ler o factory | Omitido = `responses`; Chat Completions exige `"api": "chat"` por escrito |
| `PackageReference` com faixa flutuante (`2.*`, `1.0.*`) | SDK de cadência semanal quebra em produção depois de a chamada ser paga | Versão exata; pacote fora do `ai-pin.json` leva a data de apuração |
| Put `model-registry.json` in `.morph/` | `.morph/framework` is framework-only; `.morph/config/config.json` is reserved for morph-spec metadata | Put it in `config/` of the project |
| Different `ModelRegistry` class per agent | Defeats the purpose | One class per project, singleton in DI |
| Use a hosted agent service to "simplify" | Hosted lags 6-9 months, removes control | Always direct models |
| Mix providers without a `fallback` block | Hard to diagnose outages | Declare `fallback` in `model-registry.json` |

---

## Checklist (verifiable by morph-eval)

- [ ] Project has `config/model-registry.json` (or declared equivalent), `version` `2.0`.
- [ ] Project has a `ModelRegistry` class registered as singleton in DI.
- [ ] Every OpenAI alias either omits `api` (→ `responses`) or declares `"api": "chat"` explicitly.
- [ ] No alias carries `temperature` and `reasoningEffort` at the same time.
- [ ] Every `constraints` block has a non-empty `provenance` with a document and a date.
- [ ] No agent code has hardcoded model strings — `grep -rE 'gpt-[0-9]|claude-|gemini-|whisper-' src/` matches only `ModelRegistry.cs`.
- [ ] Aliases used in agent code exist in `model-registry.json`.
- [ ] No `PackageReference` in the project uses a floating range for an AI SDK.

---

## References

- `ai-agents-setup` — provider SDKs, Responses vs Chat Completions setup
- `ai-agents-structured-output` — `ResponseFormat` and what else goes into `ChatOptions`
- `ai-agents-service-tier-flex` — `serviceTier` in `options`
- Reference implementation (compiles against the pin): `templates/dotnet/ai-kit/src/Morph.AiKit/Providers/`
- Template: `templates/code/dotnet/ai-agents/ModelRegistry.cs.template`
- Template: `templates/code/dotnet/ai-agents/model-registry.json.template`
- Template: `templates/code/dotnet/ai-agents/AgentBootstrap.cs.template`

---

*MORPH-SPEC by Polymorphism Tech — ai-agents/providers/model-registry.md v2.0 (2026-09-08)*
