# Centralised model registry > **⚠️ Superseded (2026-05-21).** The model registry is now driven by a > single manifest — **`.opencode/models/models.yaml`** — with > `tools/sync_models.py` generating every derived surface (the > `.opencode/models/*.txt` files, the `opencode.json` `agent` block, and > each agent's `.md` `model:` frontmatter). `sync_models.py` runs > automatically at controller-pipeline startup. > > See **[`.opencode/models/README.md`](../../.opencode/models/README.md)** > for the current design and operator workflow. Below, the claim that > `.opencode/models/*.txt` is the "single source of truth", the > "No `model:` frontmatter" rule, and the "Why not a renderer / single > registry file" sections are **obsolete** — `models.yaml` + > `sync_models.py` are exactly that renderer and single file. The > tier-ladder content (`tiers.yaml` / `sync_tier_models.py`) and the > Stage 2 fallback-chain ideas remain accurate. This document explains where each agent's model assignment lives, how to swap a model for the next dispatch, and what the planned Stage 2 fallback layer will add. Everything described here is in production as of the 2026-05-10 "Stage 1 model centralisation" change. ## TL;DR — how to swap a model ```bash # Switch the dispatcher fleet from Qwen to Sonnet for the next cycle. echo "anthropic/claude-sonnet-4-6" > .opencode/models/default.txt # Restart OpenCode so it re-reads opencode.json's {file:...} # interpolation. (See "Why a restart is required" below.) kill bash scripts/opencode-builder.sh # or however you started it # Next dispatched session uses the new model. python3 tools/dispatch_review.py --once ``` > **Why a restart is required (Stage 1 limitation).** Stage 1 ships > with a dispatcher-side runtime override at `POST /session`, but > OpenCode does **not** propagate the session-level model to the > per-prompt generation path — every `POST /session/{id}/prompt_async` > re-resolves the agent's model from `opencode.json`'s > `agent..model`, which is loaded into memory at server startup. > So an edit to `.opencode/models/.txt` only takes effect after > the next OpenCode restart re-reads the `{file:...}` interpolation. > > The dispatcher's `POST /session` override still serves two real > purposes: > > 1. **Observability** — every dispatched session record carries the > intended model, so the session list shows what the registry > *meant* to use. > 2. **Drift detection** — when the override on the session record > disagrees with what OpenCode actually used for generation, > operators have evidence the static cache is stale. > > True restart-free swaps are reserved for Stage 2 (see "Stage 2" > section). ## Where each model is defined | File | Purpose | |---|---| | `.opencode/models/*.txt` | **Single source of truth.** One file per role; each contains exactly one `providerID/modelID` line. | | `opencode.json` → `agent..model` | References the corresponding `.opencode/models/.txt` via `{file:...}` interpolation. OpenCode reads this at startup to populate its static agent registry. | | `.opencode/agents/*.md` | **No `model:` frontmatter.** Stripped during Stage 1 — keeping a `model:` line here would create two sources of truth with undocumented precedence. | | `tools/_opencode_worker.py` → `_resolve_role_model(agent_name)` | Reads `.opencode/models/.txt` first, falls back to `default.txt`, returns a `ResolvedModel` (or `None`). | The registry file naming convention matches the agent name 1:1, with `default.txt` as the catch-all for agents that share a worker model (today: the bulk of `git-*-util`, `session-health-*-util`, the dispatcher entry-point workers, etc.). Tier selectors (`tier-min`, `tier-0`, `tier-1`, `tier-2`) each have their own `.txt` file so swapping one slot's pinned model is independent. **Tier `.txt` files are generated** from `.opencode/models/tiers.yaml` by `tools/sync_tier_models.py` — see `.opencode/models/README.md` for the operator workflow. ## Lookup precedence The resolver tries two paths, in order: 1. `.opencode/models/.txt` — per-agent override. 2. `.opencode/models/default.txt` — fallback. If neither exists (or both are malformed), the resolver returns `None`. The worker then creates the session **without** a `model` field and OpenCode falls back to its own static resolution (the `agent..model` from `opencode.json`, then the agent's `.md` frontmatter, then OpenCode's global default). This is the same fail- open semantic OpenCode uses for any missing config — the dispatcher remains functional even with the registry directory absent. Two agents have NO `model:` line and intentionally do not appear in the registry: - `task-implementor` — inherits from the `tier-*` selector that invoked it (the entire purpose of the `tier-*/task-*` split). - `agent-evolution-pool-supervisor` — inherits the same way. These agents continue to work without explicit assignments. ## File format Every `.opencode/models/.txt`: - Contains **exactly one non-empty line**. - Starts with the provider id, then `/`, then the model id (e.g. `anthropic/claude-haiku-4-5`). - May have a single trailing newline. Leading and trailing whitespace on the model line are stripped by the resolver. - Does NOT support comments, blank lines, or multi-line content. Stage 2 will add a separate `.fallbacks.txt` file for the fallback chain (see below); for now, multi-line files are rejected as malformed. The provider id must already be declared in `opencode.json`'s `provider.` block. If you reference an unknown provider, OpenCode will 400 the `POST /session` call with an opaque message — the cost of catching this at OpenCode's edge instead of in the resolver. ## How the dispatcher uses the registry 1. `_dispatch_runtime.dispatch_one` calls `_opencode_worker.run_session_blocking(agent="pr-review-worker", …)`. 2. `run_session_blocking` calls `_resolve_role_model("pr-review-worker")`. 3. The resolver reads `.opencode/models/pr-review-worker.txt` (absent), then `.opencode/models/default.txt`, parses the model line, and returns a `ResolvedModel(provider_id="openai", full_id="openai/gpt-5-mini")`. 4. `run_session_blocking` builds the `POST /session` body as `{"title": "...", "model": {"providerID": "openai", "id": "openai/gpt-5-mini"}}` and dispatches. The session record now carries the intended model — useful for operator visibility. 5. The actual model used for generation depends on `opencode.json`'s `agent..model` cached at OpenCode startup, NOT the session-level override. Stage 1 therefore requires the two to agree (which they do as long as the operator restarts OpenCode after editing a model file). The override still serves as a consistency sentinel — if it disagrees with the assistant message's `modelID`, the operator knows the static cache is stale. Subagents the worker invokes via the Task tool (e.g. `implementation-worker` → `tier-dispatcher` → `tier-0` → `task-implementor`) pick their model from OpenCode's static agent registry — which is itself driven by the same `.opencode/models/*.txt` files via `{file:...}` interpolation in `opencode.json`. We tried adding `model` to the `prompt_async` body to bypass the startup cache. OpenCode 0.x silently dropped those requests (returned 200 OK but never produced an assistant message). Schema for runtime prompt-time overrides is undocumented and is the open question for Stage 2. ## Operator workflows ### Swap one role For non-tier agents (e.g. the default worker, `ca-test-infra-improver`), edit the `.txt` file directly: ```bash # Pin ca-test-infra-improver to a newer Haiku release. echo "anthropic/claude-haiku-5-1" > .opencode/models/ca-test-infra-improver.txt ``` For tier slots (`tier-min`, `tier-0`, `tier-1`, `tier-2`), edit the manifest and re-run the generator — the `.txt` files are generated, not hand-edited: ```bash # 1. Change the `model:` line for the desired slot in tiers.yaml $EDITOR .opencode/models/tiers.yaml # 2. Regenerate the .txt files and the tier-dispatcher mapping table python3 tools/sync_tier_models.py # 3. Verify everything stays in sync (CI also runs this) python3 tools/sync_tier_models.py --check ``` Effect (either path): the dispatcher uses the new model on the next session that invokes the affected agent (or any subagent that calls into it). Other roles unchanged. ### Swap the bulk worker ```bash # Move every default-tier agent to a different model. echo "openai/gpt-5-codex" > .opencode/models/default.txt ``` Effect: every agent in the registry that didn't have its own per-role file now uses the new value on the next dispatch. The `tier-*` files are unaffected — they still have their explicit pins. ### Verify what's currently active ```bash # Print the model the dispatcher would resolve for a given agent name. python3 -c " import importlib.util, pathlib spec = importlib.util.spec_from_file_location( '_opencode_worker', pathlib.Path('tools/_opencode_worker.py'), ) mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) r = mod._resolve_role_model('pr-review-worker') print(f'pr-review-worker -> {r.full_id} (from {r.role_file})' if r else 'no override') " ``` For the static (OpenCode-side) view, hit OpenCode's `/config` endpoint: ```bash curl -s http://127.0.0.1:4096/config | python3 -c " import json, sys cfg = json.load(sys.stdin) print(cfg['agent']['pr-review-worker']['model']) " ``` If the two values disagree after editing a model file, the OpenCode server has stale config — restart it to re-read the `{file:...}` interpolation. The dispatcher path is always live. ### Roll back ```bash git checkout HEAD -- .opencode/models/default.txt # next dispatched session uses the committed model ``` Because the registry files are committed and tiny, the rollback story is the same as for any other config: `git checkout`, no migration. ## Inheriting subagents Two agents intentionally have NO model assignment anywhere (no `.opencode/models/.txt`, no `model:` in `.md`, no `agent..model` in `opencode.json`): - `task-implementor` - `agent-evolution-pool-supervisor` Per [OpenCode's agent inheritance rule](https://opencode.ai/docs/agents#model), subagents without an explicit model inherit from the agent that invoked them. `task-implementor` always runs under a `tier-*` parent, so it gets the tier's model; `agent-evolution-pool-supervisor` runs under whichever supervisor or entry-point invoked the pool. Adding model inheritance to more subagents is a future opportunity — it would let us delete more `.opencode/models/*.txt` files in favour of cascading from `default.txt` through the call chain — but Stage 1 deliberately preserves today's bit-for-bit semantics, so no other subagent is converted to inherit yet. ## Stage 2 (planned) — fallback chain + restart-free swaps Stage 2 has two related goals: ### 2a. Fallback chain (failover) The Stage 1 design intentionally accommodates fallback as a purely additive extension. The plan, NOT shipped yet: 1. Add `.opencode/models/.fallbacks.txt` (optional). Multi-line ordered list of fallback model ids — first line is tried after the primary fails, second line after that, etc. 2. `_opencode_worker.run_session_blocking` gains a candidate loop: try the primary, catch a documented set of retryable errors (HTTP 4xx with "endpoint paused", 401, 403, 5xx, connection refused, DNS NXDOMAIN, timeout), fall through to the next candidate; only exhaust to `transport-error` when every candidate is tried. 3. A `model-fallback-engaged` status comment is posted on the PR so operators see the live failover. The Stage 1 file layout already reserves the namespace: a future `.fallbacks.txt` is the natural companion to today's `.txt`, and the resolver's "missing file = no override" semantic extends cleanly to "missing fallbacks file = no fallback chain." ### 2b. Restart-free model swaps Stage 1's restart requirement comes from OpenCode caching `agent..model` at server startup. The clean fix requires one of: - **Option A (preferred):** discover the working schema for a runtime model override on `POST /session/{id}/prompt_async`. The obvious `{providerID, id}` and `{providerID, modelID}` shapes cause OpenCode to silently drop the request — diagnostic output needed. - **Option B:** OpenCode adds a `/config/reload` endpoint we can call after editing `.opencode/models/`. - **Option C:** the dispatcher hot-restarts OpenCode automatically when it sees a registry mtime newer than the OpenCode process start time. This is the lowest-effort path and lets operators treat restarts as an implementation detail. Cost: ~5s downtime per swap; OpenCode startup takes a second or two. Until Stage 2 ships, treat the restart as part of the swap procedure. ## Why not a renderer? Earlier proposals included a `tools/render-agents.py` build step that would expand a TOML registry into the `model:` lines of each agent `.md` file. We rejected that path for two reasons: 1. **Two-step swap.** Operators would have to edit one file, run the renderer, then restart OpenCode. With Stage 2 a renderer-based solution would also need the failover loop wired in twice (in the renderer for "static" baselines AND in the dispatcher for "dynamic" failover). 2. **Generated files drift.** The renderer would commit generated `.md` files that need to stay in sync with the registry. The `{file:...}` interpolation in `opencode.json` accomplishes the same thing with no generated artefact and no commit drift. (Stage 1 still requires a restart, so the "no two-step swap" advantage of the runtime override is partly aspirational — it pays off fully once Stage 2 lands.) ## Why not a single registry file (e.g. `models.toml`)? OpenCode's `{file:...}` interpolation reads one entire file's content as a single string — there's no sub-path syntax like `{file:./models.toml#tier_haiku}`. A single registry file therefore requires either a renderer (rejected; see above) or a runtime override + custom parser. The per-file layout adopted in Stage 1: - Maps 1:1 to OpenCode's native `{file:...}` capability with no glue. - Gives each role a distinct `git blame` history. - Keeps the runtime override trivially small (~25 LOC). If the registry grows to ~100 roles, consolidating into a TOML + custom parser is a clean future refactor with no API surface change. ## Test coverage The Stage 1 contract is tested by [`tests/auto_agents/test_opencode_worker_models.py`](../../tests/auto_agents/test_opencode_worker_models.py): - Resolver: per-agent precedence, default fallback, return-None on missing files, whitespace stripping, multi-line rejection, malformed-line rejection, slash-split correctness for multi-`/` model ids. - `run_session_blocking`: model threaded into `POST /session` body when resolver succeeds; field omitted when resolver returns `None`; per-agent override wins over `default.txt`; malformed registry degrades gracefully. - Integration: every agent wired in `opencode.json`'s `agent` block resolves to a non-`None` model against the production registry. Guards against shipping a config that the resolver cannot honour.