"""Model capability tiers for the LLM classifier.
Decouples consumer code from the shifting sands of model IDs at each
provider. The operator picks a *capability tier* (``LOW`` /
``MEDIUM`` / ``HIGH``) and a *provider* (``claude`` / ``antigravity`` /
``codex``); :func:`catalogue` returns the current
:class:`ModelProfile` whose ``model_id`` meets the tier floor for
that provider.
Rationale:
- Model IDs change every few months (``sonnet-4.5``, ``gemini-3-pro``,
``o3`` -> ``o4``). Hard-coding IDs in CLI flags forces consumers to
chase them. A stable tier + alias table is a better interface.
- Thinking models (multi-step reasoning, chain-of-thought) classify
ambiguous transactions more accurately than single-shot models but
cost more tokens. Surfacing the :class:`ModelCapability` axis lets
the operator pick a trade-off.
- Classification accuracy on realistic autónomo data is acceptable at
:attr:`ModelTier.MEDIUM` and above; :attr:`ModelTier.LOW` models are
prone to ignoring the strict JSON schema or picking the wrong
classification on ambiguous inputs.
:data:`MINIMUM_CLASSIFICATION_TIER` enforces this floor.
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import IntEnum, StrEnum
[docs]
class ModelTier(IntEnum):
"""Ordered capability tier. Comparison operators work as expected.
Attributes:
LOW: Cheap / fast models prone to schema drift on ambiguous
inputs.
MEDIUM: Solid single-shot reasoning; the floor for the
classification pipeline.
HIGH: Top-tier models with strong multi-step reasoning.
"""
LOW = 1
MEDIUM = 2
HIGH = 3
[docs]
class ModelCapability(StrEnum):
"""Whether the model natively does multi-step reasoning before answering.
Attributes:
NON_THINKING: Single-shot models that emit one response without
internal chain-of-thought passes.
THINKING: Models that perform explicit multi-step reasoning
before producing the final answer.
"""
NON_THINKING = "non_thinking"
THINKING = "thinking"
[docs]
@dataclass(frozen=True)
class ModelProfile:
"""One concrete model at one provider, tagged with its capability tier.
Attributes:
provider: Lower-case provider name (``claude`` / ``antigravity`` /
``codex``).
alias: Stable human-friendly identifier, e.g. ``claude-sonnet``.
The operator uses this on the CLI; the tool maps it to ``model_id``.
model_id: Current provider-specific model argument. MAY be
empty when the provider CLI defaults to the right model
(e.g. ``codex`` picks its own default).
tier: Capability tier.
capability: Thinking vs non-thinking.
"""
provider: str
alias: str
model_id: str
tier: ModelTier
capability: ModelCapability
# Minimum capability tier permitted for the classification pipeline. The
# observable failure mode at ``LOW`` is schema drift (the model returns
# prose instead of JSON, or picks a classification outside the allow-list)
# on more than a handful of transactions per batch, which defeats the
# confidence filter operator relies on downstream.
MINIMUM_CLASSIFICATION_TIER: ModelTier = ModelTier.MEDIUM
"""Minimum :class:`ModelTier` permitted for the classification pipeline.
The observable failure mode at :attr:`ModelTier.LOW` is schema drift
(the model returns prose instead of JSON, or picks a classification
outside the allow-list) on more than a handful of transactions per
batch, which defeats the confidence filter downstream consumers rely
on."""
# Per-provider catalogue of known models. Keep the list tight and prefer
# the current default over exhaustive historical IDs — this is a floor
# for operator's choices, not an archive.
#
# Tier placement rationale:
# - claude-haiku, gpt-4o-mini: fast/cheap, ~LOW.
# - claude-sonnet, antigravity default, gpt-4o, codex default: solid single-shot, MEDIUM.
# - claude-opus, o3/o4, codex-high: top-tier reasoning, HIGH.
_CATALOGUE: tuple[ModelProfile, ...] = (
# Claude
ModelProfile(
provider="claude",
alias="claude-haiku",
model_id="claude-haiku-4-5",
tier=ModelTier.LOW,
capability=ModelCapability.NON_THINKING,
),
ModelProfile(
provider="claude",
alias="claude-sonnet",
model_id="claude-sonnet-4-6",
tier=ModelTier.MEDIUM,
capability=ModelCapability.NON_THINKING,
),
ModelProfile(
provider="claude",
alias="claude-opus",
model_id="claude-opus-4-7",
tier=ModelTier.HIGH,
capability=ModelCapability.THINKING,
),
# Antigravity (Google's agentic CLI ``agy``, the supported successor to the
# retired standalone ``gemini`` CLI). The CLI selects its own current
# default model; an empty ``model_id`` omits ``--model`` and lets ``agy``
# choose, mirroring the ``codex-default`` profile. Explicit model pinning is
# a follow-up once ``agy models`` is queryable in CI.
ModelProfile(
provider="antigravity",
alias="antigravity-default",
model_id="",
tier=ModelTier.MEDIUM,
capability=ModelCapability.THINKING,
),
# Codex — default is the agent's current top model; it does not accept
# -m for most deployments. alias "codex-default" maps to empty model_id.
ModelProfile(
provider="codex",
alias="codex-default",
model_id="",
tier=ModelTier.MEDIUM,
capability=ModelCapability.THINKING,
),
ModelProfile(
provider="codex",
alias="codex-o3",
model_id="o3",
tier=ModelTier.HIGH,
capability=ModelCapability.THINKING,
),
)
[docs]
def catalogue() -> tuple[ModelProfile, ...]:
"""Return the full known-model catalogue.
Returns:
A tuple of every registered :class:`ModelProfile`, in the
catalogue's declared order.
"""
return _CATALOGUE
__all__ = [
"MINIMUM_CLASSIFICATION_TIER",
"ModelCapability",
"ModelProfile",
"ModelTier",
"catalogue",
]