# Model Role Registry

> Single source of truth for "which Claude tier does this background task run on".

## Why

Pre-v8.14, every backend LLM call (compact summarization, SessionMemory extraction, yolo permission classification, memory relevance scoring) was hard-coded to either `getMainLoopModel()` or `getDefaultSonnetModel()`.

Concrete costs for a user on Opus:
- A `/compact` cycle summarizing 100K tokens billed Opus rates for a prose-output task that Sonnet handles equivalently.
- Every SessionMemory tick (every ~3K new tokens) forked a sub-agent on Opus to perform a structured `.md` edit.
- yoloClassifier ran Opus per tool call to answer "is this safe?" — a yes/no.
- `findRelevantMemories` was hard-coded Sonnet, ignoring users who set Haiku as their default.

This file owns the policy decision once.

## Roles

| Role | Default tier | Used for | Env override |
|---|---|---|---|
| `mainLoop` | user's `/model` choice | the user-facing turn loop | `CLAVUE_MODEL_MAIN_LOOP` |
| `summarize` | Sonnet | full / partial compact summarization | `CLAVUE_MODEL_SUMMARIZE` |
| `extract` | Sonnet | SessionMemory `.md` updater (auto + `/summary`) | `CLAVUE_MODEL_EXTRACT` |
| `classify` | smallFast (Haiku) | yoloClassifier safety classification | `CLAVUE_MODEL_CLASSIFY` |
| `searchMemory` | smallFast (Haiku) | memdir relevance scoring | `CLAVUE_MODEL_SEARCH_MEMORY` |
| `quickSummary` | smallFast (Haiku) | toolUseSummary 1-line compaction | `CLAVUE_MODEL_QUICK_SUMMARY` |
| `background` | smallFast (Haiku) | hook bodies (prompt / agent / skillImprovement) | `CLAVUE_MODEL_BACKGROUND` |

Notes:
- `smallFast` = `process.env.ANTHROPIC_SMALL_FAST_MODEL || getDefaultHaikuModel()` (existing convention; unchanged).
- Defaults are resolved per-call, so updating `ANTHROPIC_DEFAULT_SONNET_MODEL` mid-session takes effect on the next role lookup.
- Env overrides accept the same vocabulary as `/model`: aliases (`haiku`, `sonnet`, `opus`) or full IDs (`claude-haiku-4-5-20251001`).

## Resolution order

1. `CLAVUE_MODEL_<ROLE>` env var (operator override; takes effect without restart).
2. Active `/provider` profile's `modelRouting` slot, walked through the same fallback chain as `providerModes.ts:getProviderModeSlotValues` (so a user who sets `smallFastModel` in `/provider` gets that value for `classify` / `searchMemory` / `quickSummary` / `background`, not first-party Haiku).
3. Default policy table above.
4. Fallback for `mainLoop` role: user's resolved `/model` choice (already wired through `getMainLoopModel()`, which itself reads `/provider primaryModel`).

Empty / whitespace env values are treated as unset (no accidental "" → wrong-model). Same for empty routing slots — the fallback chain skips blank entries.

### Per-role routing fallback chain

When the env override isn't set, the role consults `/provider modelRouting` in this order before falling through to its first-party default:

| Role | Routing fallback chain |
|---|---|
| `summarize` | `generalModel` → `subagentModel` → `primaryModel` |
| `extract` | `subagentModel` → `generalModel` → `primaryModel` |
| `classify`, `searchMemory`, `quickSummary`, `background` | `smallFastModel` → `subagentModel` → `primaryModel` |
| `mainLoop` | (handled upstream by `getMainLoopModel()`; no separate routing branch here) |

These chains mirror `providerModes.ts:111-137` — the same chain that derives `fastModel` / `sonnetModel` slot values for the rest of the codebase. Reusing the chain keeps `/provider` behavior internally consistent so a user only configures the 8 routing slots once.

## Where it's wired

| Call site | Role | File |
|---|---|---|
| `queryModelWithStreaming` for `/compact` summary | `summarize` | `src/services/compact/compact.ts` |
| `runForkedAgent` for SessionMemory auto-extraction | `extract` | `src/services/SessionMemory/sessionMemory.ts` (post-sampling hook) |
| `runForkedAgent` for `/summary` manual extraction | `extract` | `src/services/SessionMemory/sessionMemory.ts` (manual entry) |
| `getClassifierConfiguredModel` fallback | `classify` | `src/utils/permissions/yoloClassifier.ts` |
| `findRelevantMemories` `sideQuery` | `searchMemory` | `src/memdir/findRelevantMemories.ts` |

The other smallFast call sites (`microReflectionHook`, `awaySummary`, `claudeAiLimits`, hook executors, `toolUseSummary`, `agenticSessionSearch`, API error fallback) already used `getSmallFastModel()` directly and will be migrated opportunistically — they were already on the right tier, so they're not blocking.

## Why not a settings.json schema

Adding a new schema would couple every install + sync path (settings sync, remote managed settings, profile import). Env vars + a single resolver are read identically by Bedrock, Vertex, firstParty, and Foundry — the same contract that already works for `ANTHROPIC_SMALL_FAST_MODEL`.

Persistent per-role pinning for a 3P provider lives in `/provider modelRouting` (the existing 8-slot routing block). The role table reads it via the same fallback chain `providerModes.ts` already uses, so no new persistence layer is introduced for this PR.

If a user wants to pin a model for a role permanently outside of a `/provider` profile (e.g. shell-level override on a stock Claude install), they put the env var in their shell profile or in `clavue` launcher wrapping. Keeps the source of truth in one place.

## Mixed routing (Claude + GPT) safety

When `/provider` mixes families — e.g. `primaryModel = gpt-5.5`, `generalModel = claude-opus-4-7-high`, `smallFastModel = gpt-5.3-codex` — backend roles can land on a non-Claude model. Three fragile spots and how the role registry handles them:

| Site | Risk | Behavior on mixed routing |
|---|---|---|
| `findRelevantMemories` | Sends `output_config.format` (Claude-only structured outputs beta). If routed to GPT/Bedrock/Vertex, the gateway 400s. | Role registry resolves `searchMemory`, then `findRelevantMemories` checks `modelSupportsStructuredOutputs(model)`. If false, it short-circuits to `selectLocalRelevantMemoriesFromRanked` instead of issuing a doomed call. |
| `sideQuery` (any caller passing `output_format`) | Same risk one layer down — anyone, not just memdir. | `sideQuery` strips `output_config` and the structured-outputs beta when the model is unsupported, so callers get free-text JSON parsing instead of a 400. Caller is responsible for tolerating free-text (memdir falls back to local rank; other current callers don't pass `output_format`). |
| `yoloClassifier` | Uses forced `tool_choice: { type: 'tool', name: ... }`. Many 3P gateways don't honor forced tool_choice. | Already gated upstream by `modelSupportsAutoMode` — first-party-only for external users. The classifier never reaches a 3P/GPT slot through the role registry. |
| SessionMemory `extract` | Forked agent uses `FileEditTool` to edit the `.md`. A GPT model running through the OpenAI Responses adapter can do tool use, but accuracy on structured `Edit` calls is lower than Claude. | Default `extract` chain prefers `subagentModel`; pin `CLAVUE_MODEL_EXTRACT=sonnet` (or any Claude id) when a mixed `/provider` profile would otherwise put GPT in this seat. |

The example mixed profile from the v8.14 release notes — `planModel/guideModel = gpt-5.5` (the "opus" slot), `generalModel/subagentModel = claude-opus-4-7-high` (the "sonnet" slot), `smallFastModel = gpt-5.3-codex` (the "haiku" slot) — resolves to:

| Role | Resolved | Notes |
|---|---|---|
| `mainLoop` | user's `/model` choice | Honors user choice as before — orthogonal to backend role wiring. |
| `summarize` | claude-opus-4-7-high | `general` slot wins (Claude); compact summary stays on Claude family. |
| `extract` | claude-opus-4-7-high | `subagent` slot wins (Claude); SessionMemory `FileEditTool` calls stay on Claude. |
| `classify` | gpt-5.3-codex | Gated upstream: `modelSupportsAutoMode` is first-party-only for external users, so classify never actually runs on GPT in production. The role table value is what `snapshotModelRoles()` reports for `/doctor`. |
| `searchMemory` | gpt-5.3-codex | Short-circuits to local rank automatically (no structured outputs on GPT). |
| `quickSummary`, `background` | gpt-5.3-codex | Free-text smallFast — works as long as the gateway honors plain `messages` calls. |

If you want SessionMemory `extract` to stay on Claude even when a future routing change moves `subagent`/`general` to GPT, set `CLAVUE_MODEL_EXTRACT=sonnet` (or a specific Claude id) — env override takes precedence over routing.



1. Add the role name to `ALL_MODEL_ROLES` and the `ModelRole` union in `src/utils/model/modelRolesCore.ts`.
2. Add a `case` to `defaultModelForRoleWith()` choosing the tier.
3. Document the env var in this file.
4. Update `tests/model-roles.test.mjs` (the role list test will fail until you do).

## Diagnostic

```ts
import { snapshotModelRoles } from './utils/model/modelRoles.js'
console.log(snapshotModelRoles())
// {
//   mainLoop: 'claude-opus-4-6',
//   summarize: 'claude-sonnet-4-6',
//   extract: 'claude-sonnet-4-6',
//   classify: 'claude-haiku-4-5',
//   searchMemory: 'claude-haiku-4-5',
//   quickSummary: 'claude-haiku-4-5',
//   background: 'claude-haiku-4-5',
// }
```

Useful for `/doctor` output and bug reports.
