Model Registry¶
The model registry lets transports advertise which models they support and
what each model can do. This enables capability-based model selection and
cost-aware routing. The same page covers Usage, the token counts a turn
reports back. See Token accounting.
ModelSpec¶
A frozen dataclass describing a single model:
from dataclasses import dataclass
from axio.models import Capability
@dataclass(frozen=True, slots=True)
class ModelSpec:
id: str
capabilities: frozenset[Capability] = frozenset()
max_output_tokens: int = 8192
context_window: int = 128000
input_cost: float = 0.0
output_cost: float = 0.0
ModelSpec fixes no unit for input_cost and output_cost. Whoever fills the
registry decides. Every registry that ships with axio uses price per million
tokens, so input_cost=3.0 reads as $3 per million input tokens.
Capability¶
Models declare their capabilities via a StrEnum:
from enum import StrEnum
class Capability(StrEnum):
# Input modalities
text = "text"
vision = "vision"
video = "video"
audio = "audio"
# Output modalities
image_generation = "image_generation"
video_generation = "video_generation"
# Processing capabilities
reasoning = "reasoning"
tool_use = "tool_use"
json_mode = "json_mode"
structured_outputs = "structured_outputs"
embedding = "embedding"
Capability |
Meaning |
|---|---|
|
Accepts text input |
|
Accepts image input |
|
Accepts video input |
|
Accepts audio input |
|
Can generate images |
|
Can generate videos |
|
Supports extended thinking / chain-of-thought |
|
Supports function / tool calling |
|
Can be forced to output JSON |
|
Supports schema-constrained structured outputs |
|
Can produce embedding vectors |
ModelRegistry¶
A dict-like container for ModelSpec values with powerful query methods:
from axio.models import ModelRegistry, ModelSpec, Capability
registry = ModelRegistry()
registry["gpt-4o"] = ModelSpec(
id="gpt-4o",
capabilities=frozenset({Capability.text, Capability.vision, Capability.tool_use}),
context_window=128000,
input_cost=2.50,
output_cost=10.00,
)
Query methods¶
All query methods return a new ModelRegistry, so they can be chained:
by_prefix(prefix)Filter models whose ID starts with a prefix.
assert "gpt-4o" in registry.by_prefix("gpt-4").ids()
by_capability(*caps)Keep only models that have all specified capabilities.
assert "gpt-4o" in registry.by_capability(Capability.vision, Capability.tool_use).ids()
search(*q)Keep models whose ID contains all query substrings.
assert "gpt-4o" in registry.search("gpt", "4o").ids()
by_cost(*, output=False, desc=False)Sort by input cost (default) or output cost, ascending or descending.
cheapest = registry.by_cost() # cheapest input first priciest = registry.by_cost(desc=True) # most expensive first assert cheapest.ids() == priciest.ids()[::-1]
ids()Return a plain list of model ID strings.
assert registry.by_capability(Capability.vision).ids() == ["gpt-4o"]
Chaining example¶
# Find the cheapest vision-capable model with tool use
model = (
registry
.by_capability(Capability.vision, Capability.tool_use)
.by_cost()
.ids()[0]
)
assert model == "gpt-4o"
Token accounting¶
Usage is what a turn cost in tokens. It arrives on IterationEnd.usage for a
single provider request and on SessionEndEvent.total_usage for the whole run:
from dataclasses import dataclass, field
@dataclass(frozen=True, slots=True)
class Usage:
input_tokens: int
output_tokens: int
cache_read_tokens: int = field(default=0, kw_only=True)
cache_write_tokens: int = field(default=0, kw_only=True)
reasoning_tokens: int = field(default=0, kw_only=True)
The rule¶
input_tokens and output_tokens are always inclusive grand totals. Every
other field is a disjoint slice of one of them:
cache_read_tokens + cache_write_tokens <= input_tokens
reasoning_tokens <= output_tokens
Providers disagree about whether their own headline number already contains the slices, and they disagree in opposite directions. Anthropic counts only the tokens after the last cache breakpoint, so its transport adds the cache counts back. Without that, a cached 100k prompt reports as the handful of tokens that followed the breakpoint. Google reports thinking beside the candidates rather than inside them, and tool-use prompt tokens outside the prompt count. Its transport adds both in. The OpenAI Responses API and chat completions both nest their slices inside their totals already, so those paths add nothing.
Each transport converts into the rule, so nothing downstream has to know which
provider answered. This is also the contract a new transport must satisfy. It is
the reason a new Usage field has to be a slice of one of the two totals,
rather than a sixth number beside them.
Derived properties¶
from axio import Usage
u = Usage(
input_tokens=1000,
output_tokens=400,
cache_read_tokens=800,
cache_write_tokens=50,
reasoning_tokens=300,
)
assert u.total_tokens == 1400 # input + output
assert u.uncached_input_tokens == 150 # input - cache_read - cache_write
assert u.answer_tokens == 100 # output - reasoning
Usage supports +, and every field adds. A total accumulated across
iterations keeps its slices:
total = Usage(10, 5, reasoning_tokens=4) + Usage(20, 8, cache_read_tokens=6)
assert (total.input_tokens, total.output_tokens) == (30, 13)
assert (total.reasoning_tokens, total.cache_read_tokens) == (4, 6)
Counts, never money¶
Usage reports tokens and nothing else. A cached token and a written one bill
at different multipliers. ModelSpec carries only input_cost and
output_cost, with no cached-input, cache-write or reasoning rate in the
registry. A cached turn therefore cannot be priced from it. A caller that
wants cost multiplies each slice by its own per-model rates.
A zero slice means the provider billed none of it, or reported no breakdown at all. Axio cannot tell those apart and does not pretend to.
Agent capability checking¶
The agent reads Capability.tool_use from the active model before each
iteration. If the model lacks this capability, no tools are passed to the
transport:
model = getattr(transport, "model", None)
model_caps = getattr(model, "capabilities", None)
if model_caps is not None and Capability.tool_use not in model_caps:
active_tools = [] # embedding or image-gen models: no tools
This means you can safely point an Agent at an embedding or image-generation
model. It will not try to send tool definitions that the API would reject.