Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 29 additions & 34 deletions ainfera_api/routers/inference.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,25 @@
RoutingOutcomeORM,
TenantORM,
)

# AIN-271b · SP-1 routing-string constants. Re-exported from
# `ainfera_api.routing_targets` (the leaf module that owns them) so this
# router can stay an HTTP boundary without other modules having to import
# FastAPI + the brain just to ask "is this a routed model?". The
# back-compat names below are unchanged so existing call sites keep
# working without a churn-PR across every importer. New code should
# prefer `from ainfera_api.routing_targets import …`.
from ainfera_api.routing_targets import ( # logical import group with the doc above
AUTO_MODEL,
CANONICAL_ROUTER,
INFERENCE_MODEL,
MITHRIL_MODEL,
ROUTING_ALIASES,
ROUTING_TARGETS,
)
from ainfera_api.routing_targets import (
is_routed as _is_routed,
)
from ainfera_api.services import audit, routing
from ainfera_api.services.ledger import InsufficientFundsError
from ainfera_api.services.response_normalizer import (
Expand All @@ -40,43 +59,19 @@
)
from ainfera_api.services.spend_policy import CapViolationError

# AIN-271b · `ainfera-inference` flagship routing-string lock (SP-1).
# Supersedes the AIN-244 `ainfera-mithril` lock per the 2026-05-23 founder
# decision (Disc#12). `ainfera-inference` is now the canonical routing
# target — the string the SDK, quickstart, dashboard, and marketing lead
# with. The three legacy strings below are silent aliases resolved at the
# router boundary; every alias hit is logged so we can measure migration
# coverage before any future hard-cut. Tolkien product names (Mithril)
# survive ONLY here as the alias map — never on a human-readable surface.
#
# The flagship is a ROUTING TARGET the gateway resolves, not a backend
# model — `/v1/models` lists backends (claude-*, gpt-*, …); ainfera-inference
# is the prime-brokerage default that says "let the researched decision
# pick." Vendor passthrough strings remain valid as the explicit opt-out.
INFERENCE_MODEL = "ainfera-inference"

# Silent aliases — resolve identically through dispatch_with_brain. Live
# ONLY in this resolver map; never in docs/openapi/marketing/agent-card.
_MITHRIL_ALIAS = "ainfera-mithril"
_AUTO_ALIAS = "ainfera-auto"
_AUTO_SLASH_ALIAS = "ainfera/auto"

ROUTING_ALIASES: frozenset[str] = frozenset({_MITHRIL_ALIAS, _AUTO_ALIAS, _AUTO_SLASH_ALIAS})
ROUTING_TARGETS: frozenset[str] = frozenset({INFERENCE_MODEL}) | ROUTING_ALIASES
CANONICAL_ROUTER = INFERENCE_MODEL

# Back-compat module exports — older imports reach for these names directly.
# Both now point at the canonical so importers see the same target the
# gateway resolves to. New code should import INFERENCE_MODEL.
MITHRIL_MODEL = INFERENCE_MODEL
AUTO_MODEL = INFERENCE_MODEL

logger = logging.getLogger(__name__)


def _is_routed(model_slug: str) -> bool:
"""True when the caller is asking the gateway to route (vs pin a backend)."""
return model_slug in ROUTING_TARGETS
__all__ = [
"AUTO_MODEL",
"CANONICAL_ROUTER",
"INFERENCE_MODEL",
"MITHRIL_MODEL",
"ROUTING_ALIASES",
"ROUTING_TARGETS",
"_is_routed",
"router",
]


def _log_alias_hit(model_slug: str, agent_id: UUID) -> None:
Expand Down
77 changes: 77 additions & 0 deletions ainfera_api/routing_targets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""SP-1 routing-target constants — module-level so they don't pull a router.

The flagship routing string and its three silent aliases (`ainfera-mithril`,
`ainfera-auto`, `ainfera/auto`) used to live inside
`ainfera_api/routers/inference.py`. That meant any module wanting to test
"is this a routed call?" had to import from `routers/inference`, which
transitively pulls FastAPI, the routing brain, capture services, and the
ORM — a 50-import chain to ask a frozenset-membership question.

The transitive pull also created a real circular-import bug: `capture_
invariant.py` reached into `routers/inference` for `ROUTING_TARGETS`,
which meant any *new* module wanting to import from `capture_invariant`
(e.g. the AIN-285 metric wire-in in `services/routing_outcomes.py`)
closed a cycle and had to use a function-local lazy import with a
`noqa: PLC0415`. Two such lazy imports landed in AIN-285.

Lifting the constants up to a leaf module breaks the cycle for good.
The router module re-exports them so existing callers keep working
without a churn-PR across every importer:

# still works (router-level re-export):
from ainfera_api.routers.inference import ROUTING_TARGETS, _is_routed

# new preferred path (no cycle, no FastAPI pull):
from ainfera_api.routing_targets import ROUTING_TARGETS, is_routed

Per SP-1 AIN-271b: `ainfera-inference` is the canonical flagship; the
three legacy strings are silent aliases resolved at the router boundary
inside `routers/inference.py` (`_log_alias_hit` still emits one INFO
log per legacy hit so migration is measurable). This module owns ONLY
the constants + the pure classification function — no logging, no I/O.
"""

from __future__ import annotations

INFERENCE_MODEL = "ainfera-inference"

# SP-1 silent aliases — three legacy strings that resolve identically.
# Mithril (product name) is HARD-DELETED from every human-readable surface
# per SP-1 / AIN-271b; survives ONLY in this resolver map so old SDKs +
# pinned-tag deploys still reach the gateway.
MITHRIL_ALIAS = "ainfera-mithril"
AUTO_ALIAS = "ainfera-auto"
AUTO_SLASH_ALIAS = "ainfera/auto"

ROUTING_ALIASES: frozenset[str] = frozenset({MITHRIL_ALIAS, AUTO_ALIAS, AUTO_SLASH_ALIAS})
ROUTING_TARGETS: frozenset[str] = frozenset({INFERENCE_MODEL}) | ROUTING_ALIASES
CANONICAL_ROUTER = INFERENCE_MODEL

# Back-compat module-level names — older imports reach for these
# directly. Both now point at the canonical so importers see the same
# target the gateway resolves to. New code should import INFERENCE_MODEL.
MITHRIL_MODEL = INFERENCE_MODEL
AUTO_MODEL = INFERENCE_MODEL


def is_routed(model_slug: str) -> bool:
"""True when the caller asked the gateway to route (vs pin a backend).

Cheap frozenset membership; safe to call on every request without
pulling FastAPI / the brain / the ORM.
"""
return model_slug in ROUTING_TARGETS


__all__ = [
"AUTO_ALIAS",
"AUTO_MODEL",
"AUTO_SLASH_ALIAS",
"CANONICAL_ROUTER",
"INFERENCE_MODEL",
"MITHRIL_ALIAS",
"MITHRIL_MODEL",
"ROUTING_ALIASES",
"ROUTING_TARGETS",
"is_routed",
]
2 changes: 1 addition & 1 deletion ainfera_api/services/capture_invariant.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@

from ainfera_api.models.audit_event import AuditEventType
from ainfera_api.orm import AuditEventORM, InferenceORM, RoutingOutcomeORM
from ainfera_api.routers.inference import ROUTING_TARGETS
from ainfera_api.routing_targets import ROUTING_TARGETS

logger = logging.getLogger(__name__)

Expand Down
117 changes: 117 additions & 0 deletions tests/unit/test_routing_targets_module.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
"""Guards the leaf-module property of `ainfera_api.routing_targets`.

This module exists to break the import cycle that forced two
function-local imports (`noqa: PLC0415`) in AIN-285 (the metric
wire-in in `services/routing_outcomes.py` and `routers/inference.py`).
The cycle was:

services/routing_outcomes -> services/capture_invariant
-> routers/inference (for ROUTING_TARGETS)
-> services/routing_brain
-> services/routing_outcomes ← back here

By lifting the constants to a leaf module, no node in that path needs
to import a router. The tests below lock that property so a future PR
can't accidentally re-introduce a router dep in the leaf module.
"""

from __future__ import annotations

import ast
from pathlib import Path

ROOT = Path(__file__).resolve().parents[2]
LEAF_MODULE = ROOT / "ainfera_api" / "routing_targets.py"


def _imports_in(source_path: Path) -> set[str]:
tree = ast.parse(source_path.read_text(encoding="utf-8"))
names: set[str] = set()
for node in ast.walk(tree):
if isinstance(node, ast.Import):
names.update(alias.name for alias in node.names)
elif isinstance(node, ast.ImportFrom) and node.module:
names.add(node.module)
return names


def test_routing_targets_imports_no_ainfera_internals() -> None:
"""Leaf-module invariant: routing_targets must not depend on any
other ainfera_api module. If it ever pulls one in, the cycle that
motivated this module is back — and any future module wanting to
import capture_invariant or routing_outcomes at top-level will
have to fall back to function-local lazy imports.
"""
imports = _imports_in(LEAF_MODULE)
ainfera_deps = {name for name in imports if name.startswith("ainfera_api")}
assert ainfera_deps == set(), (
f"routing_targets.py must be a leaf module — found internal deps: {ainfera_deps}"
)


def test_back_compat_re_export_keeps_router_callers_working() -> None:
"""Existing call sites import the constants from
`routers.inference` (e.g. `from ainfera_api.routers.inference
import ROUTING_TARGETS`). The router re-exports them so those
imports don't churn. If a future PR strips the re-exports, every
historical importer breaks at once.
"""
from ainfera_api.routers.inference import (
AUTO_MODEL,
CANONICAL_ROUTER,
INFERENCE_MODEL,
MITHRIL_MODEL,
ROUTING_ALIASES,
ROUTING_TARGETS,
_is_routed,
)

assert INFERENCE_MODEL == "ainfera-inference"
assert CANONICAL_ROUTER == INFERENCE_MODEL
assert MITHRIL_MODEL == INFERENCE_MODEL # back-compat alias
assert AUTO_MODEL == INFERENCE_MODEL # back-compat alias
assert "ainfera-mithril" in ROUTING_ALIASES
assert "ainfera-auto" in ROUTING_ALIASES
assert "ainfera/auto" in ROUTING_ALIASES
assert "ainfera-inference" in ROUTING_TARGETS
assert _is_routed("ainfera-inference") is True
assert _is_routed("claude-opus-4-7") is False


def test_capture_invariant_imports_from_leaf_not_router() -> None:
"""The original cycle culprit. Lock that capture_invariant pulls
ROUTING_TARGETS from the leaf module, not from routers/inference.
"""
capture = ROOT / "ainfera_api" / "services" / "capture_invariant.py"
imports = _imports_in(capture)
assert "ainfera_api.routing_targets" in imports, (
"capture_invariant must import ROUTING_TARGETS from the leaf module"
)
assert "ainfera_api.routers.inference" not in imports, (
"capture_invariant must NOT import from routers/inference — that re-creates the cycle"
)


def test_is_routed_function_is_pure() -> None:
"""The pure version on the leaf module should match the
router-re-exported one for every SP-1 routing target + a sample
of vendor slugs. Two implementations diverging silently would let
capture_invariant and the dispatcher disagree about what's routed
— a moat-shape contamination class of bug.
"""
from ainfera_api.routers.inference import _is_routed as router_is_routed
from ainfera_api.routing_targets import is_routed as leaf_is_routed

for slug in (
"ainfera-inference",
"ainfera-mithril",
"ainfera-auto",
"ainfera/auto",
"claude-opus-4-7",
"gpt-5-5",
"",
"made-up-slug",
):
assert leaf_is_routed(slug) == router_is_routed(slug), (
f"leaf vs router _is_routed disagree on {slug!r}"
)
Loading