Route bundle reference
One YAML file per route under the routes directory (default modelrig/routes/). Files are loaded, structurally validated, and frozen at createRig() time; any invalid bundle throws a RouteConfigError naming the file and every problem.
Top-level fields
| field | type | required | notes | |
|---|---|---|---|---|
route | string | yes | The task handle passed to rig.run(). Unique across the directory (duplicates are a load error). | |
version | integer ≥ 1 | yes | Bump on ANY change to the bundle, schema, or prompt. Recorded on every telemetry row. | |
schema | relative path \ | null | yes | JSON Schema file for the output. null = unstructured route (output must still be parseable JSON). Path resolves relative to the bundle file. |
candidates | list | yes, non-empty | THE candidate set. Each entry is {provider, model}, plus an optional trial: true (v2, R3 — see below). Providers (the ProviderId union): gemini, openai, deepseek, anthropic, grok, deepinfra, fireworks — all with adapters. Nothing outside this list can serve the route — enforced structurally (branded CandidateRef) and at runtime. | |
candidates[].trial | boolean | no (default false) | v2 (R3). A DECLARED-but-never-served-live candidate. A trial: true candidate is dropped from every live path (rig.run, runStream, runBundle, the hosted gateway slug lane) — the run loop only keeps it inside a bake-off replay, and selecting a trial-only variant on a live call is a RouteConfigError that names the bake-off. Declare a model this way to measure it on your real captured traffic before promoting it to a live candidate. Additive: a route with no trial candidate is byte-identical to before. Deploy the server/SDK (≥ 0.5.0) that understands trial: BEFORE any route YAML carries it — an older build ignores the flag (its resolver has no trial-mode filter) and would serve the model live, including an older hosted gateway reading the flag through the mirror. The console's declare-first flow writes this for you and the declare-YAML says so inline. | |
require | list | no | Hard constraints: schema_conformant, grounded, zero_retention, trace_visible (v2 — reasoning trace exposed to the caller). grounded is satisfied natively OR — when a search provider is configured — via the grounding-inject rung (search results injected into the prompt, citations normalized onto RunMeta.citations, search cost accounted). zero_retention is an opt-in data-governance filter (G-3): it routes only to zero-retention–designated endpoints, and fails closed (no designated candidate → the no-eligible-candidate error, never a silent non-ZDR dispatch). A single run can opt in without editing the bundle via RunOptions.zeroRetention: true. How require filters and orders candidates at run time is on Routing & reliability. | |
prefer | list | no | Advisory ordering: cost (ascending via the pricing snapshot; unknown cost sorts last), latency (accepted and currently a no-op — declared order stands; observed-latency sort is deferred to the observed-health work). Ordering is explained on Routing & reliability. | |
prompt.system | relative path | yes | Template file (see below). | |
prompt.variables | string list | yes | Every {{var}} referenced by the template must be declared here. | |
policy | object | yes | See below. | |
capture | boolean | no (default false) | v2. Local-only replay capture opt-in: every attempt's rendered variables + output text are written to the local SQLite captures table. Captures stay local unless the route's custody consent turns content upload on; the control-plane capture setting wins over this flag. | |
cache | boolean | no (default false) | v2 (WS-C). cache: auto stamps the per-provider cache directive on each attempt (gemini excluded); see Caching lifecycle. This is route-level automatic caching — distinct from the customer-owned explicit handle (RunOptions.cache). | |
repair | object | no | v2. Repair rung for the default variant — see below. | |
variants | list | no | v2. Named serving variants — see below. Absence = pure v1 semantics. |
dimensionslive inmodelrig/rig.yaml, not the bundle. Project-level cost dimensions ({ key, label, required }) that back/costsand the required-tag hygiene warning are declared once in the rig manifest — see Tags, dimensions, and the graph.
JSON Schema dialect
The schema file is validated with Ajv, configured for JSON Schema 2020-12 as the primary dialect with draft-07 also accepted — a schema declaring "$schema": "…/2020-12/schema" or "…/draft-07/schema" both compile (the draft-07 meta-schema is registered explicitly). Author 2020-12 for new schemas; existing draft-07 schemas keep working.
Two properties of the configuration are load-bearing when you author a schema:
- Non-strict, union types allowed. Unknown keywords do not fail compilation, and
"type": ["string", "null"]is accepted. formatis annotation-only — it is NOT asserted. Neither dialect enforcesformatwithoutajv-formats, which is not registered, so anemail,uri, ordate-timeformat documents intent but never rejects a value. If you need a value constrained, express it withpattern/enum/minimum/maxLength, notformat.
The gateway and the probe harness (packages/modelrig-probes) build an identical Ajv instance by design, so a fixture scores exactly as it serves.
policy
| field | type | notes | ||
|---|---|---|---|---|
retries | map | Per-failure-class retry budgets, e.g. { content_invalid: 2, network: 4, capacity_shed: 3 }. Missing class = 0 retries. Budgets are non-fungible. Terminal classes (budget_exhausted, invariant_violation) are rejected here at load time. | ||
timeout_ms | positive integer | Wall-clock deadline per attempt; breach maps to the timeout class. | ||
tier | standard \ | flex \ | priority | Requested service tier. The tier actually served is recorded separately (servedTier) — silent downgrades become visible in telemetry. |
json | native \ | json_mode | Emission rung. native: candidates need native strict schema enforcement (structured_native); the schema goes to the provider natively. json_mode: candidates need either mechanism; the schema is additionally coached into the prompt with strict formatting guidance. | |
sampling | object | no | Declared sampling — { temperature?, top_p?, max_output_tokens? }. Preserve exactly what the original call used. Absent = every adapter keeps its own defaults (Gemini runs at temperature 1.0); a call that relied on a specific temperature must declare it or its behaviour silently changes. Each field is optional and sent to the provider as declared (no clamp); ranges are validated at load — temperature 0–2, top_p (0, 1], max_output_tokens integer ≥ 1 — an out-of-range value is a config error. The block is strict: an unknown or misspelled key (e.g. temperatur, or camelCase maxOutputTokens) is a config error too, so a typo can't silently drop your sampling. max_output_tokens is the routed-lane exposure of the existing adapter cap. The route's sampling applies to the primary attempt AND its repair/extractor followups (one knob per route). |
policy:
timeout_ms: 60000
tier: standard
json: native
sampling: { temperature: 0.1, max_output_tokens: 8192 } # preserve the original call's sampling
Gemini 3 note. Google recommends temperature: 1.0 for Gemini 3 and warns that sub-1.0 values can loop / degrade on complex reasoning tasks (classification and extraction are typically fine). Declaring a sub-1.0 temperature on a Gemini-3 candidate loads and is sent as declared, with a load-time advisory naming the model and value.
Variants (bundle format v2 — Phase 3)
A route may declare variants — named bindings served via rig.run(route, { variant: "name" }). Omitting variant (or passing "default") serves the route's base config exactly as v1 did; v1 bundles remain valid unchanged. Every telemetry row records served_variant.
variants:
- name: cheap
candidates: # narrowing ONLY — must be ⊆ the route's list
- provider: openai
model: gpt-5.4-mini
json: json_mode # rung override for this variant
repair:
max_repairs: 2
repair_model: openai/gpt-5.4-mini
- name: scaffolded
scaffold: | # reasoning scaffold, appended before coaching
Think through the drivers step by step before emitting JSON.
- name: reworded
prompt_override: ./prompts/echo-v2.system.md # inline text also accepted
| field | notes | |
|---|---|---|
name | Unique per route; "default" is reserved for the base config. | |
candidates | Subset of the route's declared candidates — variants narrow, never widen (load error otherwise; the candidate-set invariant holds through variants). | |
prompt_override | Replaces the system template (rendered with the same variables + capability conditionals). Mutually exclusive with prompt_append. | |
prompt_append | Appended to the rendered system prompt. | |
scaffold | Reasoning-scaffold text appended after prompt_append, before json coaching. Manual authoring only this phase. | |
json | Rung override (native \ | json_mode). |
repair | Variant-level repair rung, overrides the route-level repair. |
repair (the repair rung of the candidate ladder — Phase 3)
repair:
max_repairs: 2 # 1 = retry-with-errors only; 2 adds the repair model
repair_model: deepseek/deepseek-chat # required when max_repairs is 2; must be a declared candidate
This is the repair rung of the candidate ladder — the ladder's rungs (resolve → render → dispatch → validate → repair → fall-through) are named in full on Routing & reliability.
On a schema-invalid output with repair enabled: attempt 1 re-asks the SAME model with the ajv error summary appended; attempt 2 hands {invalid output, errors, schema} to the designated repair model with a fixed repair prompt. Repair attempts draw from their own repair budget (never the content_invalid budget), and repaired rows carry repaired_by in telemetry — repair cost is visible, not hidden.
Requirement → capability mapping (registry-wired since Phase 3)
| requirement | satisfied by |
|---|---|
schema_conformant + json: native | structured_native |
schema_conformant + json: json_mode | structured_native OR json_mode |
grounded | grounded_native (native directive on dispatch) OR a configured search provider (grounding-inject) |
trace_visible | trace_visible (e.g. deepseek-reasoner's exposed reasoning_content) |
zero_retention | zero_retention — a model the credential-scoped registry facts designate zero-retention under our managed account (a BYOK key is scoped separately). Opt-in; fail-closed when absent. |
Capability flags are resolved from registry facts (the packaged registry/registry.json, env-overridable via MODELRIG_REGISTRY_PATH) with per-flag precedence capabilityOverrides > probed > declared — a probed-false trumps a declared-true (DeepSeek's declared schema support is revoked because every probed sample served via json_mode coaching). Models absent from the registry fall back to the adapter-static baseline below. This table is the registry-absent fallback for gemini, openai, and deepseek; a provider not shown here falls back to its own adapter's declared capability set.
| capability | gemini | openai | deepseek |
|---|---|---|---|
structured_native | ✅ | ✅ | — |
json_mode | ✅ | ✅ | ✅ |
grounded_native | ✅ | — | — |
context_cache_explicit | ✅ | — | — |
prompt_cache_key | — | ✅ | — |
prefix_cache_implicit | ✅ | ✅ | ✅ |
tier_flex | ✅ | flex-eligible models only (no mini/nano) | — |
Overridable per provider via RigConfig.capabilityOverrides (replaces the whole flag set for that provider — useful for pinning a route to one candidate in tests).
Templates
{{var}}— substituted fromRunOptions.input. Strings verbatim; objects and arrays areJSON.stringifyed; missing values throw.{{#if capability.X}}…{{/if}}/{{#unless capability.X}}…{{/unless}}— resolved against the SERVING candidate's flags before variable substitution. Flat only (nesting is a load-time error); unknown flags are load-time errors.- The rendered template is sent as the system prompt; the user turn is a fixed terse trigger. Everything the task needs belongs in the template.
Versioning discipline
The (route, version) pair keys the compiled validator cache, telemetry rows, and the console's routes mirror. Editing a bundle without bumping version makes telemetry lie across the change — bump every time.
When it doesn't work
These are the load-time and first-run failures a bundle actually hits, in the loader's own words. The first three fail LOUDLY (the bundle won't load); the last fails SILENTLY (it loads and then behaves unlike the code it replaced) — which is why it is the one to be most careful about.
schema file not found: <path>—schema:names a JSON Schema file that isn't at that path. Paths resolve relative to the bundle file. Create the file there, or setschema: nullfor a genuinely free-form task.prompt.system file not found: <path>— same shape for the prompt template. Pointprompt.systemat where the file actually is.references undeclared variable "<name>"— the template (orgrounding.query) uses a{{name}}thatprompt.variablesdoesn't declare. Add it to the list, or fix the typo. This is caught at load so an empty slot can't ship silently. Note the ONLY conditional blocks the template engine supports are{{#if capability.X}}/{{#unless capability.X}}— a{{#if <variable>}}is not a feature and will not render.- A wrong
json: native— the bundle loads, then the run behaves unlike the code it replaced.json: nativeis a claim about the MODEL: set it only for a candidate whose registry entry shows probed native strict enforcement (structured_native). Otherwise omit it and let the emulation ladder handle structure. Check the probed registry (llms.txt or the MCP oracle) rather than recalling — declared and probed behaviour differ, which is the whole point.
Still stuck? Open an issue at <https://github.com/modelrig/modelrig/issues> — a human reads them.
Raw-lane defaults (rig.runRaw)
What the raw lane sends when you omit a knob. Each knob is additive — absent ⇒ byte-identical dispatch. The one default with teeth is timeoutMs, which is tier-aware (since 0.5.0): a passthrough should not be shorter-fused than the tier it advertises, so standard/priority target 5 min and flex targets the 15-minute budget ModelRig advertises. An explicit timeoutMs always wins (no pre-dispatch rejection — a short deadline is a choice), and a timeout failure on the raw lane carries a fixHint naming the tier, its budget, and the two fixes (raise timeoutMs, or drop to serviceTier: "standard"). On flex, set timeoutMs in minutes. This table is generated from the RAW_DEFAULTS constant, so it cannot drift from what the runtime resolves.
| Knob | Default | Note |
|---|---|---|
timeoutMs | 300 s (standard, priority) · 900 s (flex) | tier-aware wall-clock deadline; an explicit timeoutMs always wins, and a timeout failure carries a fixHint naming the tier + budget |
serviceTier | standard | forwarded to the adapter; meta.servedTier reports what actually served. On flex, set timeoutMs in minutes (default 900 s) |
temperature | adapter default — not sent | absent ⇒ no temperature on the request (byte-identical dispatch) |
topP | adapter default — not sent | absent ⇒ no topP on the request (byte-identical dispatch) |
maxOutputTokens | adapter default — not sent | absent ⇒ no output-token cap on the request (byte-identical dispatch) |
grounding | absent ⇒ byte-identical | native provider grounding; provider support per the raw-lane knob matrix |
reasoning | absent ⇒ byte-identical | reasoning/thinking effort; provider support per the raw-lane knob matrix |
responseFormat | absent ⇒ byte-identical | syntactic-JSON forcing (mime-shaping only); provider support per the raw-lane knob matrix |
cache | none | customer cache handle passed through verbatim when supplied; ModelRig never creates, refreshes, or deletes the resource |
tags | none | lane=raw and rung=primary are stamped by the SDK and cannot be overridden by a caller tag |
tools / toolChoice | none | absent ⇒ no tools declared; on the raw lane a grounding-originated functionCall part is NOT a tool-call turn |
stepKey | auto — tags.step, else <provider>/<model>#<n> inside a run context | names the ground-truth step a run context records for this call (G1); explicit key wins; no run context ⇒ no step |
Raw-lane provider × knob support (rig.runRaw)
Since 0.4.0:reasoningbeyond gemini,responseFormat, and the DeepInfra/Fireworksprompt_cache_keymapping. The openai/grok prompt-cache hints were already true on 0.3.0.
The Lane-B rig.runRaw seam forwards a set of provider knobs beyond the core prompt/sampling ones. Each is ADDITIVE — absent ⇒ byte-identical dispatch — and a knob a provider cannot honor FAILS CLOSED pre-dispatch (RigFailureError, class invariant_violation; no meter, no provider call, no telemetry row), never a silent drop. This table is the single source of truth: the runtime guard (assertRawKnobsSupported) and this matrix are both generated from the same RAW_KNOB_SUPPORT table, so what you read here is exactly what the guard enforces.
| Provider | grounding | reasoning | responseFormat | cache | serviceTier | destination _(UNRELEASED)_ |
|---|---|---|---|---|---|---|
gemini | native — Google Search (googleSearch tool) | level → thinkingConfig.thinkingLevel, thinking-capable models only (see GEMINI_THINKING_CAPABLE_MODELS) | responseMimeType: application/json (mime-shaping only — no schema, validated stays false) | cache.key = a cachedContent resource handle (customer-owned lifecycle) | flex / standard / priority honored | direct only — vendor key (BYOK / configured) |
openai | native — web_search tool (Responses API) — released (live gate passed 2026-09-09) | level → reasoning_effort (1:1); a non-reasoning model's rejection is classified teachably | response_format: { type: json_object } | cache.key → prompt_cache_key; ttlSeconds ≥ 86400 → prompt_cache_retention: 24h | flex (allowlist) / priority; flex→standard is a customer-side degrade (§3.2) | direct only — vendor key (BYOK / configured) |
deepseek | fail-closed | no effort control — reasoning is model selection (deepseek-reasoner) | response_format: { type: json_object } — the prompt must contain the word "JSON" (caller's responsibility on the raw lane) | automatic prefix cache — the key is not used | no tiers — always standard | direct only — vendor key (BYOK / configured) |
anthropic | fail-closed | extended thinking is not wired on the raw lane (future work) | fail-closed — no json_object equivalent (structured output is a forced tool call) | prefix caching via cache_control markers — the key is not used | no tiers — always standard | direct only — vendor key (BYOK / configured) |
grok | native — web_search tool (xAI Agent Tools / Responses API); replaces retired Live Search — released (live gate passed 2026-09-09) | coarsened (disclosed): minimal,low → "low"; medium,high → "high" → reasoning_effort | response_format: { type: json_object } | cache.key → prompt_cache_key (routing hint) | no tiers — always standard | direct only — vendor key (BYOK / configured) |
deepinfra | fail-closed | level → reasoning_effort (1:1); a non-reasoning model's rejection is classified teachably | response_format: { type: json_object } | cache.key → prompt_cache_key (routing hint) | no tiers — always standard | direct · modelrig-served — ModelRig-owned key (managed serving, UNRELEASED) |
fireworks | fail-closed | level → reasoning_effort (1:1); a non-reasoning model's rejection is classified teachably | response_format: { type: json_object } | cache.key → prompt_cache_key (routing hint; caching is automatic host-side) | no tiers — always standard | direct · modelrig-served — ModelRig-owned key (managed serving, UNRELEASED) |
zai | fail-closed — native GLM web_search wired in B4b | level → reasoning_effort (1:1); a non-reasoning model's rejection is classified teachably | response_format json_object | cache.key → prompt_cache_key (routing hint) | no tiers — always standard | direct only — vendor key (BYOK / configured) |
Fail-closed is discoverable, not a surprise. A knob a provider cannot honor throws RigFailureError invariant_violation PRE-DISPATCH (no meter, no provider call, no telemetry row) — read this table before you call, not after. reasoning has NO per-model allowlist outside gemini. openai/deepinfra/fireworks forward level verbatim and let the provider enforce; a rejection surfaces as a classified failure that names the fix. grok coarsens (disclosed above). deepseek reasoning is model selection (deepseek-reasoner); anthropic extended thinking is unwired. responseFormat is mime-shaping, not schema enforcement. json_object forces syntactic JSON only — the raw lane still never validates or repairs, and meta.validated stays false (F4). §3.2 flex degrade is customer-side. ModelRig does not replicate the gemini direct-path infra-retry wrapper on the raw lane; under flex a capacity rejection surfaces classified (capacity_shed) for your own → standard retry ladder — a NAMED difference for your migration record, not a silent one. destination is UNRELEASED. The serving-destination column names which providers can be modelrig-served (reached on ModelRig's own key) vs direct only. Managed serving is gated behind a config flag (config.managedServing, default off, set in code — not an env var) and remains UNRELEASED (no GA); until it is enabled every call is direct (ABSENT ⇒ byte-identical).
Ask it in code instead of duplicating this table: rawKnobSupport(provider) returns the row above, and GEMINI_THINKING_CAPABLE_MODELS is the exported set the gemini reasoning gate uses (both from the package root).
Hosted proxy — /v1/chat/completions message handling
The OpenAI-compatible proxy accepts a messages[] array. Two rules keep an untrusted caller turn from being mistaken for a trusted instruction:
- Role enum. Each message
rolemust be one ofsystem,developer(normalised to the system slot),user,assistant, ortool. Any other value is rejected before the request reaches the pipeline with a teaching message (HTTP422, the gateway's validation status) — an arbitrary role is never forwarded. - Unambiguous turn encoding (raw lane). When a raw-lane request carries a genuine multi-turn transcript, the non-
systemturns are flattened into the adapter's single user prompt as a **JSON-encoded block — one JSON object per line** ({"role":"user","content":"…"}, `{"role":"tool","tool_call_id":"…", "content":"…"}), not asrole: contenttext lines.JSON.stringify` escapes any embedded newline, so a crafted turn whose content contains `"\n\nsystem: …"stays inside itscontent` field and can never read as a synthetic system line.systemturns are still joined into the system prompt as before, and the common single-user-turn case is passed through verbatim (byte-identical).