From 6d34f391957b9f68376f41532d3af6e7c96759e6 Mon Sep 17 00:00:00 2001 From: varda-elentari Date: Wed, 27 May 2026 09:19:36 +0700 Subject: [PATCH] chore(api): lift ROUTING_TARGETS to a leaf module (breaks import cycle) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New module ainfera_api/routing_targets.py owns the SP-1 routing-string constants: INFERENCE_MODEL, ROUTING_ALIASES, ROUTING_TARGETS, CANONICAL_ROUTER, MITHRIL/AUTO/AUTO_SLASH aliases, MITHRIL_MODEL + AUTO_MODEL back-compat names, and a pure is_routed() classifier. Motivation: capture_invariant.py pulled ROUTING_TARGETS from routers/inference, which forced a 50-import chain and created a real circular-import bug. Any module wanting to import capture_invariant at top-level closed the cycle: services/routing_outcomes -> services/capture_invariant -> routers/inference (for ROUTING_TARGETS) -> services/routing_brain -> services/routing_outcomes AIN-285 had to work around this with two function-local imports (noqa: PLC0415). With ROUTING_TARGETS lifted to a leaf module, any future module — including the AIN-285 wire-ins when feat/ain-285 rebases — can use plain top-level imports. ## What changed - NEW: ainfera_api/routing_targets.py (~75 lines, zero ainfera_api deps). Owns the constants + is_routed(). No FastAPI / no logging / no I/O. - routers/inference.py imports from the leaf module and re-exports the constants + _is_routed (back-compat alias for is_routed). All existing callers — anthropic_compat.py, openai_compat.py, test suites — keep working unchanged. - services/capture_invariant.py imports ROUTING_TARGETS from the leaf module instead of routers/inference. Cycle broken. ## What did NOT change - Public string values (every SP-1 constant has the same value). - _is_routed semantics — the router-level alias and the leaf-level is_routed return identical results for every input (tested explicitly against canonical + 3 aliases + vendor slugs + empty). - /v1 OpenAPI surface (the route definitions in routers/inference are unmoved). ## What enables (future PRs) When feat/ain-285-capture-metric (PR #82) rebases onto this: - routers/inference.py — the lazy get_counter import becomes top-level. - services/routing_outcomes.py — same retirement. - Both noqa: PLC0415 comments come out. ## Tests - NEW: tests/unit/test_routing_targets_module.py (4 tests) locks: * routing_targets.py has zero ainfera_api imports (leaf invariant) * back-compat re-exports work for every constant + _is_routed * capture_invariant imports from leaf, not from router * leaf is_routed() agrees with router _is_routed for every SP-1 target + a vendor-slug sample (so the two impls can't drift) - 550/550 unit + smoke tests green (was 546; +4 from the new file). - ruff check + ruff format --check + mypy --strict clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- ainfera_api/routers/inference.py | 63 ++++++------ ainfera_api/routing_targets.py | 77 ++++++++++++++ ainfera_api/services/capture_invariant.py | 2 +- tests/unit/test_routing_targets_module.py | 117 ++++++++++++++++++++++ 4 files changed, 224 insertions(+), 35 deletions(-) create mode 100644 ainfera_api/routing_targets.py create mode 100644 tests/unit/test_routing_targets_module.py diff --git a/ainfera_api/routers/inference.py b/ainfera_api/routers/inference.py index 42e8ada..cf36d83 100644 --- a/ainfera_api/routers/inference.py +++ b/ainfera_api/routers/inference.py @@ -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 ( @@ -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: diff --git a/ainfera_api/routing_targets.py b/ainfera_api/routing_targets.py new file mode 100644 index 0000000..b8b89c8 --- /dev/null +++ b/ainfera_api/routing_targets.py @@ -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", +] diff --git a/ainfera_api/services/capture_invariant.py b/ainfera_api/services/capture_invariant.py index dec4e10..91e32b9 100644 --- a/ainfera_api/services/capture_invariant.py +++ b/ainfera_api/services/capture_invariant.py @@ -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__) diff --git a/tests/unit/test_routing_targets_module.py b/tests/unit/test_routing_targets_module.py new file mode 100644 index 0000000..61e04b0 --- /dev/null +++ b/tests/unit/test_routing_targets_module.py @@ -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}" + )