From eb0c9662550a674f296c2ff8830af84c37ebcd49 Mon Sep 17 00:00:00 2001 From: Abhimanyu Kapur <38531241+akapur99@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:14:59 -0700 Subject: [PATCH] fix(complexity_router): use max aggregation for semantic keyword route scoring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SemanticRouter defaults to mean aggregation across a route's utterances. Since each tier's route holds one utterance per configured keyword, a real semantic match on one keyword was averaged together with the tier's other, unrelated keywords and dragged below match_threshold — e.g. a MEDIUM tier with keywords [beep, boop, new york] never fired for a genuine "new york" paraphrase, because mean(sim_to_beep, sim_to_boop, sim_to_new_york) landed well under the threshold even though sim_to_new_york alone cleared it. Pass aggregation="max" so a tier matches if the query is close enough to any one of its keywords, not the average of all of them. Verified against live Voyage embeddings: raw cosine similarity for "new york" vs a paraphrase was 0.54 (above a 0.5 threshold), but the route scored 0.28 under mean aggregation and never matched; max aggregation fixes it. Adds a regression test with a tier holding one matching and two unrelated keywords, asserting the tier still fires; fails without aggregation="max". --- .../complexity_router/complexity_router.py | 1 + .../router_strategy/test_complexity_router.py | 34 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/litellm/router_strategy/complexity_router/complexity_router.py b/litellm/router_strategy/complexity_router/complexity_router.py index 2dae9393510..2eb69aad2de 100644 --- a/litellm/router_strategy/complexity_router/complexity_router.py +++ b/litellm/router_strategy/complexity_router/complexity_router.py @@ -485,6 +485,7 @@ class ComplexityRouter(CustomLogger): score_threshold=self.config.match_threshold, ), auto_sync="local", + aggregation="max", ) self._semantic_routelayer = routelayer return routelayer diff --git a/tests/test_litellm/router_strategy/test_complexity_router.py b/tests/test_litellm/router_strategy/test_complexity_router.py index eda73f7e7b6..3d92700032b 100644 --- a/tests/test_litellm/router_strategy/test_complexity_router.py +++ b/tests/test_litellm/router_strategy/test_complexity_router.py @@ -1546,6 +1546,40 @@ class TestSemanticKeywordTierRules: assert result.model == "o1-preview" # REASONING via semantic match assert fake_router.async_embedding_calls, "expected an embedding call for the prompt" + @pytest.mark.asyncio + async def test_tier_matches_on_best_utterance_not_diluted_by_others(self, basic_config): + """A tier with several keywords must match if the query is close to ANY of them, + not the average across all of them. A tier's route holds one utterance per keyword; + mean aggregation (the semantic_router library default) scores the query against the + *average* similarity across every utterance in the route, so a real match on one + keyword gets dragged below threshold by the tier's other, unrelated keywords. + """ + fake_router = FakeEmbeddingRouter() + config = { + **basic_config, + "keyword_tier_rules": [ + {"keywords": ["kubernetes deployment", "thanks", "goodbye"], "tier": "REASONING"}, + ], + "semantic_keyword_matching": True, + "embedding_model": "fake-embed", + "match_threshold": 0.5, + } + router = ComplexityRouter( + model_name="test-router", + litellm_router_instance=fake_router, + complexity_router_config=config, + ) + # Only "kubernetes deployment" is close to this query (cos 1.0); "thanks" and + # "goodbye" are orthogonal (cos 0.0). Mean over the three would be ~0.33, below the + # 0.5 threshold; the best (max) utterance alone clears it. + result = await router.async_pre_routing_hook( + model="test-model", + request_kwargs={}, + messages=[{"role": "user", "content": "help me roll out my k8s cluster today"}], + ) + assert result is not None + assert result.model == "o1-preview" # REASONING via best-utterance semantic match + @pytest.mark.asyncio async def test_semantic_embedding_call_carries_caller_metadata(self, basic_config): """The query embedding call must carry the caller's metadata/litellm_metadata