Model Routing

aroha.routing · v1.2.0 · Python 3.11+
Stable · v1.2.0pip install aroha

aroha.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:

1. ComplexityEstimatorScores prompt complexity in [0, 1] to inform model tier selection
2. ModelSelectorPicks the cheapest model satisfying the mandate constraints, using Thompson-sampled quality estimates
3. CascadeControllerRuns the selected model; escalates to the next tier if quality < threshold
4. TokenTrackerRecords exact token counts and costs per call; enforces hard budget limits
5. OnlineLearnerBayesian Beta bandit — updates per (model, task_type, complexity_bucket) from observed quality
6. RegistryFeedbackClientBatches quality signals and pushes crowd-sourced priors to/from the Aroha registry
Distinct from spending mandates. 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

FieldTypeDefaultDescription
token_budgetintrequiredHard cap on total tokens (input + output) for the session.
cost_budget_usdfloatrequiredHard cap on USD spend. Enforced before each call.
quality_thresholdfloatrequiredMinimum quality in [0, 1]. Models below this are filtered.
task_typeTaskTyperequiredGoverns which benchmarks are used and how quality is scored.
context_window_requiredint4096Minimum context window the model must support.
latency_budget_msint30000Maximum end-to-end latency per LLM call (ms).
preferred_providerstr | NoneNoneRestrict to one provider: 'anthropic', 'openai', or 'google'.
allow_cascadeboolTrueWhether the cascade controller may escalate to a pricier model.
max_cascade_stepsint2Maximum escalation hops (not counting the initial call).
expected_stepsint1Budget allocator hint: how many .chat() calls this session will make.
mandate_idstruuid4()Auto-generated unique ID for this mandate instance.
registry_mandate_idstr | NoneNoneLinks 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.

TaskType.REASONING"reasoning"Multi-step reasoning, logic, math. Benchmarks: MMLU, HellaSwag.
TaskType.CODE"code"Code generation, review, refactoring. Benchmarks: HumanEval, MBPP.
TaskType.EXTRACTION"extraction"Structured data extraction from unstructured text.
TaskType.CREATIVE"creative"Creative writing, brainstorming, open-ended generation.
TaskType.CLASSIFICATION"classification"Label assignment, intent detection, routing.
TaskType.SUMMARIZATION"summarization"Text compression, key-point extraction. Benchmarks: MT-Bench.

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 + output

Lifecycle

# 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.
Not thread-safe for concurrent .chat() calls. For parallel multi-step workloads, create one 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

AllocatorMode.PROPORTIONALDefault. budget_step_i = remaining / remaining_steps. Simple, no history needed. Good for homogeneous steps.
AllocatorMode.IMPORTANCE_WEIGHTEDbudget_step_i = remaining × (w_i / Σw_j). Caller assigns weights per step. Better when steps vary in expected complexity.
AllocatorMode.THOMPSONBayesian Beta bandit over step types. Learned from observed quality/cost efficiency. Degrades to PROPORTIONAL until 5 observations per arm.

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},
#     ...
#   },
#   ...
# }
When 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()
Quality signals are buffered in memory and flushed every 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)
BudgetInfeasibleErrorNo model in the catalog satisfies all mandate constraints. binding_constraint names the first eliminating constraint.
RuntimeErrorsession.chat() called after token_budget or cost_budget_usd is exhausted. Check tracker before calling.

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.

ModelProviderTierInput $/MTokOutput $/MTokContextLatency (ms)
claude-haiku-4-5anthropicNANO$0.80$4.00200K800
claude-sonnet-4-6anthropicMID$3.00$15.00200K1800
claude-opus-4-8anthropicFRONTIER$15.00$75.00200K4200
gpt-4o-miniopenaiNANO$0.15$0.60128K700
gpt-4oopenaiMID$2.50$10.00128K2000
o3openaiFRONTIER$10.00$40.00200K6000
gemini-2.0-flashgoogleNANO$0.10$0.401M600
gemini-2.0-progoogleMID$1.25$5.002M2200
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)