Model Routing
pip install arohaaroha.routing selects the best LLM for each call given a token budget, cost cap, quality bar, and task type — then cascades to a higher tier if observed quality falls short, learns from outcomes with a Beta bandit, and reports exact token and cost usage.
Overview
The routing stack is a six-algorithm pipeline that runs inside every ModelSession.chat() call:
ModelBudgetMandate governs which model is selected and how many tokens can be consumed. SpendingMandate (from aroha.mandate) governs payment authority between agents. Both can be held simultaneously.Quick start
# pip install aroha
import asyncio
from aroha.routing import ModelSession, ModelBudgetMandate, TaskType
from aroha.routing.providers.anthropic import AnthropicProvider
async def main():
provider = AnthropicProvider()
mandate = ModelBudgetMandate(
token_budget=4_000,
cost_budget_usd=0.05,
quality_threshold=0.85,
task_type=TaskType.CODE,
)
session = ModelSession.create(provider=provider, mandate=mandate)
response = await session.chat("Refactor this function to be async.")
await session.close()
print(response.content)
print(response.model_id) # e.g. "claude-sonnet-4-6"
print(session.tracker.summary())
asyncio.run(main())ModelBudgetMandate
Declares the constraints an agent places on model selection for a session. All routing algorithms read from this object — it is the single source of truth for budget, quality, and task requirements.
from aroha.routing import ModelBudgetMandate, TaskType
mandate = ModelBudgetMandate(
# Required
token_budget=8_000, # hard cap: input + output tokens for the session
cost_budget_usd=0.10, # hard cap: USD spend for the session
quality_threshold=0.85, # minimum quality in [0, 1]
task_type=TaskType.REASONING, # governs benchmark selection & scoring
# Optional — defaults shown
context_window_required=4_096, # model must support at least this many tokens
latency_budget_ms=30_000, # max latency per LLM call in ms
preferred_provider=None, # "anthropic" | "openai" | "google" | None
allow_cascade=True, # allow escalation when quality < threshold
max_cascade_steps=2, # max escalation hops (not counting initial)
expected_steps=1, # hint to BudgetAllocator for multi-step sessions
)Field reference
| Field | Type | Default | Description |
|---|---|---|---|
| token_budget | int | required | Hard cap on total tokens (input + output) for the session. |
| cost_budget_usd | float | required | Hard cap on USD spend. Enforced before each call. |
| quality_threshold | float | required | Minimum quality in [0, 1]. Models below this are filtered. |
| task_type | TaskType | required | Governs which benchmarks are used and how quality is scored. |
| context_window_required | int | 4096 | Minimum context window the model must support. |
| latency_budget_ms | int | 30000 | Maximum end-to-end latency per LLM call (ms). |
| preferred_provider | str | None | None | Restrict to one provider: 'anthropic', 'openai', or 'google'. |
| allow_cascade | bool | True | Whether the cascade controller may escalate to a pricier model. |
| max_cascade_steps | int | 2 | Maximum escalation hops (not counting the initial call). |
| expected_steps | int | 1 | Budget allocator hint: how many .chat() calls this session will make. |
| mandate_id | str | uuid4() | Auto-generated unique ID for this mandate instance. |
| registry_mandate_id | str | None | None | Links to a registry spending_budgets row for cross-service accounting. |
Derived helpers
# shadow_price — KKT dual variable: quality per dollar
# Cascade escalates only when ΔQuality / ΔCost >= shadow_price
mandate.shadow_price # -> quality_threshold / cost_budget_usd
# remaining() — narrows a mandate after consuming resources
narrowed = mandate.remaining(tokens_used=1200, cost_used=0.012)
# narrowed.token_budget = 6_800
# narrowed.cost_budget_usd = 0.088
# narrowed.expected_steps = max(1, original - 1)TaskType
Controls which benchmark suite is used when scoring model quality and which scoring heuristics QualityScorer applies.
ModelSession
The primary entry point. Use ModelSession.create() (not the constructor directly) — it wires the feedback client and pre-loads persisted learner state automatically.
ModelSession.create()
from aroha.routing import ModelSession, ModelBudgetMandate, TaskType, AllocatorMode
from aroha.routing.providers.anthropic import AnthropicProvider
session = ModelSession.create(
provider=AnthropicProvider(),
mandate=mandate,
# Optional
allocator_mode=AllocatorMode.THOMPSON, # budget allocation strategy
learner_path="/var/cache/aroha/learner.json", # persist learner across restarts
# Registry feedback loop (recommended in production)
registry_url="https://registry.aroha-labs.com",
registry_api_key="ak_...",
feedback_flush_every=20, # batch size before flushing to registry
)session.chat()
response = await session.chat(
prompt="Summarise the document above.",
system="You are a technical writer.", # optional system prompt
)
# ModelResponse fields:
response.content # str — generated text
response.model_id # str — model that produced the response
response.quality # float — observed quality in [0, 1]
response.input_tokens # int
response.output_tokens # int
response.cost_usd # float — cost of this specific call
response.n_escalations # int — cascade hops used (0 = no escalation needed)
response.success # bool — False when quality < threshold (best-effort)
response.total_tokens # property: input + outputLifecycle
# warm_from_registry() — pull crowd-sourced priors before first call
await session.warm_from_registry() # returns int: number of priors loaded
# close() — flush pending feedback signals and auto-save learner
await session.close()
# Always close() — especially important with registry_url set,
# to flush the last batch of quality signals.ModelSession per concurrent branch. Sequential steps within one session use session.step() instead.Multi-step sessions
When a session makes multiple LLM calls (retrieval, synthesis, summarisation), use session.step()to let the BudgetAllocator partition the remaining budget intelligently across steps.
mandate = ModelBudgetMandate(
token_budget=12_000,
cost_budget_usd=0.20,
quality_threshold=0.85,
task_type=TaskType.SUMMARIZATION,
expected_steps=3, # tell the allocator how many steps to expect
)
session = ModelSession.create(
provider=provider,
mandate=mandate,
allocator_mode=AllocatorMode.IMPORTANCE_WEIGHTED,
)
# Each step() yields a narrowed ModelBudgetMandate for that slice of the budget.
async with session.step("retrieval", importance_weight=1.0, total_weight=4.0) as m:
r1 = await session.chat("Extract key facts from this document.", step_mandate=m)
async with session.step("analysis", importance_weight=1.0, total_weight=4.0) as m:
r2 = await session.chat("Identify the main argument.", step_mandate=m)
async with session.step("synthesis", importance_weight=2.0, total_weight=4.0) as m:
r3 = await session.chat("Write a 200-word summary.", step_mandate=m)
await session.close()
print(session.tracker.summary())importance_weight / total_weight is only used in IMPORTANCE_WEIGHTED mode. In PROPORTIONAL mode the budget is split evenly. In THOMPSON mode the allocator samples from a Beta posterior over observed efficiency per step type.AllocatorMode
TokenTracker
Accessed via session.tracker. Records every LLM call and maintains running totals against the mandate limits.
tracker = session.tracker
# Running totals
tracker.tokens_used # int — total tokens consumed so far
tracker.tokens_remaining # int — remaining before token_budget is hit
tracker.cost_used # float — USD spent so far
tracker.cost_remaining # float — USD remaining
tracker.token_budget_exhausted # bool
tracker.cost_budget_exhausted # bool
# Per-call breakdown
for call in tracker.usage.calls:
print(call.model_id, call.input_tokens, call.output_tokens, call.cost_usd)
# Summary dict
tracker.summary()
# {
# "total_tokens": 1842,
# "tokens_remaining": 6158,
# "total_cost_usd": 0.01234567,
# "cost_remaining": 0.08765433,
# "n_calls": 3,
# "models_used": ["claude-haiku-4-5", "claude-sonnet-4-6"],
# }OnlineLearner
A Bayesian Beta bandit that maintains one posterior per (model_id, task_type, complexity_bucket) triple. The ModelSelector samples quality estimates from these posteriors when ranking candidates — giving models with consistent observed quality a tighter, higher bound than rarely-used models.
from aroha.routing.learner import OnlineLearner
from aroha.routing.catalog import BUILTIN_MODELS
# Share a learner across sessions for cross-session learning
learner = OnlineLearner(catalog=BUILTIN_MODELS)
session1 = ModelSession.create(provider, mandate1, learner=learner)
session2 = ModelSession.create(provider, mandate2, learner=learner)
# Persist to disk and reload
learner.auto_save("/var/cache/aroha/learner.json")
learner.load("/var/cache/aroha/learner.json")
# Inspect posteriors
posteriors = learner.posteriors() # dict[str, dict] keyed by model_id
# {
# "claude-sonnet-4-6": {
# ("code", 2): {"alpha": 14.3, "beta": 1.7, "mean_quality": 0.894},
# ...
# },
# ...
# }learner_path is passed to ModelSession.create(), the session loads and auto-saves the learner automatically — you do not need to call load() / auto_save() manually.RegistryFeedbackClient
Pushes batched quality signals to the Aroha registry and pulls crowd-sourced priors to warm a cold learner. Enabled by passing registry_url + registry_api_key to ModelSession.create().
# Full production setup with registry feedback loop
session = ModelSession.create(
provider=AnthropicProvider(),
mandate=mandate,
registry_url="https://registry.aroha-labs.com",
registry_api_key=os.environ["AROHA_API_KEY"],
learner_path="/var/cache/aroha/learner.json",
feedback_flush_every=20,
allocator_mode=AllocatorMode.THOMPSON,
)
# Warm learner from registry before first call (optional but recommended)
priors_loaded = await session.warm_from_registry()
print(f"Warmed with {priors_loaded} priors")
response = await session.chat("Classify this support ticket: ...")
# close() flushes any buffered feedback signals
await session.close()feedback_flush_every calls, or on session.close(). They are fire-and-forget — a failed flush does not raise an exception.Errors
from aroha.routing.mandate import BudgetInfeasibleError
try:
response = await session.chat("...")
except BudgetInfeasibleError as e:
print(e) # human-readable message
print(e.binding_constraint) # e.g. "quality_threshold", "context_window", "cost_budget_usd"
except RuntimeError as e:
# Raised when token_budget or cost_budget is already exhausted before the call
print(e)Built-in model catalog
The built-in catalog ships quality benchmarks (normalised from MMLU, HumanEval, HellaSwag, MBPP, MT-Bench) and pricing as of 2026-Q2. Prices can be overridden via the Aroha registry GET /v1/models.
| Model | Provider | Tier | Input $/MTok | Output $/MTok | Context | Latency (ms) |
|---|---|---|---|---|---|---|
| claude-haiku-4-5 | anthropic | NANO | $0.80 | $4.00 | 200K | 800 |
| claude-sonnet-4-6 | anthropic | MID | $3.00 | $15.00 | 200K | 1800 |
| claude-opus-4-8 | anthropic | FRONTIER | $15.00 | $75.00 | 200K | 4200 |
| gpt-4o-mini | openai | NANO | $0.15 | $0.60 | 128K | 700 |
| gpt-4o | openai | MID | $2.50 | $10.00 | 128K | 2000 |
| o3 | openai | FRONTIER | $10.00 | $40.00 | 200K | 6000 |
| gemini-2.0-flash | NANO | $0.10 | $0.40 | 1M | 600 | |
| gemini-2.0-pro | MID | $1.25 | $5.00 | 2M | 2200 |
from aroha.routing.catalog import BUILTIN_MODELS, get_catalog, get_model
# All models
for m in BUILTIN_MODELS:
print(m.model_id, m.quality_for(TaskType.CODE))
# Filtered view
anthropic_mid_plus = get_catalog(provider="anthropic", min_tier=ModelTier.MID)
# Lookup by ID
profile = get_model("claude-sonnet-4-6")
cost = profile.cost_estimate(input_tokens=500, output_tokens=200)