Declared-task model routing
praetorctl models route makes an offline selection from
.config/models/routing.yaml. It filters by declared task and model capabilities,
checks projected request capacity against supplied counters and declared limits,
and minimizes configured token cost.
It does not dispatch an agent or call a provider. Prices, capabilities and task
assignments are operator declarations, not measured quality or current availability.
Current execution path
Text description
- Filter chain, in code order: 1. hasRoutingTag (tier.TargetTasks match), 2. hasTaskCapabilities (model.Capabilities match), 3. quotaEligible (RPM/TPM & 429 cooldown), 4. betterTaskRoute (lowest cost & ID tie-break).
- Boxes: models route (task, tokens & capabilities), routing.yaml (.config/models/routing.yaml), LimitTracker (in-process or --usage snapshot), Selected TaskRoute (model, tier, cost, headroom), Excluded / Skipped (ineligible or higher cost).
- models route → 1. hasRoutingTag (TaskRequest).
- routing.yaml → 1. hasRoutingTag (LoadRoutingConfigContext) → 2. hasTaskCapabilities (task eligible) → 3. quotaEligible (caps match).
- LimitTracker → 3. quotaEligible (usage counters) → 4. betterTaskRoute (within quota) → Selected TaskRoute (best route).
- 2. hasTaskCapabilities → Excluded / Skipped (missing capability).
- 3. quotaEligible → Excluded / Skipped (limit or cooldown).
- 4. betterTaskRoute → Excluded / Skipped (cost or tie-break).
- Scenario 1, candidate selected: Candidate passes task, capability, and quota checks, winning on configured cost. LoadRoutingConfigContext loads .config/models/routing.yaml; hasRoutingTag finds candidate tiers. hasTaskCapabilities verifies all requested capabilities are declared on candidate models. quotaEligible checks projected RPM, TPM, and ensures no active 30s 429 cooldown. betterTaskRoute compares estimated token costs to find the lowest-cost candidate. gpt-oss-small wins with lowest configured cost ($0) and ID tie-break; TaskRoute is returned.
- Scenario 2, capability missing: A candidate lacking requested capabilities is excluded by hasTaskCapabilities. The request requires specific capabilities: tools, json. hasTaskCapabilities checks each model in the tier against required capabilities. Candidate missing requested capabilities is excluded from further evaluation.
- Scenario 3, limit or cooldown: A candidate exceeding projected capacity or in 429 cooldown is skipped. Candidate matches declared target task and requested capabilities. projectTaskCapacity projects +1 RPM and request TPM against exhaustion threshold. quotaEligible returns false; over-limit or cooling candidate is skipped.
- (Shortened to 2500 characters; docs/figures/model-routing.ts holds the full content.)
The CLI calls LoadRoutingConfigContext, optionally LoadUsageSnapshot and
TrackerFromSnapshot, then ModelCapacityArbiter.SelectForTask. This is a real
consumer of the Go router. There is no MCP route tool or scheduler dispatch
connection in this slice. The MCP standards_dogfood_repair_status tool consumes
the same advisory selection through repair planning, so oversized requests block
repair admission there too. The older SelectModel tier-cascade and
SelectOrthogonalAuditor helpers still exist, but have no production consumer.
They do not establish that a running agent harness enforces their policies.
Select a declared task
Run from the Praetor checkout:
go run ./cmd/standardsctl models route \
--task=feature_implementation --input-tokens=1000 --output-tokens=500
--config selects another routing file. --capabilities accepts a comma-separated
list of required labels. Every requested label must appear in that model's
capabilities list; a missing list grants no capabilities. No model ID, family,
parameter count or historical benchmark score implies eligibility. The existing
catalog has task assignments but no per-model capability declarations, so a
capability-constrained request requires explicit configuration first.
The task must occur in a tier's target_tasks. All models in all tiers explicitly
declaring that task are considered. A cheaper model in an undeclared tier remains
ineligible, including a tier named by the legacy fallback_tier field. No eligible
candidate is an error, never a silent downgrade or automatic escalation. The same
target_tasks label also selects the text register and the optional output budget that
models route reports; the register never changes the tier
(see the text register guide).
For eligible candidates the configured estimate is:
Both prices must be explicit finite nonnegative numbers. Explicit zero is allowed;
missing or null rates cannot win as free models. Rates must use a common currency
or unit; no currency conversion is implemented. Token estimates are optional. With
neither given, the route is tier-only: estimated_cost is 0 and candidates rank by
the sum of their two per-million rates (basis says so), pinned by
TestTierOnlyRouteRanksByRatesWithoutEstimates. Each given estimate still drives
cost and the quota checks. Equal ranks resolve by lexicographic model ID, then tier
name, independently of map or list iteration order.
The JSON result includes the exact loaded configuration SHA256, selected model,
task/capabilities, token estimates, configured cost and capacity observation status.
It also includes register_manifest_sha256, binding the reported register row to the exact
repository manifest bytes used for resolution.
This work does not update the repository's existing model IDs, task presets or
rates. An existing zero rate is a declaration, not proof that the endpoint exists
or that running it has no resource cost.
Explicit capacity observations
Without --usage, the CLI uses an empty in-process tracker and reports
capacity_source: "unobserved", capacity_observed: false, with no recorded
headroom value. quota_limits_known says whether both configured quota dimensions
are positive. When both are known, projected_headroom describes utilization after
the request, using zero as a provisional starting counter when observations are
absent. These fields do not turn an unobserved selection into a live quota claim.
To use actual observations collected separately:
go run ./cmd/standardsctl models route \
--task=feature_implementation --input-tokens=1000 --output-tokens=500 \
--usage=/path/to/observed-usage.json
A snapshot is a JSON object with exactly these fields:
| Field | Required data |
|---|---|
version |
Integer 1 |
captured_at |
Nonzero RFC3339 timestamp of the observations |
models |
Object keyed by configured model IDs |
Each model's current_rpm |
Explicit nonnegative integer request counter |
Each model's current_tpm |
Explicit nonnegative integer token counter |
A model observation may also include nonnegative total_spend, nonnegative integer
error_count, RFC3339 last_429_time, and RFC3339 observed_at.
An omitted observation time inherits the snapshot's captured_at; importing a
snapshot never refreshes its observation time. Those fields must use their exact names.
Duplicate keys, field-name aliases, unknown fields/models, null observations and
missing counters fail. Do not fill absent observations with invented zeroes.
When --usage is supplied, unobserved models and models lacking a positive RPM or
TPM limit cannot be selected. Programmatic callers obtain the same behavior with
TaskRequest.RequireObservedCapacity.
governance.quota_reading_max_age sets the maximum quota observation age as a
positive duration (for example, 10m, the default when omitted from YAML).
Programmatic configurations must set Governance.QuotaReadingMaxAge explicitly.
A reading exactly at the maximum age remains eligible; older readings, missing
observation times and future timestamps are refused. A stale reading is refused
even during provisional selection. CalculateHeadroom, SelectModel and
orthogonal auditor selection require a reading; task routing requires one when
RequireObservedCapacity is set. An absent reading can still support the
provisional task selection described above when that flag is false.
exhaustion_threshold_percent must be between 0 and 85 inclusive, retaining at
least 15 percent of every configured provider quota dimension. Configuration
validation rejects 86 and higher. Both RPM and TPM must fit the threshold.
Admission adds one request and the sum of both token estimates before checking
each known limit against exhaustion_threshold_percent. At 80 percent, 7 recorded
requests against a 10 RPM limit admits one more; 8 does not. Similarly, 700 recorded
tokens against a 1,000 TPM limit admits 60 input plus 40 output tokens, but rejects
60 plus 41. Integer additions are checked before mutation, and threshold comparisons
use the exact configured numeric value without rounding large counters through
floating point. Headroom floats are diagnostics only.
A configured zero RPM or TPM limit disables that dimension in provisional selection; it does not prove an unlimited provider quota. A recorded 429 triggers the existing 30-second cooldown. Fully exhausted or cooling models remain ineligible. Orthogonal selection uses the configured threshold and requires positive headroom.
SelectModel returns the selected endpoint descriptor with Skipped candidates
using the shared RouteSkip type. An exhausted
tier cascades to its configured fallback. Stale, missing, unreadable, cooling and
over-threshold readings carry explicit reasons in selection results or errors;
task routes expose the same exclusions in skipped.
These are historical counter checks with an age limit; rolling-window expiry is
not inferred. Actual RecordUsage and known reservation completions timestamp
their observations; reservations, unknown completions and 429 events preserve
the preceding quota observation time. total_spend and error_count
do not produce a latency, success-rate or error-rate score. Advisory selection uses
one coherent tracker snapshot and does not reserve anything.
Atomic reservations within a process
The Go API adds arbiter.ReserveForTask(ctx, request) (*TaskReservation, error) and
tracker.FinishReservation(reservation, actual) error. A successful reservation
selects and charges +1 RPM, estimated input+output TPM, and configured cost under
one tracker lock. It enforces a positive max_concurrent_same_model; zero means
reservation concurrency has not been configured and fails. Active reservations
also affect advisory selection and headroom reads. Share the same LimitTracker
between dispatchers and keep their routing configuration immutable while in use.
The opaque handle's Route() returns a detached description. Every successful
reservation must be finished, including failed or cancelled dispatches; defer the
cleanup in the owning runner and finish only after execution has actually stopped.
Admission cancellation and declined requests write no counters or handles.
Finishing does not require a context, so a cancelled request can still release its
slot. No background task, provider request or expiry timer is started by this API.
Pass &router.ReservationUsage{Tokens: actualTokens, Cost: actualCost} when actual
usage is known, including an explicit zero. Completion replaces the estimated
tokens and cost, keeps the one RPM charge, and releases the active slot exactly
once. Pass nil when actual usage is unknown: the charge stays conservative and
the slot is released. Do not also call RecordUsage for that request; that older
API is for independent consumption. Invalid actual usage leaves the handle active
for correction or a final nil completion. Duplicate, copied and foreign handles
are rejected without changing accounting.
Counters created only by estimated charges do not become observations, including
after nil completion. ObservedUsage returns charged counters plus a boolean
indicating whether supplied or actual usage exists; that boolean does not certify
every charge as measured. A lone 429 event records cooldown, not measured quota
counters. Accounting overflow saturates the affected counter,
blocks subsequent admission for that model, releases the finishing slot and returns
an explicit error. Later completions cannot reclaim saturated accounting. Legacy
RecordUsage cannot wrap counters or reopen a blocked model.
One tracker admits at most 1,024 active handles and cannot add new reservation model
identities beyond 1,024 tracked models. Counters are cumulative until a caller
deliberately builds a new tracker from independent observations. No automatic
reset is safe while reservations remain active. Separate processes, trackers or
CLI invocations do not share this state: this is not fleet persistence, provider
availability checking, or scheduler integration. models route remains advisory.
Maintain the catalog with models sync
praetorctl models sync merges the built-in seed list into routing.yaml. With
discovery on, which is the default, it also adds the models installed on local
Ollama and vLLM endpoints, so a model you pull or serve becomes routable.
praetorctl models sync # seed list plus this machine's local models
praetorctl models sync --discover-local=false # seed list only; no daemon needed
praetorctl models sync --prune # remove flagged retired seed entries
Discovery queries every endpoint in --local-endpoints (default
http://localhost:11434,http://localhost:8000), whatever its port. Each endpoint is
asked for Ollama's /api/tags first and, when that path answers 404, for the
OpenAI-compatible /v1/models that vLLM serves (internal/router/discovery.go). An
endpoint that does not answer is listed as Endpoint skipped and the sync goes on
with the others, keeping that endpoint's catalogued entries. A --prune run refuses
instead, because it refuses to prune from a partial local inventory (internal/router/sync.go:386).
TestDiscoverLocalModelsIsolatesFailingEndpoints and TestSyncCatalogIsolatesFailedDiscovery pin this.
A new entry's tier comes from the parameter-count tag in its ID (7b, 1.5b, 235b,
8x7b): below 5 billion is nano, below 20 lightweight, up to 35 midweight, and
larger heavy-frontier. Only an ID without a size tag falls back to name tags such as
qwen3 or haiku (ClassifyTier, pinned by TestClassifyTierSizeBoundaries). Its
family is the training lineage the name starts with, read after the last / of a
hosted path, so hf.co/<org>/Qwythos-9B-Claude-... is not anthropic; open weights
of a vendor with a hosted API in the catalog (gemma, gpt-oss, qwen) share that
vendor's family, because the orthogonal auditor guards against correlated errors,
which hosting does not change (DetectFamily, TestDetectFamilyMatchesVendorPrefixOnly).
Each model entry records who owns it in source:
source |
Written by | What a sync does with it |
|---|---|---|
seed |
the seed list in internal/router/sync.go |
rewrites it in place from the seed list; retired seed entries leave with --prune |
local |
discovery against a local Ollama or vLLM endpoint | keeps it; a rediscovered ID is never added twice |
| absent | an operator editing the file | keeps it |
Without --prune, a sync never removes an entry. Entries the seed list does not own
stay in their tier and order, and tiers the defaults do not define stay with their
descriptions and task labels. An installed model that is also a seed entry keeps its
seed entry instead of appearing twice. If a sync would still lose an entry, which
happens when the seed list drops a model the catalog marks source: seed, it writes
nothing and exits with an error that lists the IDs and names --prune.
--prune removes only flagged seed entries (source: seed) that are retired;
hand-declared entries, alias entries and local models are preserved as operator data,
and the command prints each removed ID.
A catalog that does not load, for example with a duplicate ID or an unknown source
value, is refused rather than overwritten; repair or delete it first. The write is
bound to the bytes the sync read: contextopt.ReplaceSnapshot checks them again
before replacing the file, so an edit made while the sync runs fails it instead of
being lost, apart from a writer that races that final check.
Governance and the default tiers' metadata follow the same ownership rule as model
entries, with or without --prune: each governance key and each default tier's
description, target_tasks, fallback_tier and lane that the file declares is kept, an
explicit false, zero or empty list included, and only an undeclared key takes the
built-in default (internal/router/sync_settings.go). To return a setting to its
default, delete the key and sync. internal/router/sync.go:458 preserves unowned tiers
and internal/router/sync.go:465 preserves hand-declared models across prune, so a declared
fallback to an operator tier is no longer dropped or left dangling by prune (internal/router/sync_test.go:289).
The behavior is pinned by internal/router/sync_test.go and
cmd/standardsctl/models_sync_test.go.
Nightly catalog check
.github/workflows/sync-models.yml runs the offline sync every night and fails when
the result differs from the tracked catalog, which means a seed-list change landed
without a regenerated routing.yaml. The job has read-only repository access and
never commits. main requires a pull request, signed commits and passing checks with
no bypass actor, so a bot push cannot land there, and this repository does not let
GITHUB_TOKEN open pull requests. To clear the failure, run praetorctl models sync
and land the result in a pull request.
Gateway aliases
An OpenAI-compatible gateway can serve router aliases (classes such as light, coding, reasoning or auto) and refuse the concrete model IDs its key cannot use, so a pinned ID the catalog lists may fail there. A catalog entry can therefore name an alias instead:
gateway:
address: https://gateway.example.com/v1 # yours; the repository ships none
key_env: PRAETOR_GATEWAY_API_KEY # variable holding the bearer key; never stored
tiers:
lightweight:
models:
- {id: gateway-light, family: openai, provider: gateway, alias: light,
cost_per_m_in: 0, cost_per_m_out: 0}
An alias entry needs a gateway section, and id stays the unique catalog key
(internal/router/gateway.go). The provider field is optional metadata; the router
sends the alias verbatim as the model name in gateway chat completions and expands
{target} to the bare alias in lane commands. A model declaring provider without an
alias is refused at config load.
The key_env setting must name an environment variable starting with PRAETOR_GATEWAY_
followed by at least one character of [A-Z0-9_]. A single constant validated at config load
enforces this allow-list and the refusal names the rule (e.g. GITHUB_TOKEN is refused).
Nothing in code, defaults or docs names a gateway: the adopter configures its address and aliases.
models sync makes one bounded call per alias entry (a one-token chat completion, 10
second deadline) and records alias_status: answers or unanswered with the gateway's
refusal in alias_reason. It does not trust the gateway's model listing, which can list
more than the key may use. A probe that cannot run, such as an unset key_env variable,
changes nothing and is reported as Alias not probed, as is a transport failure or an HTTP
408, 429 or 5xx answer, which say nothing about the alias; any other refusal is recorded
as unanswered. The probe is opt-in (--probe-aliases) because it sends the value of
key_env as a bearer token to the declared address: the address must be https unless
it is a loopback host, and a change to the gateway section in a pull request needs the
same review as a change to a CI secret binding. The route then applies three rules:
- An alias entry is a candidate only while its status is
answers. Anunansweredor never probed alias is skipped and listed in the result'sskippedarray with the reason (alias has not been probed; run models sync --probe-aliases). - Alias exclusion is per tier. A tier holding at least one alias entry excludes its pinned
non-local models, however cheap and whatever the probes said, because the gateway refuses
concrete IDs; only models from a local runtime (
source: local) stay, since they never pass through the gateway. A tier with no alias entry keeps its pinned models. When none of the aliases in an alias-holding tier answers, that tier fails closed with the skipped reasons. - The harness is told the alias, never a model ID behind it.
TestRouteNeverFallsBackToPinnedModelWhenGatewayServesAliases,
TestRouteExcludesPinnedModelsWhenNoAliasAnswers and
TestProbeAliasesRecordsAnswerAndReason pin these.
Lanes and outcomes
A lane says how a pick is executed. One lanes table in routing.yaml, read by the
same loader as the rest, maps a lane name to its harness and headless command, and a
tier or model names its lane (lane:; a model's own lane wins):
lanes:
gateway-coding:
harness: coding-harness
command: [coding-harness, run, --model, "{target}", --task, "{task}"]
tiers:
lightweight:
lane: gateway-coding
The command is an argument vector, never a shell line. {target} expands to the
alias of an alias entry or the model ID, {task} to the label; each stays one
argument whatever it contains, and any other {name} is refused at load. The
harness may be a local or gateway model through a headless coding harness, a
free-tier CLI or the frontier agent; Praetor ships no lane, because the command lines
are the adopter's.
models route --task <label> returns lane with name, harness, target and the
exact command. A pick with no lane declared returns lane_note instead of a guess.
TestEveryDeclaredLabelRoutesToAnExecutableLane routes every declared label.
Subscription windows
A subscription lane (a CLI on a flat-rate plan) shares one rolling usage window with every
session and subagent on its account. When the window runs out, all of them stop at once.
The window governor (internal/router/window.go, #1142) refuses new work on such a lane
before that happens, and tells admitted work how fast it may spend.
A lane becomes governed when it names an account, and the account names the snapshot record that holds its window:
lanes:
claude:
harness: claude-code
command: [claude, -p, "{task}"]
account: max-a
governance:
window:
warn_percent: 65 # default 65
stop_percent: 80 # default 80; 0 < warn < stop <= 100
max_reading_age_seconds: 600 # default 600
stop_horizon_minutes: 30 # default 30
per_agent_percent_per_hour: 0 # burn floor per active agent; 0 turns it off
accounts:
max-a: {window_record: claude-code/five_hour}
Readings come from a tribunus snapshot (tribunusctl sync --out): a record with
access_path: subscription_cli and a usage_window (used_percent, resets_at, and,
when the source measures them, burn_percent_per_hour and active_agents). The
record's provenance gives the source and the age. praetorctl models route --windows
<snapshot> decides each governed candidate:
| Level | When | Admission |
|---|---|---|
unknown |
no snapshot, no record, a reading older than the maximum age or dated in the future, no used_percent, or a reset already past |
refused |
stop |
used at or above the stop line, or the projected burn reaches it within the horizon and before the reset | refused |
warn |
used at or above the warn line, or the projected burn reaches the stop line before the reset | admitted, with advice to finish the unit and checkpoint |
ok |
otherwise | admitted |
The projected burn is the larger of the measured burn and active agents times the
per-agent floor. The floor covers the minutes after a reset, when many agents wake
together and the measured burn still reads low. A refused candidate appears in
skipped with a reason that names the window, and the route falls through to
ungoverned lanes. With none left, the command fails with the existing no-eligible-model
error. An admitted pick carries window, the pacing signal: the level,
used_percent, resets_at, projected_burn_percent_per_hour and
pace_percent_per_agent_hour, which is the burn each agent may spend so the fleet
reaches the stop line no earlier than the reset.
The tests in internal/router/window_test.go replay the acceptance cases of #1142, the
warn and stop boundaries, and the agent-scaling fixture from the issue thread.
Two sources write window records today: codex-local
(tribunus/internal/sources/codexlocal), which copies the provider's own
used_percent, and claude-local (tribunus/internal/sources/claudelocal), which
derives one from the Claude Code session logs (tribunusctl sync --sources=claude-local
[--claude-projects-dir <dir>]). A lane on any other client stays unknown until a
source writes its records.
claude-local reads only usage fields: per-response token counts, the model, the
timestamp, and the quotaLimits of rejected requests. Message content is never
decoded. It reports claude-code/five_hour, claude-code/seven_day, and every other
rateLimitType a rejection names (a suffix such as seven_day_opus counts only that
model family). For each window:
- Usage is priced at first-party API rates (cost-equivalent) and counted once per response.
- The ceiling is the smallest usage a fully logged window held at its first rejected request.
- The reset is the last reported
resetsAt, advanced in whole window steps. used_percentis the current window's usage over the ceiling.burn_percent_per_hourandactive_agentscover the last 15 minutes.
Without a ceiling (no rejection in the logs and none stored), or with usage of a
model the rate table lacks, the record has no used_percent and says why in absent,
so the governor refuses as unknown. Files last written more than seven days ago are not
read.
A ceiling outlives the logs it came from. sync keeps one store per account,
claude-ceilings/<account>.json below --claude-state-dir. The default state dir is
praetor/tribunus under the user's state folder: $XDG_STATE_HOME or
~/.local/state, %LOCALAPPDATA% on Windows. The store records each window's
ceiling, the rejection that set it (time and log file), the reset it reported, and
when it was recorded.
- A rejection in the logs newer than the stored one replaces the stored entry.
- A stored ceiling serves after a reset or a restart until its rejection is older than
--claude-ceiling-max-age(default 720h). After that the window is unknown again. - The store is written whole to
<file>.newand renamed into place. - A store that does not decode or validate fails the source; it is never repaired or overwritten.
tribunusctl claude-limits prints how often each rateLimitType and quotaLimits
status value occurs in all session logs, and nothing else. On the office workstation
on 2026-10-13 it found five_hour 148 times, all rejected, across 742 files; the
weekly names (seven_day, seven_day_<family>) were not observed there. The tests in
tribunus/internal/sources/claudelocal use synthetic logs only;
tribunus/cmd/tribunusctl/sync_windows_test.go runs a produced snapshot through the
governor.
praetorctl models outcome --task <label> --target <t> --result ok|fail|timeout
[--lane <name>] [--duration-ms n] [--note text] [identity flags] appends one
record to .workingdir/routing/outcomes.jsonl (--outcome-log changes it), a
private JSON Lines log that is never rewritten. It is the measured routing data
the efficiency ledger reads through router.ReadOutcomes, which fails on a record
it cannot decode instead of averaging over the rest. The router does not dispatch:
the caller that runs the command records how it ended.
Every outcome record requires a verified run identity:
--physical-model <id>(always required): the model that ran, for exampleclaude-3-7-sonnet-20250219. The target is never copied into it, because a gateway alias such ascordana-codingneed not be declared in the catalog. When the routing catalog (--config, default.config/models/routing.yaml) loads, a value naming any of its aliases, or the catalog ID of any alias entry, is refused. When no catalog is found the command says on stderr that the value was not checked. For a pinned target, pass the target or the model the provider resolved it to.--harness <name>and--harness-version <version>: harness that ran the task.--prompt-digest <sha256:...>: SHA-256 digest of prompt template or brief.--context-digest <sha256:...>: SHA-256 digest of compiled context.--context-bytes <n>: byte size of compiled context.--tools <list>: comma-separated tool names available during the run, at mostrouter.MaxIdentityTools(4096). The record keepstool_set_digest(SHA-256 over the sorted names, one per line, so listing order does not matter) andtool_count.--record-tool-listkeeps the full list as well; a record that then exceeds the 4096-byte line bound is refused with that reason.--rounds <n>and--retries <n>: interaction rounds and prior retries.--cost-estimate <dollars>and--actual-cost <dollars>: pre-dispatch cost estimate and post-run measured cost. An omitted flag leaves the field absent;--cost-estimate=0records a measured zero.
An outcome record without mandatory identity fields is refused by ValidateOutcome.
Two runs differing only in prompt template produce distinct identity keys (Key()).
estimate_error (actual_cost - cost_estimate) is set only when both costs are
present. After recording, the command prints it to stderr per lane
(estimate-error [<lane>]: ...), or not measured when either cost is missing.
--reconcile prints one line per lane across the log. Its sums cover measured runs
only, that is, runs with a run identity, an estimate and an actual cost
(Outcome.MeasuredCosts, router.CostTally); the efficiency ledger sums units by
the same rule.
Catalog freshness
Every entry may carry preview: true and an as_of date (YYYY-MM-DD) for when its data
was written or confirmed; a model ID containing preview counts as preview. models
sync lists each preview entry and each entry whose as_of is older than
governance.catalog_max_age_days (default 180) as Catalog stale. An answering alias
probe sets as_of to the sync date and a discovered local model gets the sync date. An
entry without as_of, and an entry owned by the seed list (source: seed), is not
judged on age: the seed list ships inside the binary, so a fixed date would turn the
audit red on that date with no change in the repository. The preview check applies to
every entry, seed included, so the seed prices are only as current as the binary.
praetorctl audit runs the same check over .config/models/routing.yaml
(auditModelCatalog) and fails on any finding; a repository without that file skips it,
saying so, and a file that does not load fails. Under the operator-data rule (operator data
is configured, never deleted), praetorctl models sync --prune removes only entries with
source: seed that the freshness check flags (for example retired preview models); it never
removes hand-declared or alias entries, and never touches the gateway or lanes sections. A
plain models sync refuses to remove flagged seed entries without --prune. The audit
remedy names each flagged hand or alias entry for the operator to refresh (as_of) or remove
by hand; retired seed entries leave with models sync --prune --discover-local=false or by hand. The seed list no longer carries the three
preview models it once did. Tests: TestAuditModelCatalogFailsStaleAndPreviewEntries,
TestAuditModelCatalogWindowBoundary, TestSyncProbesAliasesAndReportsStalePreviewEntries,
TestSyncPruneRemovesOnlyFlaggedSeedEntries.
Bounds and configuration migration
Configuration and snapshot files must be regular files no larger than 1 MiB. Routing accepts version 1, up to 16 tiers, 64 models per tier, 64 task/capability labels per list, 256 bytes per name and 1,024 snapshot model entries. Model IDs are globally unique. Each token estimate is bounded at 1,000,000,000; that is an input safety bound, not an asserted model context-window limit. Overflowing cost calculations fail. Reads and task selection accept caller cancellation.
Migration: the shared loader used by models list and models route now rejects
missing/null prices, unknown fields, duplicate IDs/tags, unsupported versions,
invalid bounds and nonregular input paths. Provide both numeric cost fields for
each model, remove misspelled/unknown keys, and use a regular config file. Existing
shipped routing data and models sync output already include both prices. This
shared validation prevents listing one ambiguous file as free while routing treats
it differently. Programmatically constructed task-routing descriptors must set
CostRatesDeclared when both configured rates are intentional.
Migration: praetorctl audit now fails on preview or stale catalog entries (auditModelCatalog).
Retired seed previews must be removed by hand or with praetorctl models sync --prune --discover-local=false.
A non-loopback HTTP gateway address is refused; use HTTPS for external gateways. Gateway alias probing
is opt-in via --probe-aliases. Gateway key_env must begin with the PRAETOR_GATEWAY_ prefix followed
by at least one character of [A-Z0-9_]. Alias exclusion is scoped per tier rather than catalog-wide, so
tiers holding alias entries exclude pinned non-local models and fail closed if none answer, while tiers
without alias entries retain pinned models. models sync --prune removes only flagged seed entries
(source: seed) that are retired; hand-declared entries, alias entries, local models, and gateway/lanes
configurations are preserved as operator data. Flagged hand or alias entries must be refreshed (as_of)
or removed by hand as reported by praetorctl audit.
Migration: model entries accept an optional source field whose only values are
seed and local; any other value is rejected. models sync now merges instead of
rewriting, and removes entries only with --prune. A script that relied on a plain
models sync to discard entries must pass --prune. A catalog committed with a
duplicate ID, which earlier sync runs could produce by listing an installed seed model
twice, no longer loads; delete the second copy.
Migration: requests that cross a configured RPM/TPM threshold now fail before
selection, including oversized requests against an empty tracker. Supply realistic
token estimates and explicit applicable limits. --usage and
RequireObservedCapacity additionally require positive limits for both dimensions.
Consumers may read the additive quota_limits_known and projected_headroom
fields; existing recorded_headroom remains the pre-request counter diagnostic.
Dispatch consumers must opt into the reservation API, configure positive concurrency,
and finish every successful handle; calling advisory selection alone does not
enforce concurrency.
Migration: outcome records now require mandatory run-identity fields (physical
model, harness, version, prompt digest, context digest), and every models outcome
call needs --physical-model, whatever the target. Pre-existing outcomes.jsonl
logs written before run identity stay readable: ReadOutcomes skips the identity
checks only for a record that carries none of the run-identity fields
(identity, identity_key, resolved_model, actual_cost, estimate_error,
branch). Such a record counts as a run in lane reconciliation but never as a
measured cost. Any record with one of those fields is validated in full.
Remaining dispatch and feedback work
Automatic agent dispatch, shared fleet reservations, feeding the
outcome log back into selection, quality calibration, retry/escalation policy and
cross-model review orchestration remain unimplemented integrations. The declared
orthogonal_audit_required setting does not establish scheduler enforcement.
The concurrency setting is enforced only by callers using the shared tracker
reservation API. The standalone .config/agent/hooks/pre_agent_dispatch.py
script has no verified hook registration here; this command does not invoke it.
models list displays routing configuration. The separate models sync path
merges a built-in catalog and can query configured local Ollama endpoints (see
Maintain the catalog with models sync).
It is not called by models route. There is no implemented upstream gateway
pricing synchronization in this path, and the catalog it merges into is the routing file
itself (.config/models/routing.yaml, the --config default), not a separate catalog file.
Future work must connect execution and real observation sources explicitly before
claiming live fanout or evidence-driven escalation.