feat(auto-router): track turns per complexity tier (LIT-5302) (#36209)

* feat(auto-router): track turns per complexity tier (LIT-5302)

Stamps complexity tier at decision time (rollup never re-derives from routed
model, since tier->model mapping is mutable config). Records per-tier turn
counts in LiteLLM_AutoRouterSession.tier_turns (jsonb), rolls up per router
in benchmarks SQL via jsonb_object_agg, returns on AutoRouterBenchmarkGroup
for dashboard turns/share metrics.

Addresses Greptile/Bugbot findings:

- Missing _SessionAggRow.tier_turns field: added with field_validator to
  parse jsonb text cast and handle NULL. Would 500 every benchmarks read.

- Missing ::text cast on tier parameter: Postgres fails type inference on
  parameterized CASE/IS NULL without explicit cast. Added to all usages.

- Docstring false claim (only complexity routers produce tiers): quality
  router stamps numeric tier '1'/'2'/'3'. Per-type grouping in SQL prevents
  cross-contamination. Rewrote docstring to clarify isolation.

- Comment convention violations: stripped per CLAUDE.md rule.

- Test gaps: 8 unit tests for extraction/validation/aggregation, 7 behavior
  tests for SQL semantics against real Postgres. 12 mutations killed.
  Fixed fragile complexity_router test that broke on nested function calls.

No API change; extends existing GET /auto_router/benchmarks response only.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(auto-router): address review findings on tier turns tracking

- Guard router_type update so a mid-session reconfigure can't pool
  foreign tier names into tier_turns
- Keep pinned turns attributed to the tier that actually serves them
- Drop stray -- AlterTable comment from hand-written migration
- Drop the now-unnecessary ::text/json.loads round-trip; prisma
  already returns tier_turns as a parsed dict

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(auto-router): satisfy type-discipline lint gate

- tier_turns fields: dict[str, int] -> Mapping[str, int] (LIT001,
  mutable collection in annotation); these are read-only after
  construction
- _summed_agg_row: {} -> MappingProxyType({}) (LIT002, mutable dict
  literal)
- default-fallback branch: replace the reassigned-without-Final
  fallback_tier with a Final default_model_first flag and a single
  ternary assignment (LIT010)

Verified locally: type_discipline_gate.py, ruff_strict_gate.py, and
type_check_gate.py all pass against the litellm_internal_staging
merge-base; full test_complexity_router.py (374), auto_router
management-endpoint tests (26), db-layer rollup tests (31), and the
live-Postgres proxy_behavior rollup suite (17) all pass.

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
tin-berri 2026-08-07 17:03:34 -07:00 committed by GitHub
parent 8db2fbaad0
commit 3238ce8406
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 268 additions and 15 deletions

View file

@ -0,0 +1 @@
ALTER TABLE "LiteLLM_AutoRouterSession" ADD COLUMN IF NOT EXISTS "tier_turns" JSONB NOT NULL DEFAULT '{}';

View file

@ -1439,6 +1439,7 @@ model LiteLLM_AutoRouterSession {
total_tokens BigInt @default(0)
spend Float @default(0)
saved_spend Float @default(0)
tier_turns Json @default("{}")
@@id([api_key, session_id, router_name])
@@index([last_turn_at], map: "idx_autorouter_session_last_turn")

View file

@ -49,6 +49,7 @@ class AutoRouterTurnTransaction:
cache_hit: bool
cache_ttl_seconds: int | None
cache_touched: bool
tier: str | None = None
class TurnCacheFacts(NamedTuple):
@ -152,11 +153,13 @@ def build_autorouter_turn_transaction(
return None
usage_object_raw: Final = metadata.get("usage_object")
cache: Final = turn_cache_facts(usage_object_raw if isinstance(usage_object_raw, Mapping) else None)
tier_raw: Final = routing_decision.get("tier")
return AutoRouterTurnTransaction(
api_key=api_key,
session_id=_bounded_session_id(session_id),
router_name=router_name,
router_type=str(routing_decision.get("router_type") or "unknown"),
tier=tier_raw if isinstance(tier_raw, str) and tier_raw else None,
model=model,
turn_at=turn_at,
total_tokens=int(payload.get("prompt_tokens") or 0) + int(payload.get("completion_tokens") or 0),
@ -184,6 +187,8 @@ _COVERED: Final = _p("covered")
_CACHE_HIT: Final = _p("cache_hit")
_CACHE_TTL: Final = _p("cache_ttl_seconds")
_TOUCHED: Final = _p("cache_touched")
_TIER: Final = f"{_p('tier')}::text"
_TIER_DELTA: Final = f"(CASE WHEN {_TIER} IS NULL THEN '{{}}'::jsonb ELSE jsonb_build_object({_TIER}, 1) END)"
_IN_ORDER: Final = f"{_TURN_AT}::timestamp >= t.last_turn_at"
_SAME: Final = f"{_IN_ORDER} AND t.last_model = {_MODEL}"
@ -201,7 +206,7 @@ INSERT INTO "LiteLLM_AutoRouterSession" AS t (
last_model, models, turns, unordered_turns, covered_turns, cache_hits,
same_model_turns, same_model_hits, first_visit_turns, first_visit_hits,
return_turns, return_hits, return_expired_misses, return_within_ttl_misses,
ttl_5m_turns, ttl_1h_turns, total_tokens, spend, saved_spend
ttl_5m_turns, ttl_1h_turns, total_tokens, spend, saved_spend, tier_turns
)
VALUES (
{_p("api_key")}, {_p("session_id")}, {_p("router_name")}, {_p("router_type")}, {_TURN_AT}::timestamp, {_TURN_AT}::timestamp,
@ -211,7 +216,8 @@ VALUES (
0, 0, 0, 0,
(CASE WHEN {_CACHE_TTL}::int = {CACHE_TTL_5M_SECONDS} THEN 1 ELSE 0 END),
(CASE WHEN {_CACHE_TTL}::int = {CACHE_TTL_1H_SECONDS} THEN 1 ELSE 0 END),
{_p("total_tokens")}::bigint, {_p("spend")}::float8, {_p("saved_spend")}::float8
{_p("total_tokens")}::bigint, {_p("spend")}::float8, {_p("saved_spend")}::float8,
{_TIER_DELTA}
)
ON CONFLICT (api_key, session_id, router_name) DO UPDATE SET
turns = t.turns + 1,
@ -242,6 +248,9 @@ ON CONFLICT (api_key, session_id, router_name) DO UPDATE SET
ELSE COALESCE((t.models -> {_MODEL} ->> 'ttl')::int, {_CACHE_TTL}::int) END)
)),
last_model = (CASE WHEN {_IN_ORDER} THEN {_MODEL} ELSE t.last_model END),
tier_turns = (CASE WHEN {_TIER} IS NOT NULL AND t.router_type = {_p("router_type")}
THEN t.tier_turns || jsonb_build_object({_TIER}, COALESCE((t.tier_turns ->> {_TIER})::int, 0) + 1)
ELSE t.tier_turns END),
first_turn_at = LEAST(t.first_turn_at, EXCLUDED.first_turn_at),
last_turn_at = GREATEST(t.last_turn_at, EXCLUDED.last_turn_at)
"""

View file

@ -4,8 +4,9 @@ AUTO ROUTER MANAGEMENT ENDPOINTS
POST /auto_router/test_routing - Route one prompt through an unsaved complexity-router config
"""
from collections.abc import Sequence
from collections.abc import Mapping, Sequence
from datetime import datetime, timedelta, timezone
from types import MappingProxyType
from typing import TYPE_CHECKING, Annotated, Final
from pydantic import BaseModel, TypeAdapter
@ -260,6 +261,7 @@ async def preview_auto_router_routing(
class _SessionAggRow(BaseModel):
router_name: str
router_type: str
tier_turns: Mapping[str, int]
sessions: int
turns: int
unordered_turns: int
@ -284,6 +286,23 @@ class _SessionAggRow(BaseModel):
_SESSION_AGG_ROWS: Final = TypeAdapter(list[_SessionAggRow])
_BENCHMARKS_SQL: Final = """
WITH windowed AS (
SELECT * FROM "LiteLLM_AutoRouterSession"
WHERE last_turn_at >= $1::timestamp AND first_turn_at < $2::timestamp
),
tier_maps AS (
SELECT router_name, router_type, jsonb_object_agg(tier, tier_turns) AS tier_turns
FROM (
SELECT router_name, router_type, kv.key AS tier, SUM((kv.value)::int)::int AS tier_turns
FROM windowed, LATERAL jsonb_each_text(tier_turns) AS kv
GROUP BY router_name, router_type, kv.key
) per_tier
GROUP BY router_name, router_type
)
SELECT
agg.*,
COALESCE(tier_maps.tier_turns, '{}'::jsonb) AS tier_turns
FROM (
SELECT
router_name,
router_type,
@ -306,10 +325,11 @@ SELECT
COALESCE(SUM(spend), 0)::float8 AS spend,
COALESCE(SUM(saved_spend), 0)::float8 AS saved_spend,
COALESCE(SUM(EXTRACT(EPOCH FROM (last_turn_at - first_turn_at))), 0)::float8 AS session_seconds
FROM "LiteLLM_AutoRouterSession"
WHERE last_turn_at >= $1::timestamp AND first_turn_at < $2::timestamp
FROM windowed
GROUP BY router_name, router_type
ORDER BY SUM(spend) DESC
) agg
LEFT JOIN tier_maps USING (router_name, router_type)
ORDER BY agg.spend DESC
"""
@ -366,6 +386,7 @@ def _summed_agg_row(rows: Sequence[_SessionAggRow]) -> _SessionAggRow:
return _SessionAggRow(
router_name="",
router_type="",
tier_turns=MappingProxyType({}),
sessions=sum(row.sessions for row in rows),
turns=sum(row.turns for row in rows),
unordered_turns=sum(row.unordered_turns for row in rows),
@ -443,6 +464,7 @@ async def get_auto_router_benchmarks(
AutoRouterBenchmarkGroup(
router_name=row.router_name,
router_type=row.router_type,
tier_turns=row.tier_turns,
**_benchmark_totals(row).model_dump(),
)
for row in rows

View file

@ -1439,6 +1439,7 @@ model LiteLLM_AutoRouterSession {
total_tokens BigInt @default(0)
spend Float @default(0)
saved_spend Float @default(0)
tier_turns Json @default("{}")
@@id([api_key, session_id, router_name])
@@index([last_turn_at], map: "idx_autorouter_session_last_turn")

View file

@ -1730,6 +1730,7 @@ class ComplexityRouter(CustomLogger):
routing_decision=self._build_routing_decision(
routed_model=routed_model,
cause=cause,
tier=self._tier_for_model(routed_model),
escalation_keyword=pin_escalation_keyword,
escalated=escalated,
conversation_continuing=conversation_continuing,
@ -1797,7 +1798,8 @@ class ComplexityRouter(CustomLogger):
if user_message is None:
verbose_router_logger.debug("ComplexityRouter: No user message found, routing to default model")
if not self.config.plugins and self.config.default_model:
default_model_first: Final = not self.config.plugins and self.config.default_model
if default_model_first:
# No plugins configured: preserve the pre-existing default_model-first
# priority exactly (changing it would be a silent behavior change for
# every non-plugin user, not just a security fix).
@ -1809,12 +1811,14 @@ class ComplexityRouter(CustomLogger):
routed_model = await self._pick_model_for_tier(
ComplexityTier.MEDIUM, messages, resolved_messages, request_kwargs
)
fallback_tier: Final = None if default_model_first else ComplexityTier.MEDIUM
return PreRoutingHookResponse(
model=routed_model,
messages=messages if has_original_messages else None,
routing_decision=self._build_routing_decision(
routed_model=routed_model,
cause="default_fallback",
tier=fallback_tier,
conversation_continuing=conversation_continuing,
),
)

View file

@ -2,6 +2,7 @@
Types for auto-router management endpoints
"""
from collections.abc import Mapping
from typing import Final
from pydantic import BaseModel, Field, field_validator
@ -120,6 +121,16 @@ class AutoRouterBenchmarkGroup(AutoRouterBenchmarkTotals):
router_name: str = Field(description="The auto-router alias requests were sent to")
router_type: str = Field(description="complexity, adaptive or quality")
tier_turns: Mapping[str, int] = Field(
default_factory=dict,
description="Turns per tier, keyed by the tier name the routing decision recorded at "
"request time (never re-derived at read time, since the tier-to-model mapping is "
"mutable config). Tier names are scoped to this group's router_type and are not "
"comparable across types: a complexity router reports 'simple'/'medium'/'complex'/"
"'reasoning', a quality router reports its numeric quality tier, and an adaptive router "
"records no tier at all. Turns no tier served (the classifier fell back to default_model) "
"are absent rather than pooled under a sentinel key, so the values may sum to less than turns",
)
class AutoRouterBenchmarksResponse(BaseModel):

View file

@ -1439,6 +1439,7 @@ model LiteLLM_AutoRouterSession {
total_tokens BigInt @default(0)
spend Float @default(0)
saved_spend Float @default(0)
tier_turns Json @default("{}")
@@id([api_key, session_id, router_name])
@@index([last_turn_at], map: "idx_autorouter_session_last_turn")

View file

@ -38,11 +38,13 @@ async def _turn(
tokens: int = 100,
spend: float = 0.01,
saved: float = 0.02,
tier: "str | None" = None,
) -> None:
touched: Final = 1 if (hit or ttl is not None or not covered) else 0
await db.execute_raw(
UPSERT_AUTOROUTER_SESSION_SQL,
key, session_id, router, router_type, model, at.isoformat(), tokens, spend, saved, covered, hit, ttl, touched,
tier,
)
@ -195,6 +197,106 @@ async def test_a_reconfigured_alias_reports_each_router_type_as_its_own_group(db
assert [(row["router_type"], row["sessions"]) for row in matching] == [("complexity", 1), ("quality", 1)]
async def test_tier_turns_count_each_tier_that_served_a_turn(db):
key = f"k-{uuid.uuid4()}"
await _turn(db, key, "A", T0, tier="simple")
await _turn(db, key, "B", T0 + timedelta(seconds=10), tier="complex")
await _turn(db, key, "A", T0 + timedelta(seconds=20), tier="simple")
assert (await _row(db, key))["tier_turns"] == {"simple": 2, "complex": 1}
async def test_an_untiered_turn_increments_no_tier_counter(db):
key = f"k-{uuid.uuid4()}"
await _turn(db, key, "A", T0, tier=None)
assert (await _row(db, key))["tier_turns"] == {}
await _turn(db, key, "A", T0 + timedelta(seconds=10), tier="medium")
await _turn(db, key, "A", T0 + timedelta(seconds=20), tier=None)
row = await _row(db, key)
assert row["tier_turns"] == {"medium": 1}
assert row["turns"] == 3
async def test_a_mid_session_router_type_change_keeps_foreign_tier_names_out_of_the_map(db):
key = f"k-{uuid.uuid4()}"
await _turn(db, key, "A", T0, router_type="complexity", tier="medium")
await _turn(db, key, "A", T0 + timedelta(seconds=10), router_type="quality", tier="2")
await _turn(db, key, "A", T0 + timedelta(seconds=20), router_type="complexity", tier="medium")
row = await _row(db, key)
assert row["tier_turns"] == {"medium": 2}
assert row["turns"] == 3
async def test_an_out_of_order_turn_still_counts_toward_its_tier(db):
key = f"k-{uuid.uuid4()}"
await _turn(db, key, "A", T0 + timedelta(seconds=60), tier="simple")
await _turn(db, key, "A", T0, tier="simple")
row = await _row(db, key)
assert row["tier_turns"] == {"simple": 2}
assert row["unordered_turns"] == 1
async def test_the_benchmarks_aggregate_sums_tier_turns_across_sessions(db):
key = f"k-{uuid.uuid4()}"
router = f"r-{uuid.uuid4()}"
await _turn(db, key, "A", T0, session_id=f"s-{uuid.uuid4()}", router=router, tier="simple")
await _turn(db, key, "A", T0 + timedelta(seconds=10), session_id=f"s-{uuid.uuid4()}", router=router, tier="simple")
await _turn(db, key, "B", T0 + timedelta(seconds=20), session_id=f"s-{uuid.uuid4()}", router=router, tier="complex")
await _turn(db, key, "C", T0 + timedelta(seconds=30), session_id=f"s-{uuid.uuid4()}", router=router, tier=None)
rows = await db.query_raw(
_BENCHMARKS_SQL,
(T0 - timedelta(days=1)).isoformat(),
(T0 + timedelta(days=1)).isoformat(),
)
grouped = next(row for row in rows if row["router_name"] == router)
assert grouped["tier_turns"] == {"simple": 2, "complex": 1}
assert grouped["turns"] == 4
async def test_tier_maps_stay_separate_per_router_type_on_a_reconfigured_alias(db):
key = f"k-{uuid.uuid4()}"
router = f"r-{uuid.uuid4()}"
await _turn(
db, key, "A", T0, session_id=f"s-{uuid.uuid4()}", router=router, router_type="complexity", tier="medium"
)
await _turn(
db,
key,
"A",
T0 + timedelta(seconds=10),
session_id=f"s-{uuid.uuid4()}",
router=router,
router_type="quality",
tier="2",
)
rows = await db.query_raw(
_BENCHMARKS_SQL,
(T0 - timedelta(days=1)).isoformat(),
(T0 + timedelta(days=1)).isoformat(),
)
by_type = {row["router_type"]: row["tier_turns"] for row in rows if row["router_name"] == router}
assert by_type == {"complexity": {"medium": 1}, "quality": {"2": 1}}
async def test_a_window_with_no_tiered_turns_aggregates_to_an_empty_map(db):
key = f"k-{uuid.uuid4()}"
router = f"r-{uuid.uuid4()}"
await _turn(db, key, "A", T0, session_id=f"s-{uuid.uuid4()}", router=router, tier=None)
rows = await db.query_raw(
_BENCHMARKS_SQL,
(T0 - timedelta(days=1)).isoformat(),
(T0 + timedelta(days=1)).isoformat(),
)
grouped = next(row for row in rows if row["router_name"] == router)
assert grouped["tier_turns"] == {}
async def test_a_miss_that_touched_no_cache_does_not_advance_the_ttl_clock(db):
key = f"k-{uuid.uuid4()}"
await _turn(db, key, "A", T0, ttl=300)

View file

@ -93,6 +93,19 @@ class TestBuildTransaction:
def test_requests_without_a_routing_decision_are_skipped(self, metadata: dict):
assert _build(metadata=metadata) is None
def test_the_tier_the_decision_recorded_is_carried_onto_the_transaction(self):
transaction = _build(metadata=_metadata(routing_decision={**ROUTING_DECISION, "tier": "reasoning"}))
assert transaction is not None and transaction.tier == "reasoning"
@pytest.mark.parametrize("tier", [None, "", 3, {"tier": "medium"}])
def test_a_decision_without_a_usable_tier_records_no_tier(self, tier: object):
transaction = _build(metadata=_metadata(routing_decision={**ROUTING_DECISION, "tier": tier}))
assert transaction is not None and transaction.tier is None
def test_a_decision_that_never_mentions_tier_records_no_tier(self):
transaction = _build()
assert transaction is not None and transaction.tier is None
def test_router_name_falls_back_to_the_payload_model_group(self):
transaction = _build(metadata=_metadata(routing_decision={"router_type": "complexity"}))
assert transaction is not None and transaction.router_name == "live-auto"
@ -167,7 +180,11 @@ class _FakeClient:
self.db = _FakeDB(failures, poison_session)
def _transaction(session_id: str = "s1", at: datetime = datetime(2026, 8, 1, 12, 0, 0)) -> AutoRouterTurnTransaction:
def _transaction(
session_id: str = "s1",
at: datetime = datetime(2026, 8, 1, 12, 0, 0),
tier: str | None = "medium",
) -> AutoRouterTurnTransaction:
return AutoRouterTurnTransaction(
api_key="k1",
session_id=session_id,
@ -182,6 +199,7 @@ def _transaction(session_id: str = "s1", at: datetime = datetime(2026, 8, 1, 12,
cache_hit=False,
cache_ttl_seconds=None,
cache_touched=False,
tier=tier,
)
@ -201,7 +219,7 @@ class TestFlush:
assert sql == UPSERT_AUTOROUTER_SESSION_SQL
assert params == (
"k1", "s1", "live-auto", "complexity", "bedrock/haiku",
"2026-08-01T12:00:00", 100, 0.01, 0.02, 1, 0, None, 0,
"2026-08-01T12:00:00", 100, 0.01, 0.02, 1, 0, None, 0, "medium",
)
def test_a_connect_error_retries_the_same_statement(self):

View file

@ -292,6 +292,7 @@ class TestAutoRouterBenchmarks:
ROW = _SessionAggRow(
router_name="live-auto",
router_type="complexity",
tier_turns={},
sessions=4,
turns=40,
unordered_turns=1,
@ -377,6 +378,21 @@ class TestAutoRouterBenchmarks:
assert totals.avg_turns_per_session == 10.0
assert totals.spend == 10.0
def test_tier_names_stay_scoped_to_the_router_type_that_recorded_them(self):
quality = self.ROW.model_copy(
update={"router_name": "quality-auto", "router_type": "quality", "tier_turns": {"2": 7}}
)
complexity = self.ROW.model_copy(update={"tier_turns": {"medium": 7}})
assert complexity.tier_turns == {"medium": 7}
assert quality.tier_turns == {"2": 7}
def test_summed_totals_carry_no_tier_map_because_names_are_router_scoped(self):
from litellm.proxy.management_endpoints.auto_router_endpoints import _summed_agg_row
quality = self.ROW.model_copy(update={"router_type": "quality", "tier_turns": {"2": 7}})
complexity = self.ROW.model_copy(update={"tier_turns": {"medium": 7}})
assert _summed_agg_row([complexity, quality]).tier_turns == {}
@pytest.mark.asyncio
async def test_non_admin_roles_cannot_read_benchmarks(self):
from litellm.proxy.management_endpoints.auto_router_endpoints import get_auto_router_benchmarks
@ -427,3 +443,26 @@ class TestAutoRouterBenchmarks:
assert response.routers_in_scope == 1
assert response.groups[0].router_name == "live-auto"
assert response.groups[0].saved_pct == response.totals.saved_pct == 75.0
@pytest.mark.asyncio
@pytest.mark.parametrize(
"wire_value, expected", [({"simple": 24, "complex": 16}, {"simple": 24, "complex": 16}), ({}, {})]
)
async def test_the_tier_map_reaches_the_response_as_the_jsonb_column_returns_it(
self, wire_value: dict, expected: dict, monkeypatch: pytest.MonkeyPatch
):
from litellm.proxy import proxy_server
from litellm.proxy.management_endpoints.auto_router_endpoints import get_auto_router_benchmarks
class _DB:
async def query_raw(self, sql: str, *params: object):
return [{**TestAutoRouterBenchmarks.ROW.model_dump(), "tier_turns": wire_value}]
monkeypatch.setattr(proxy_server, "prisma_client", type("P", (), {"db": _DB()})())
response = await get_auto_router_benchmarks(
user_api_key_dict=ADMIN,
start_date="2026-07-01",
end_date="2026-08-01",
)
assert response.groups[0].tier_turns == expected

View file

@ -3477,6 +3477,23 @@ class TestSessionAffinity:
# Pinned to the first turn's model, not re-classified down to SIMPLE.
assert second.model == "o1-preview"
@pytest.mark.asyncio
async def test_a_pinned_turn_reports_the_tier_that_serves_it(self, mock_router_instance, session_affinity_config):
mock_router_instance.cache = DualCache()
router = ComplexityRouter(
model_name="test-router",
litellm_router_instance=mock_router_instance,
complexity_router_config=session_affinity_config,
)
request_kwargs = self._request_kwargs("session-1")
await router.async_pre_routing_hook(
model="test-model", request_kwargs=request_kwargs, messages=self.REASONING_MESSAGE
)
pinned = await router.async_pre_routing_hook(
model="test-model", request_kwargs=request_kwargs, messages=self.SIMPLE_MESSAGE
)
assert pinned.routing_decision["tier"] == "REASONING"
@pytest.mark.asyncio
async def test_different_sessions_classify_independently(self, mock_router_instance, session_affinity_config):
mock_router_instance.cache = DualCache()
@ -4362,7 +4379,24 @@ class TestRoutingDecisionContents:
assert decision is not None
assert decision["cause"] == "default_fallback"
assert decision["routed_model"] == response.model
assert "tier" not in decision
assert decision.get("tier") == "MEDIUM"
@pytest.mark.asyncio
async def test_a_default_model_fallback_claims_no_tier(self, mock_router_instance, basic_config):
router = ComplexityRouter(
model_name="test-complexity-router",
litellm_router_instance=mock_router_instance,
complexity_router_config={**basic_config, "default_model": "gpt-4o"},
)
response = await router.async_pre_routing_hook(
model="test-complexity-router",
request_kwargs={},
messages=[{"role": "system", "content": "be nice"}],
)
assert response is not None
assert response.routing_decision is not None
assert response.routing_decision["cause"] == "default_fallback"
assert "tier" not in response.routing_decision
@pytest.mark.asyncio
async def test_session_pin_decision(self, mock_router_instance, basic_config):
@ -5907,11 +5941,21 @@ class TestConversationShapeDiscriminator:
)
builds = source.split("self._build_routing_decision(")[1:]
assert builds
missing = [
i
for i, block in enumerate(builds)
if "conversation_continuing=conversation_continuing" not in block.split("),")[0]
]
missing = []
for i, block in enumerate(builds):
depth = 0
end = 0
for j, char in enumerate(block):
if char == "(":
depth += 1
elif char == ")":
depth -= 1
if depth < 0:
end = j
break
extracted = block[:end]
if "conversation_continuing=conversation_continuing" not in extracted:
missing.append(i)
assert not missing, f"routing decisions {missing} do not carry the conversation shape"