Skip to content

feat(auto_router): keyword tier overrides and semantic keyword matching for the complexity router - #32859

Merged
akapur99 merged 12 commits into
litellm_internal_stagingfrom
litellm_complexity_router_keyword_tiers
Jul 11, 2026
Merged

feat(auto_router): keyword tier overrides and semantic keyword matching for the complexity router#32859
akapur99 merged 12 commits into
litellm_internal_stagingfrom
litellm_complexity_router_keyword_tiers

Conversation

@mateo-berri

@mateo-berri mateo-berri commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Relevant issues

Copy of #32829 by @akapur99, pushed to an in-repo litellm_ branch so CircleCI can run. All credit for this work goes to @akapur99. The branch tracks the original PR branch, with follow-up fixes committed here directly

Linear ticket

Pre-Submission checklist

Please complete all items before asking a LiteLLM maintainer to review your PR

  • I have added meaningful tests
  • My PR passes all CI/CD checks (e.g., lint, format, unit tests)
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have received a Greptile Confidence Score of at least 4/5 before requesting a maintainer review (Greptile reviews automatically once the PR is opened; only comment @greptileai to re-request a review after pushing changes)

Screenshots / Proof of Fix

Captured at commit 5ae72c8676 against real Anthropic and Voyage APIs (no mocks)

Lexical keyword tier overrides on a router with rules hi -> SIMPLE and beep -> COMPLEX (semantic off). Each keyword routes to its tier, and a prompt matching both escalates to the most-severe tier rather than the first rule in the list

"hi"       -> tier=SIMPLE   served=anthropic/claude-haiku-4-5
"beep"     -> tier=COMPLEX  served=anthropic/claude-sonnet-5
"hi beep"  -> tier=COMPLEX  served=anthropic/claude-sonnet-5   # escalated, not SIMPLE

Semantic keyword matching on a router with semantic_keyword_matching: true, embedding_model: voyage-3-5, match_threshold: 0.5. Paraphrases with no literal keyword still route via embeddings

"help me roll out my container cluster today"  -> REASONING  served=anthropic/claude-sonnet-5   # no literal "kubernetes"/"k8s"
"hey there, good morning"                      -> SIMPLE     served=anthropic/claude-haiku-4-5   # paraphrase of "hi"

Command shape used for each:

curl -s http://localhost:4000/v1/chat/completions -H "Authorization: Bearer sk-1234" \
  -H "Content-Type: application/json" \
  -d '{"model":"smart-router","messages":[{"role":"user","content":"hi beep"}]}'

Tested end-to-end from Claude Code pointed at the router as its default model (ANTHROPIC_BASE_URL -> the proxy, ANTHROPIC_MODEL=smart-router); requests reached the proxy over the Anthropic Messages API (/v1/messages, including streaming) and were classified and routed correctly

UI: Models + Endpoints -> Add Model -> Add Auto Router, Router Type set to "Auto-Router v2 [Recommended]" (default)

Screenshot 2026-07-10 at 5 34 23 PM Screenshot 2026-07-10 at 5 34 29 PM

Type

🆕 New Feature

Changes

Adds two routing capabilities to the complexity auto-router, fixes a semantic-scoring correctness bug, and hardens the proxy router lifecycle. All surfaced in the Add Auto Router UI behind a Router Type selector

Keyword tier overrides: a new keyword_tier_rules list ({keywords, tier}) on ComplexityRouterConfig, evaluated before the weighted/LLM scorer; a match routes straight to that tier. When several rules match, routing escalates to the most-severe matched tier (SIMPLE < MEDIUM < COMPLEX < REASONING) so the decision is independent of the order rules were authored in. keywords must contain at least one non-empty entry (validated at config load)

Semantic keyword matching (opt-in): with semantic_keyword_matching plus an embedding_model and match_threshold, the same rules are matched by embedding similarity so paraphrases route without a literal keyword. Reuses the existing LiteLLMRouterEncoder + SemanticRouter

Semantic scoring fix, MAX aggregation (the significant behavioral change): each tier's route holds one utterance per configured keyword. semantic_router's default MEAN aggregation averaged a genuine match on one keyword together with that tier's other unrelated keywords, dragging the score below match_threshold so real matches silently missed. Switched to MAX aggregation, so a strong match on any single keyword in a tier is what counts. This is what makes multi-keyword semantic rules actually fire as configured; without it the feature under-matches

Routing-time embedding safety: the query embedding carries the caller's request metadata so its spend is attributed and budget-enforced against the originating key/team. Only the parent request's budget reservation is stripped, not user_api_key_auth, which access-group filtering needs, so the embedding model selection stays scoped to the caller's authorized access groups. The lazy route index is built once under a per-router asyncio.Lock, off the event loop via to_thread, so concurrent cold-start requests neither each construct it nor block the worker on synchronous provider I/O; an embedding failure falls back to the scorer rather than failing the request

Proxy router lifecycle fixes: clear_cache no longer blanket-.clear()s the auto/complexity router registries (which left config-defined routers for other tenants permanently unroutable until a restart); it evicts only DB-backed auto_router/* entries by name. delete_model applies the same auto_router/-prefixed guard when popping a deleted deployment's router entry, so deleting a regular DB model that coincidentally shares a name with a config-defined router doesn't evict the config router

Frontend: restores the Router Type radio, where "Auto-Router v2 [Recommended]" (default) sends keyword_tier_rules and the semantic settings directly instead of flattening keywords into custom_technical_keywords, while "Semantic Router [to be deprecated]" keeps the existing utterance flow unchanged. Client-side guards mirror the backend validators (embedding model required, at least one rule, non-empty keywords). The "How Classification Works" explainer moved below Custom Technical Keywords. Test Connection is dropped from the recommended flow since it can't build a valid pre-save payload for a router (a TODO is left for a JSON preview)

Tests cover lexical escalation, semantic matching via the real library with injected embeddings, MAX-aggregation scoring, metadata propagation with budget-reservation stripping and access-group preservation on the embedding call, embedding-failure fallback, concurrent cold-start building the index exactly once, config validation (empty keywords and semantic requirements), the clear_cache config-router-preservation regression, delete_model registry eviction, and the frontend payload builder and guards


Note

High Risk
Touches request routing, embedding sub-calls, and budget-reservation metadata on a hot path, plus multi-tenant router registry behavior in clear_cache/delete_model where mistakes could break routing or budget accounting.

Overview
Adds keyword tier overrides and optional semantic keyword matching to the complexity auto-router, plus proxy lifecycle fixes and dashboard support on the “Auto-Router v2” add flow.

Routing: New keyword_tier_rules on ComplexityRouterConfig run before heuristic/LLM scoring; multiple matches escalate to the highest tier (SIMPLE → REASONING). With semantic_keyword_matching, the same rules match via embeddings (SemanticRouter + LiteLLMRouterEncoder), using MAX aggregation so one strong keyword match isn’t diluted by other utterances on the tier. Query embeddings forward caller metadata for spend/budget while stripping parent budget reservation (sanitized user_api_key_auth kept for access groups); semantic failures fall back to scoring. Route index builds once under a lock, off the event loop.

Proxy: clear_cache and delete_model no longer blanket-clear router registries—they evict only DB-backed auto_router/* deployments by name so config-defined routers aren’t left unroutable after unrelated DB model updates/deletes.

UI: Add Auto Router exposes keyword rules, semantic toggle, embedding model, and threshold via buildComplexityRouterConfig / client validation; dev config adds a sample embedding model for local testing.

Reviewed by Cursor Bugbot for commit 8982e1c. Bugbot is set up for automated code reviews on this repo. Configure here.

Copy link
Copy Markdown
Contributor Author

@greptileai


Generated by Claude Code

@greptile-apps

greptile-apps Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Adds keyword tier overrides and optional semantic keyword matching to the complexity auto-router, fixes proxy lifecycle bugs in clear_cache/delete_model, and surfaces the new settings in the Add Auto Router UI.

  • Core routing: New keyword_tier_rules on ComplexityRouterConfig are evaluated before the scorer; multiple lexical matches escalate to the most-severe tier. With semantic_keyword_matching, rules match via embedding similarity using a lazily-built SemanticRouter (MAX aggregation, lock-guarded singleton, off-loop build); query embeddings forward caller metadata while stripping the parent budget reservation.
  • Proxy lifecycle: clear_cache and delete_model now guard auto/complexity router registry evictions behind an auto_router/ prefix check, preventing config-defined routers from being permanently evicted by unrelated DB model updates.
  • Frontend: The Add Auto Router tab gains a Router Type selector; the "Auto-Router v2" path assembles keyword_tier_rules and semantic settings via buildComplexityRouterConfig, with client-side guards mirroring the backend validators.

Confidence Score: 5/5

Safe to merge; all new routing paths fall back gracefully to the existing scorer, config validation catches misconfiguration at load time, and the proxy lifecycle fixes are guarded and regression-tested.

The keyword tier override and semantic matching paths are well-isolated: a lexical miss or semantic failure each return None and let scoring decide as before. The double-checked async lock plus to_thread build is correct for CPython. The metadata sanitization is verified end-to-end against the actual cost-callback reader. The clear_cache and delete_model fixes address the exact cross-tenant registry bug with precise guards and dedicated regression tests.

complexity_router.py — the route-layer build has no failure-state cache, so a persistently-unavailable embedding provider causes every request to retry the build and absorb the full embedding timeout before falling back to scoring.

Important Files Changed

Filename Overview
litellm/router_strategy/complexity_router/complexity_router.py Adds keyword tier overrides (lexical + semantic) before the scorer, budget-reservation metadata sanitization for sub-calls, and a lock-guarded lazy SemanticRouter build. Core logic is sound; a minor concern exists around retrying the route-layer build on every request after a failed cold-start embedding call.
litellm/router_strategy/complexity_router/config.py Adds KeywordTierRule (with blank-keyword normalization), TIER_SEVERITY_ORDER, and semantic-matching fields to ComplexityRouterConfig, all guarded by validators that catch misconfiguration at load time.
litellm/proxy/management_endpoints/model_management_endpoints.py Fixes clear_cache (selective auto_router/* eviction instead of blanket .clear()) and delete_model (guards registry pop on auto_router/ prefix so config-defined routers survive DB model deletions); both with matching regression tests.
tests/test_litellm/router_strategy/test_complexity_router.py Comprehensive new test classes for lexical escalation, semantic matching via FakeEmbeddingRouter, MAX-aggregation, metadata propagation and budget-stripping, lock-based cold-start singleton, embedding failure fallback, and config validation. All mock-based, no real network calls.
tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py New test classes cover clear_cache config-router preservation, delete_model registry eviction with and without auto_router/ prefix, and the update_model cache-clear regression. All use MagicMock/AsyncMock.
ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts New utility that builds the complexity router config payload; trims and filters empty keyword rules before submission, mirroring the backend validator.
ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx Refactored to split Auto-Router v2 (recommended) and Semantic Router (legacy) flows; dead custom_embedding_model branch removed.
ui/litellm-dashboard/src/components/add_model/handle_add_auto_router_submit.tsx Simplified submit handler; complexity router path passes complexity_router_config directly; semantic router path preserved for legacy flow.
ui/litellm-dashboard/src/components/add_model/KeywordTierRules.tsx New component for managing keyword tier rule rows with add/remove and keyword tag input.
ui/litellm-dashboard/src/components/add_model/SemanticKeywordMatching.tsx New component exposing the semantic matching toggle, embedding model selector, and match threshold slider.
ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx Extended with optional keyword tier rules and semantic matching sections; sections hidden when change handlers are absent.

Reviews (16): Last reviewed commit: "Merge remote-tracking branch 'origin/lit..." | Re-trigger Greptile

Comment thread litellm/router_strategy/complexity_router/config.py
Comment thread litellm/router_strategy/complexity_router/complexity_router.py Outdated
Comment thread litellm/router_strategy/complexity_router/complexity_router.py Outdated
@veria-ai

veria-ai Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

PR overview

All previously flagged issues have been addressed. No open security concerns remain on this pull request.

Security review

No open security issues remain on this pull request.

Fixed/addressed: 5 · PR risk: 0/10

@greptile-apps

greptile-apps Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds two new capabilities to the complexity auto-router: deterministic keyword-to-tier overrides (keyword_tier_rules) evaluated before the weighted scorer, and optional embedding-based semantic keyword matching via the existing LiteLLMRouterEncoder/SemanticRouter machinery. It also fixes a clear_cache bug where auto_routers.clear() was wiping config-defined routers that can never be restored by the DB-model reload that follows. The frontend restores the Router Type selector with "Auto-Router v2" as the recommended path and introduces new KeywordTierRules and SemanticKeywordMatching sub-components.

  • Backend (complexity_router.py, config.py): lexical escalation picks the most-severe matched tier; semantic mode reuses SemanticRouter.acall; the _semantic_routelayer is lazily built and cached on the instance; ComplexityRouterConfig gains a model_validator that enforces embedding_model + non-empty rules when semantic matching is on.
  • clear_cache fix (model_management_endpoints.py): replaces blanket .clear() with targeted .pop() for DB-model names only, preventing config-defined routers from going permanently unroutable after any DB model write.
  • Frontend (add_auto_router_tab.tsx, new components): clean separation between recommended and semantic submit paths; buildComplexityRouterConfig utility extracted and unit-tested; client-side guard mirrors the backend validator for semantic config.

Confidence Score: 4/5

Safe to merge; the backend logic and the clear_cache fix are correct and well-tested. The only rough edges are two deleted frontend tests that should have been migrated and a small dead-code remnant in the submit handler.

The lexical escalation, semantic override, and targeted cache-clearing are all implemented correctly and covered by new tests. Two existing tests in ComplexityRouterConfig.test.tsx were deleted rather than updated to the new required-prop signature — the component content they exercised still exists, so this is a test coverage gap rather than a functional problem. A dead custom_embedding_model branch remains in handle_add_auto_router_submit.tsx after the "Enter custom model name" option was removed from the Select. Neither issue affects runtime behavior.

ComplexityRouterConfig.test.tsx (deleted tests) and handle_add_auto_router_submit.tsx (dead code branch).

Important Files Changed

Filename Overview
litellm/router_strategy/complexity_router/complexity_router.py Adds lexical escalation (_lexical_tier_override, escalates to most-severe matched tier) and semantic override (_semantic_tier_override via SemanticRouter.acall). Both plug into async_pre_routing_hook before the scorer. Logic is sound and follows the existing auto_router pattern for SemanticRouter initialization.
litellm/router_strategy/complexity_router/config.py Adds KeywordTierRule, TIER_SEVERITY_ORDER, and new config fields (keyword_tier_rules, semantic_keyword_matching, embedding_model, match_threshold) with a model_validator that enforces embedding_model and non-empty rules when semantic matching is enabled. Config is clean and well-validated.
litellm/proxy/management_endpoints/model_management_endpoints.py Fixes clear_cache to only pop DB-backed entries from auto_routers and complexity_routers instead of calling .clear() on both; preserves config-defined routers that would otherwise go permanently unroutable until restart.
tests/test_litellm/router_strategy/test_complexity_router.py Adds thorough new test classes for lexical escalation, semantic matching (using FakeEmbeddingRouter — no real network calls), config validation, and edge-case branches. No real network calls, satisfying the test isolation rule.
tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py Converts auto_routers mock to a real dict and adds complexity_routers; new TestClearCachePreservesConfigRouters regression test verifies config-defined routers survive a DB-model reload cycle.
ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts New utility that builds the complexity router config payload; omits optional fields when empty and includes semantic fields only when the toggle is on. Well-covered by co-located unit tests.
ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx Router type renamed from "complexity/semantic" to "recommended/semantic"; submit logic split into two clean handlers. Removes Test Connection button (with TODO comment) and the custom model entry option from the semantic router dropdowns.
ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx Adds new required props (keywordTierRules, semanticMatchingEnabled, embeddingModel, matchThreshold) and renders the new KeywordTierRules and SemanticKeywordMatching sub-components below the existing config.
ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.test.tsx Updated to use baseProps for the new required props; adds good coverage for KeywordTierRules and SemanticKeywordMatching. Two existing tests for "How Classification Works" were deleted instead of migrated, losing coverage for that section.
ui/litellm-dashboard/src/components/add_model/KeywordTierRules.tsx New component for managing keyword-to-tier override rules with add/remove/update operations. Uses AntdSelect in tags mode with tokenSeparators for keyword entry.
ui/litellm-dashboard/src/components/add_model/SemanticKeywordMatching.tsx New component exposing a Switch to enable semantic matching, plus (when enabled) an embedding model select and match threshold input. DEFAULT_MATCH_THRESHOLD is exported for reuse in the parent tab.
ui/litellm-dashboard/src/components/add_model/handle_add_auto_router_submit.tsx Minor refactor; dead code remains in the custom_embedding_model branch since the "Enter custom model name" option was removed from the upstream Select.
ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.test.ts New unit tests for buildComplexityRouterConfig and getSemanticConfigError covering all conditional branches including the "toggle off with lingering embedding model" edge case.

Comments Outside Diff (1)

  1. ui/litellm-dashboard/src/components/add_model/handle_add_auto_router_submit.tsx, line 742-744 (link)

    P2 Dead code: custom_embedding_model branch is now unreachable

    The add_auto_router_tab.tsx diff removes the { value: "custom", label: "Enter custom model name" } option from the embedding model AntdSelect in the semantic router path — so values.auto_router_embedding_model can never be "custom", and values.custom_embedding_model is never set. Both the !== "custom" guard and the else if (values.custom_embedding_model) branch are therefore dead code after this change.

Reviews (2): Last reviewed commit: "feat(auto_router): keyword tier override..." | Re-trigger Greptile

@codspeed-hq

codspeed-hq Bot commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_complexity_router_keyword_tiers (973a86a) with litellm_internal_staging (36aec56)

Open in CodSpeed

Copy link
Copy Markdown
Contributor Author

@greptileai


Generated by Claude Code

@codecov

codecov Bot commented Jul 11, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.56098% with 3 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...er_strategy/complexity_router/complexity_router.py 97.40% 2 Missing ⚠️
...management_endpoints/model_management_endpoints.py 95.45% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

Copy link
Copy Markdown
Contributor Author

bugbot run


Generated by Claude Code

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for all 3 issues found in the latest run.

  • ✅ Fixed: Router pop on name collision
    • clear_cache now only pops auto/complexity router entries whose matching DB row is itself an auto_router/* deployment, so a plain DB chat model sharing a model_name with a config-defined router can no longer evict it.
  • ✅ Fixed: Embedding errors skip scorer fallback
    • _resolve_keyword_tier_override now catches exceptions from the semantic path, falls back to lexical rules, and (on further miss) allows the caller to proceed to the weighted scorer instead of hard-failing the request.
  • ✅ Fixed: Keyword rules ignore system text
    • async_pre_routing_hook now passes system+user text into the tier override helpers, matching how the weighted scorer already scans that combined text for code, technical, and simple signals.

Create PR

Or push these changes by commenting:

@cursor push 6ff2274c9d
Preview (6ff2274c9d)
diff --git a/litellm/proxy/management_endpoints/model_management_endpoints.py b/litellm/proxy/management_endpoints/model_management_endpoints.py
--- a/litellm/proxy/management_endpoints/model_management_endpoints.py
+++ b/litellm/proxy/management_endpoints/model_management_endpoints.py
@@ -1715,8 +1715,22 @@
         for model_id in db_model_ids:
             llm_router.delete_deployment(id=model_id)
 
-        # Clear auto routers
-        llm_router.auto_routers.clear()
+        # Clear only DB-backed auto/complexity routers, keyed by model_name. A blanket
+        # .clear() would also drop config-defined routers, which are never re-added below
+        # (add_deployment only reloads DB models) - leaving them permanently unroutable
+        # until a full proxy restart, for every tenant, whenever any team admin updates
+        # any team-owned DB model. Only pop entries whose DB row is itself an
+        # auto/complexity router deployment; otherwise a DB model that happens to
+        # share a model_name with a config-defined router would evict it.
+        db_router_model_names = {
+            model.get("model_name")
+            for model in current_models
+            if model.get("model_info", {}).get("db_model", False)
+            and str(model.get("litellm_params", {}).get("model", "")).startswith("auto_router/")
+        }
+        for model_name in db_router_model_names:
+            llm_router.auto_routers.pop(model_name, None)
+            llm_router.complexity_routers.pop(model_name, None)
 
         # Reload only DB models
         await proxy_config.add_deployment(prisma_client=prisma_client, proxy_logging_obj=proxy_logging_obj)

diff --git a/litellm/router_strategy/complexity_router/complexity_router.py b/litellm/router_strategy/complexity_router/complexity_router.py
--- a/litellm/router_strategy/complexity_router/complexity_router.py
+++ b/litellm/router_strategy/complexity_router/complexity_router.py
@@ -20,16 +20,20 @@
     DEFAULT_REASONING_KEYWORDS,
     DEFAULT_SIMPLE_KEYWORDS,
     DEFAULT_TECHNICAL_KEYWORDS,
+    TIER_SEVERITY_ORDER,
     ComplexityRouterConfig,
     ComplexityTier,
 )
 
 if TYPE_CHECKING:
+    from semantic_router.routers import SemanticRouter
+
     from litellm.router import Router
     from litellm.types.router import PreRoutingHookResponse
 else:
     Router = Any
     PreRoutingHookResponse = Any
+    SemanticRouter = Any
 
 
 def _append_custom_keywords(base_keywords: list[str], custom_keywords: Optional[list[str]]) -> list[str]:
@@ -104,6 +108,10 @@
         )
         self.simple_keywords = self.config.simple_keywords or DEFAULT_SIMPLE_KEYWORDS
 
+        # Lazily built on first semantic request and cached for reuse (route
+        # embeddings are static, only the prompt is embedded per request).
+        self._semantic_routelayer: Optional[SemanticRouter] = None
+
         # Pre-compile regex patterns for efficiency
         # Use non-greedy .*? to prevent ReDoS on pathological inputs
         self._multi_step_patterns = [
@@ -325,6 +333,99 @@
 
         raise ValueError(f"No model configured for tier {tier_key} and no default_model set")
 
+    def _lexical_tier_override(self, user_message: str) -> Optional[ComplexityTier]:
+        """When keyword_tier_rules match literally, the most-severe matched tier wins.
+
+        Escalating to the highest tier (rather than the first rule in the list) keeps
+        routing independent of the order rules were authored in: a prompt hitting both a
+        SIMPLE and a REASONING keyword routes to REASONING.
+        """
+        rules = self.config.keyword_tier_rules
+        if not rules:
+            return None
+        text = user_message.lower()
+        matched_tiers = [
+            rule.tier for rule in rules if any(self._keyword_matches(text, keyword) for keyword in rule.keywords)
+        ]
+        if not matched_tiers:
+            return None
+        return max(matched_tiers, key=TIER_SEVERITY_ORDER.index)
+
+    def _get_or_create_semantic_routelayer(self) -> "SemanticRouter":
+        """Build (once) a SemanticRouter with one route per tier, utterances = that tier's keywords."""
+        if self._semantic_routelayer is not None:
+            return self._semantic_routelayer
+
+        from semantic_router.routers import SemanticRouter
+        from semantic_router.routers.base import Route
+
+        from litellm.router_strategy.auto_router.litellm_encoder import (
+            LiteLLMRouterEncoder,
+        )
+
+        embedding_model = self.config.embedding_model
+        if embedding_model is None:
+            raise ValueError("embedding_model is required for semantic keyword matching")
+
+        rules = self.config.keyword_tier_rules or []
+        ordered_tiers = tuple(dict.fromkeys(rule.tier.value for rule in rules))
+        routes = [
+            Route(
+                name=tier,
+                utterances=[keyword for rule in rules if rule.tier.value == tier for keyword in rule.keywords],
+                score_threshold=self.config.match_threshold,
+            )
+            for tier in ordered_tiers
+        ]
+        routelayer = SemanticRouter(
+            routes=routes,
+            encoder=LiteLLMRouterEncoder(
+                litellm_router_instance=self.litellm_router_instance,
+                model_name=embedding_model,
+                score_threshold=self.config.match_threshold,
+            ),
+            auto_sync="local",
+        )
+        self._semantic_routelayer = routelayer
+        return routelayer
+
+    async def _semantic_tier_override(self, user_message: str) -> Optional[ComplexityTier]:
+        """Match the prompt against keyword_tier_rules by embedding similarity."""
+        from semantic_router.schema import RouteChoice
+
+        routelayer = self._get_or_create_semantic_routelayer()
+        route_choice = await routelayer.acall(text=user_message)
+
+        if isinstance(route_choice, list):
+            route_choice = route_choice[0] if route_choice else None
+        if not isinstance(route_choice, RouteChoice) or not route_choice.name:
+            return None
+        try:
+            return ComplexityTier(route_choice.name)
+        except ValueError:
+            return None
+
+    async def _resolve_keyword_tier_override(self, keyword_text: str) -> Optional[ComplexityTier]:
+        """Resolve a keyword_tier_rule override, semantically or lexically per config.
+
+        Semantic mode degrades to lexical matching (and, if that also yields no
+        match, to the weighted scorer via the caller returning None) when the
+        embedding call fails, so a transient embedding outage does not turn
+        routing enhancements into hard request failures.
+        """
+        if not self.config.keyword_tier_rules:
+            return None
+        if self.config.semantic_keyword_matching:
+            try:
+                return await self._semantic_tier_override(keyword_text)
+            except Exception as exc:
+                verbose_router_logger.warning(
+                    f"ComplexityRouter: semantic keyword matching failed ({exc}), "
+                    "falling back to lexical keyword_tier_rules"
+                )
+                return self._lexical_tier_override(keyword_text)
+        return self._lexical_tier_override(keyword_text)
+
     def _resolve_messages(
         self,
         messages: Optional[List[Dict[str, Any]]],
@@ -445,6 +546,18 @@
                 messages=messages if has_original_messages else None,
             )
 
+        keyword_text = f"{system_prompt or ''} {user_message}".strip()
+        override_tier = await self._resolve_keyword_tier_override(keyword_text)
+        if override_tier is not None:
+            routed_model = self.get_model_for_tier(override_tier)
+            verbose_router_logger.info(
+                f"ComplexityRouter: keyword rule fired, tier={override_tier.value}, routed_model={routed_model}"
+            )
+            return PreRoutingHookResponse(
+                model=routed_model,
+                messages=messages if has_original_messages else None,
+            )
+
         tier, score, signals = self.classify(user_message, system_prompt)
         routed_model = self.get_model_for_tier(tier)
 

diff --git a/litellm/router_strategy/complexity_router/config.py b/litellm/router_strategy/complexity_router/config.py
--- a/litellm/router_strategy/complexity_router/config.py
+++ b/litellm/router_strategy/complexity_router/config.py
@@ -8,7 +8,7 @@
 from enum import Enum
 from typing import Dict, List, Optional
 
-from pydantic import BaseModel, ConfigDict, Field
+from pydantic import BaseModel, ConfigDict, Field, model_validator
 
 
 class ComplexityTier(str, Enum):
@@ -20,6 +20,26 @@
     REASONING = "REASONING"
 
 
+TIER_SEVERITY_ORDER: tuple[ComplexityTier, ...] = (
+    ComplexityTier.SIMPLE,
+    ComplexityTier.MEDIUM,
+    ComplexityTier.COMPLEX,
+    ComplexityTier.REASONING,
+)
+
+
+class KeywordTierRule(BaseModel):
+    """A deterministic override: if any keyword matches, route to this tier."""
+
+    keywords: List[str] = Field(
+        min_length=1,
+        description="Keywords/phrases that trigger this rule (lexical or semantic match)",
+    )
+    tier: ComplexityTier = Field(
+        description="Tier to route to when this rule matches",
+    )
+
+
 # ─── Default Keyword Lists ───
 # Note: Keywords should be full words/phrases to avoid substring false positives.
 # The matching logic uses word boundary detection for single-word keywords.
@@ -257,8 +277,40 @@
         description="Default model to use if tier cannot be determined",
     )
 
+    # Deterministic keyword -> tier overrides, evaluated before weighted scoring
+    keyword_tier_rules: Optional[List[KeywordTierRule]] = Field(
+        default=None,
+        description="Rules that force a specific tier when their keywords match the prompt",
+    )
+
+    # Semantic (embedding) matching for keyword_tier_rules instead of literal text matching
+    semantic_keyword_matching: bool = Field(
+        default=False,
+        description="Match keyword_tier_rules by embedding similarity instead of literal text",
+    )
+    embedding_model: Optional[str] = Field(
+        default=None,
+        description="Embedding model (LiteLLM model name) used when semantic_keyword_matching is enabled",
+    )
+    match_threshold: float = Field(
+        default=0.5,
+        ge=0.0,
+        le=1.0,
+        description="Minimum cosine similarity for a semantic keyword match",
+    )
+
     model_config = ConfigDict(extra="allow")  # Allow additional fields
 
+    @model_validator(mode="after")
+    def _validate_semantic_matching(self) -> "ComplexityRouterConfig":
+        if not self.semantic_keyword_matching:
+            return self
+        if not self.embedding_model:
+            raise ValueError("embedding_model is required when semantic_keyword_matching is enabled")
+        if not self.keyword_tier_rules:
+            raise ValueError("keyword_tier_rules must be non-empty when semantic_keyword_matching is enabled")
+        return self
 
+
 # Combined default config
 DEFAULT_COMPLEXITY_CONFIG = ComplexityRouterConfig()

diff --git a/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py
--- a/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py
+++ b/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py
@@ -436,28 +436,32 @@
             clear_cache,
         )
 
-        # Create mock router with mixed DB and config models
+        # Create mock router with mixed DB and config models. DB-backed router
+        # entries only exist for deployments whose litellm_params.model is an
+        # auto_router/* prefix, matching how init_auto_router_deployment and
+        # init_complexity_router_deployment gate creation.
         mock_router = MagicMock()
         mock_router.model_list = [
             {
                 "model_name": "gpt-4",
                 "model_info": {"id": "db-model-1", "db_model": True},
-                "litellm_params": {"model": "gpt-4"},
+                "litellm_params": {"model": "auto_router/foo"},
             },
             {
                 "model_name": "gpt-3.5-turbo",
                 "model_info": {"id": "config-model-1", "db_model": False},
-                "litellm_params": {"model": "gpt-3.5-turbo"},
+                "litellm_params": {"model": "auto_router/foo"},
             },
             {
                 "model_name": "claude-3",
                 "model_info": {"id": "db-model-2", "db_model": True},
-                "litellm_params": {"model": "claude-3"},
+                "litellm_params": {"model": "auto_router/complexity_router"},
             },
         ]
         mock_router.delete_deployment = MagicMock(return_value=True)
-        mock_router.auto_routers = MagicMock()
-        mock_router.auto_routers.clear = MagicMock()
+        # Real dicts (not MagicMock) so we can assert on their actual contents below.
+        mock_router.auto_routers = {"gpt-4": MagicMock(), "gpt-3.5-turbo": MagicMock()}
+        mock_router.complexity_routers = {"claude-3": MagicMock(), "gpt-3.5-turbo": MagicMock()}
 
         mock_config = MagicMock()
         mock_config.add_deployment = AsyncMock(return_value=True)
@@ -479,8 +483,14 @@
             mock_router.delete_deployment.assert_any_call(id="db-model-1")
             mock_router.delete_deployment.assert_any_call(id="db-model-2")
 
-            # Should have cleared auto routers
-            mock_router.auto_routers.clear.assert_called_once()
+            # DB-backed router entries (gpt-4, claude-3) are cleared so they can be
+            # re-populated by the reload below; the config-backed entry (gpt-3.5-turbo)
+            # must survive, since add_deployment() only reloads DB models and would
+            # otherwise leave it permanently unroutable (see TestClearCachePreservesConfigRouters).
+            assert "gpt-4" not in mock_router.auto_routers
+            assert "claude-3" not in mock_router.complexity_routers
+            assert "gpt-3.5-turbo" in mock_router.auto_routers
+            assert "gpt-3.5-turbo" in mock_router.complexity_routers
 
             # Should have called add_deployment to reload DB models
             mock_config.add_deployment.assert_called_once_with(
@@ -488,6 +498,101 @@
             )
 
 
+class TestClearCachePreservesConfigRouters:
+    """
+    Regression test: clear_cache() must not wipe config-defined auto/complexity
+    routers.
+
+    clear_cache() runs after any DB model write (e.g. a team admin patching a
+    team-owned model via PATCH /model/{id}/update). Before this fix, it called
+    auto_routers.clear() / complexity_routers.clear() unconditionally, which also
+    dropped routers defined in config.yaml belonging to *other* tenants. Those
+    entries are never restored, because the reload below only re-adds DB models
+    (proxy_config.add_deployment), so a config-defined router would stay
+    permanently unroutable until a full proxy restart - a cross-tenant
+    denial-of-service triggerable by any team admin's unrelated model update.
+    """
+
+    @pytest.mark.asyncio
+    async def test_config_backed_routers_survive_unrelated_db_model_update(self):
+        from litellm.proxy.management_endpoints.model_management_endpoints import (
+            clear_cache,
+        )
+
+        mock_router = MagicMock()
+        mock_router.model_list = [
+            {
+                "model_name": "team-a-db-router",
+                "model_info": {"id": "db-model-1", "db_model": True},
+                "litellm_params": {"model": "auto_router/complexity_router"},
+            },
+        ]
+        mock_router.delete_deployment = MagicMock(return_value=True)
+        mock_router.auto_routers = {"config-semantic-router": MagicMock()}
+        mock_router.complexity_routers = {
+            "team-a-db-router": MagicMock(),
+            "config-defined-complexity-router": MagicMock(),
+        }
+
+        mock_config = MagicMock()
+        mock_config.add_deployment = AsyncMock(return_value=True)
+
+        with (
+            patch("litellm.proxy.proxy_server.llm_router", mock_router),
+            patch("litellm.proxy.proxy_server.proxy_config", mock_config),
+            patch("litellm.proxy.proxy_server.prisma_client", MagicMock()),
+            patch("litellm.proxy.proxy_server.proxy_logging_obj", MagicMock()),
+            patch("litellm.proxy.proxy_server.verbose_proxy_logger"),
+        ):
+            await clear_cache()
+
+        # The DB-backed router for the model that was actually updated is cleared
+        # so the reload below can re-populate it.
+        assert "team-a-db-router" not in mock_router.complexity_routers
+        # Config-defined routers for unrelated tenants must survive untouched.
+        assert "config-defined-complexity-router" in mock_router.complexity_routers
+        assert "config-semantic-router" in mock_router.auto_routers
+
+    @pytest.mark.asyncio
+    async def test_config_router_survives_db_model_with_same_name(self):
+        """A DB-backed chat model that happens to share a model_name with a
+        config-defined router must not evict that router. Only DB rows that are
+        themselves auto/complexity router deployments (litellm_params.model
+        starts with ``auto_router/``) should clear the matching router entry.
+        """
+        from litellm.proxy.management_endpoints.model_management_endpoints import (
+            clear_cache,
+        )
+
+        shared_name = "shared-model-name"
+        mock_router = MagicMock()
+        mock_router.model_list = [
+            {
+                "model_name": shared_name,
+                "model_info": {"id": "db-plain-chat", "db_model": True},
+                "litellm_params": {"model": "openai/gpt-4o-mini"},
+            },
+        ]
+        mock_router.delete_deployment = MagicMock(return_value=True)
+        mock_router.auto_routers = {shared_name: MagicMock()}
+        mock_router.complexity_routers = {shared_name: MagicMock()}
+
+        mock_config = MagicMock()
+        mock_config.add_deployment = AsyncMock(return_value=True)
+
+        with (
+            patch("litellm.proxy.proxy_server.llm_router", mock_router),
+            patch("litellm.proxy.proxy_server.proxy_config", mock_config),
+            patch("litellm.proxy.proxy_server.prisma_client", MagicMock()),
+            patch("litellm.proxy.proxy_server.proxy_logging_obj", MagicMock()),
+            patch("litellm.proxy.proxy_server.verbose_proxy_logger"),
+        ):
+            await clear_cache()
+
+        assert shared_name in mock_router.auto_routers
+        assert shared_name in mock_router.complexity_routers
+
+
 class TestUpdateModel:
     """
     Tests for the update_model (POST /model/update) handler.

diff --git a/tests/test_litellm/router_strategy/test_complexity_router.py b/tests/test_litellm/router_strategy/test_complexity_router.py
--- a/tests/test_litellm/router_strategy/test_complexity_router.py
+++ b/tests/test_litellm/router_strategy/test_complexity_router.py
@@ -15,6 +15,7 @@
     0, os.path.abspath("../../..")
 )  # Adds the parent directory to the system path
 
+import litellm
 from litellm import Router
 from litellm.router_strategy.complexity_router.complexity_router import (
     ComplexityRouter,
@@ -26,6 +27,7 @@
     ComplexityRouterConfig,
     ComplexityTier,
 )
+from pydantic import ValidationError
 
 
 @pytest.fixture
@@ -1131,3 +1133,482 @@
         )
         assert user_msg is None
         assert sys_prompt is None
+
+
+class TestLexicalKeywordTierRules:
+    """Test deterministic (literal) keyword_tier_rules overrides."""
+
+    @pytest.fixture
+    def rule_config(self, basic_config) -> Dict:
+        return {
+            **basic_config,
+            "keyword_tier_rules": [
+                {"keywords": ["deploy to k8s"], "tier": "REASONING"},
+            ],
+        }
+
+    @pytest.mark.asyncio
+    async def test_matching_rule_overrides_scoring(
+        self, mock_router_instance, rule_config
+    ):
+        """A prompt hitting a rule keyword routes to that tier, not the scored tier."""
+        router = ComplexityRouter(
+            model_name="test-router",
+            litellm_router_instance=mock_router_instance,
+            complexity_router_config=rule_config,
+        )
+        prompt = "please deploy to k8s now"
+        # Without the rule this short prompt would not score into REASONING.
+        scored_tier, _, _ = router.classify(prompt)
+        assert scored_tier != ComplexityTier.REASONING
+
+        result = await router.async_pre_routing_hook(
+            model="test-model",
+            request_kwargs={},
+            messages=[{"role": "user", "content": prompt}],
+        )
+        assert result is not None
+        assert result.model == "o1-preview"  # REASONING tier model
+
+    @pytest.mark.asyncio
+    async def test_most_severe_tier_wins_regardless_of_rule_order(self, mock_router_instance, basic_config):
+        """When several rules match, the highest-severity tier wins, independent of list order."""
+        config = {
+            **basic_config,
+            "keyword_tier_rules": [
+                {"keywords": ["database"], "tier": "SIMPLE"},  # listed first, lower tier
+                {"keywords": ["database"], "tier": "REASONING"},  # listed later, higher tier
+            ],
+        }
+        router = ComplexityRouter(
+            model_name="test-router",
+            litellm_router_instance=mock_router_instance,
+            complexity_router_config=config,
+        )
+        result = await router.async_pre_routing_hook(
+            model="test-model",
+            request_kwargs={},
+            messages=[{"role": "user", "content": "tell me about the database"}],
+        )
+        assert result is not None
+        assert result.model == "o1-preview"  # REASONING wins over the earlier SIMPLE rule
+
+    @pytest.mark.asyncio
+    async def test_distinct_keywords_escalate_to_highest_tier(self, mock_router_instance, basic_config):
+        """A prompt hitting keywords across tiers routes to the most complex one."""
+        config = {
+            **basic_config,
+            "keyword_tier_rules": [
+                {"keywords": ["hi"], "tier": "SIMPLE"},
+                {"keywords": ["advise"], "tier": "COMPLEX"},
+                {"keywords": ["kubernetes"], "tier": "REASONING"},
+            ],
+        }
+        router = ComplexityRouter(
+            model_name="test-router",
+            litellm_router_instance=mock_router_instance,
+            complexity_router_config=config,
+        )
+        result = await router.async_pre_routing_hook(
+            model="test-model",
+            request_kwargs={},
+            messages=[{"role": "user", "content": "hi, advise me on kubernetes"}],
+        )
+        assert result is not None
+        assert result.model == "o1-preview"  # REASONING, the highest of SIMPLE/COMPLEX/REASONING
+
+    def test_lexical_override_returns_most_severe_matched_tier(self, mock_router_instance, basic_config):
+        """Unit-level check of the escalation helper across mixed matches."""
+        config = {
+            **basic_config,
+            "keyword_tier_rules": [
+                {"keywords": ["hi"], "tier": "SIMPLE"},
+                {"keywords": ["advise"], "tier": "COMPLEX"},
+            ],
+        }
+        router = ComplexityRouter(
+            model_name="test-router",
+            litellm_router_instance=mock_router_instance,
+            complexity_router_config=config,
+        )
+        assert router._lexical_tier_override("hi there, please advise") == ComplexityTier.COMPLEX
+        assert router._lexical_tier_override("just saying hi") == ComplexityTier.SIMPLE
+        assert router._lexical_tier_override("nothing relevant here") is None
+
+    @pytest.mark.asyncio
+    async def test_no_rule_match_falls_back_to_scoring(
+        self, mock_router_instance, basic_config
+    ):
+        """A prompt that matches no rule is classified by the scorer as usual."""
+        config = {
+            **basic_config,
+            "keyword_tier_rules": [
+                {"keywords": ["zzznomatch"], "tier": "REASONING"},
+            ],
+        }
+        router = ComplexityRouter(
+            model_name="test-router",
+            litellm_router_instance=mock_router_instance,
+            complexity_router_config=config,
+        )
+        result = await router.async_pre_routing_hook(
+            model="test-model",
+            request_kwargs={},
+            messages=[{"role": "user", "content": "Hello!"}],
+        )
+        assert result is not None
+        assert result.model == "gpt-4o-mini"  # SIMPLE via scoring, rule did not fire
+
+    def test_word_boundary_avoids_substring_false_positive(
+        self, mock_router_instance, basic_config
+    ):
+        """A single-word rule keyword must not match inside a larger word."""
+        config = {
+            **basic_config,
+            "keyword_tier_rules": [{"keywords": ["k8s"], "tier": "REASONING"}],
+        }
+        router = ComplexityRouter(
+            model_name="test-router",
+            litellm_router_instance=mock_router_instance,
+            complexity_router_config=config,
+        )
+        assert router._lexical_tier_override("running my k8s cluster") == ComplexityTier.REASONING
+        assert router._lexical_tier_override("what is a k8scluster thing") is None
+
+
+def _make_embedding_response(vectors: List[List[float]]) -> "litellm.EmbeddingResponse":
+    return litellm.EmbeddingResponse(
+        model="fake-embed",
+        data=[
+            {"embedding": vec, "index": idx, "object": "embedding"}
+            for idx, vec in enumerate(vectors)
+        ],
+        object="list",
+    )
+
+
+class FakeEmbeddingRouter:
+    """A stand-in router whose embeddings are deterministic 2D unit vectors.
+
+    Any text mentioning a cluster/container concept maps to [1, 0]; everything
+    else maps to [0, 1]. This lets the real SemanticRouter compute exact cosine
+    similarities (1.0 or 0.0) so threshold behavior is testable without a network call.
+    """
+
+    _CLUSTER_MARKERS = ("k8s", "kube", "container", "cluster", "orchestrat")
+
+    def __init__(self):
+        self.async_embedding_calls: List[List[str]] = []
+
+    def _vectors(self, docs: List[str]) -> List[List[float]]:
+        return [
+            [1.0, 0.0] if any(marker in doc.lower() for marker in self._CLUSTER_MARKERS) else [0.0, 1.0]
+            for doc in docs
+        ]
+
+    @staticmethod
+    def _as_list(text) -> List[str]:
+        return text if isinstance(text, list) else [text]
+
+    def embedding(self, input, model, **kwargs):
+        return _make_embedding_response(self._vectors(self._as_list(input)))
+
+    async def aembedding(self, input, model, **kwargs):
+        docs = self._as_list(input)
+        self.async_embedding_calls.append(docs)
+        return _make_embedding_response(self._vectors(docs))
+
+
+class TestSemanticKeywordTierRules:
+    """Test embedding-based keyword_tier_rules matching."""
+
+    @pytest.mark.asyncio
+    async def test_semantic_match_routes_to_rule_tier(self, basic_config):
+        """A paraphrase (no literal keyword) still routes via embedding similarity."""
+        fake_router = FakeEmbeddingRouter()
+        config = {
+            **basic_config,
+            "keyword_tier_rules": [
+                {"keywords": ["kubernetes deployment", "container orchestration"], "tier": "REASONING"},
+                {"keywords": ["hello", "thanks"], "tier": "SIMPLE"},
+            ],
+            "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,
+        )
+        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 semantic match
+        assert fake_router.async_embedding_calls, "expected an embedding call for the prompt"
+
+    @pytest.mark.asyncio
+    async def test_below_threshold_falls_back_to_scoring(self, basic_config):
+        """When no route clears the threshold, scoring decides the tier."""
+        fake_router = FakeEmbeddingRouter()
+        config = {
+            **basic_config,
+            "keyword_tier_rules": [
+                {"keywords": ["kubernetes deployment"], "tier": "REASONING"},
+            ],
+            "semantic_keyword_matching": True,
+            "embedding_model": "fake-embed",
+            "match_threshold": 0.9,
+        }
+        router = ComplexityRouter(
+            model_name="test-router",
+            litellm_router_instance=fake_router,
+            complexity_router_config=config,
+        )
+        # "hello there friend" embeds orthogonal to the REASONING route (cos 0 < 0.9).
+        result = await router.async_pre_routing_hook(
+            model="test-model",
+            request_kwargs={},
+            messages=[{"role": "user", "content": "hello there friend"}],
+        )
+        assert result is not None
+        assert result.model == "gpt-4o-mini"  # SIMPLE via scoring fallback
+
+    @pytest.mark.asyncio
+    async def test_route_embeddings_cached_across_requests(self, basic_config):
+        """The route layer is built once and reused on subsequent requests."""
+        fake_router = FakeEmbeddingRouter()
+        config = {
+            **basic_config,
+            "keyword_tier_rules": [
+                {"keywords": ["kubernetes deployment"], "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,
+        )
+        assert router._semantic_routelayer is None
+        await router.async_pre_routing_hook(
+            model="test-model",
+            request_kwargs={},
+            messages=[{"role": "user", "content": "roll out my k8s cluster"}],
+        )
+        first_layer = router._semantic_routelayer
+        assert first_layer is not None
+        await router.async_pre_routing_hook(
+            model="test-model",
+            request_kwargs={},
+            messages=[{"role": "user", "content": "scale my container cluster"}],
+        )
+        assert router._semantic_routelayer is first_layer
+
+
+class TestSemanticConfigValidation:
+    """Test config validation for semantic_keyword_matching."""
+
+    def test_semantic_without_embedding_model_raises(self):
+        with pytest.raises(ValidationError):
+            ComplexityRouterConfig(
+                semantic_keyword_matching=True,
+                keyword_tier_rules=[{"keywords": ["k8s"], "tier": "REASONING"}],
+            )
+
+    def test_semantic_without_rules_raises(self):
+        with pytest.raises(ValidationError):
+            ComplexityRouterConfig(
+                semantic_keyword_matching=True,
+                embedding_model="fake-embed",
+            )
+
+    def test_semantic_disabled_needs_no_embedding_model(self):
+        config = ComplexityRouterConfig(
+            keyword_tier_rules=[{"keywords": ["k8s"], "tier": "REASONING"}],
+        )
+        assert config.semantic_keyword_matching is False
+        assert config.match_threshold == 0.5
+
+    def test_rule_with_empty_keywords_raises(self):
+        with pytest.raises(ValidationError):
+            ComplexityRouterConfig(
+                keyword_tier_rules=[{"keywords": [], "tier": "REASONING"}],
+            )
+
+
+class _StubRouteLayer:
+    """Returns a fixed acall result so _semantic_tier_override branches can be exercised."""
+
+    def __init__(self, result):
+        self._result = result
+
+    async def acall(self, text=None):
+        return self._result
+
+
+class TestKeywordOverrideEdgeCases:
+    """Cover the defensive branches of the lexical and semantic override helpers."""
+
+    def _semantic_router(self, mock_router_instance, basic_config):
+        config = {
+            **basic_config,
+            "keyword_tier_rules": [{"keywords": ["kubernetes"], "tier": "REASONING"}],
+            "semantic_keyword_matching": True,
+            "embedding_model": "fake-embed",
+            "match_threshold": 0.5,
+        }
+        return ComplexityRouter(
+            model_name="test-router",
+            litellm_router_instance=mock_router_instance,
+            complexity_router_config=config,
+        )
+
+    def test_lexical_override_none_when_no_rules(self, mock_router_instance, basic_config):
+        """No keyword_tier_rules configured -> lexical override is a no-op."""
+        router = ComplexityRouter(
+            model_name="test-router",
+            litellm_router_instance=mock_router_instance,
+            complexity_router_config=basic_config,
+        )
+        assert router._lexical_tier_override("deploy to k8s and reason step by step") is None
+
+    def test_semantic_routelayer_requires_embedding_model(self, mock_router_instance, basic_config):
+        """Building the route layer without an embedding model raises (defensive invariant)."""
+        config = {**basic_config, "keyword_tier_rules": [{"keywords": ["k8s"], "tier": "REASONING"}]}
+        router = ComplexityRouter(
+            model_name="test-router",
... diff truncated: showing 800 of 2212 lines

You can send follow-ups to the cloud agent here.

Comment thread litellm/proxy/management_endpoints/model_management_endpoints.py
Comment thread litellm/router_strategy/complexity_router/complexity_router.py
Comment thread litellm/router_strategy/complexity_router/complexity_router.py
@mateo-berri
mateo-berri force-pushed the litellm_complexity_router_keyword_tiers branch from ec64fb2 to d8c46e0 Compare July 11, 2026 03:04

Copy link
Copy Markdown
Contributor Author

@greptileai


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

bugbot run


Generated by Claude Code

Comment thread litellm/router_strategy/complexity_router/complexity_router.py Outdated

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Semantic guard allows empty keywords
    • Extended getSemanticConfigError to reject any keyword tier rule with an empty keywords array when semantic matching is enabled, so users can no longer pass client validation with rules that would build empty semantic routes.

Create PR

Or push these changes by commenting:

@cursor push 29f89ebf3e
Preview (29f89ebf3e)
diff --git a/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.test.ts b/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.test.ts
--- a/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.test.ts
+++ b/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.test.ts
@@ -127,4 +127,22 @@
       getSemanticConfigError({ semanticMatchingEnabled: true, embeddingModel: "voyage-3-5", keywordTierRules: [rule] }),
     ).toBeNull();
   });
+
+  it("errors when enabled with a rule that has no keywords, so semantic routing can't silently no-op", () => {
+    const emptyRule = { id: "r2", keywords: [], tier: "SIMPLE" as const };
+    expect(
+      getSemanticConfigError({
+        semanticMatchingEnabled: true,
+        embeddingModel: "voyage-3-5",
+        keywordTierRules: [emptyRule],
+      }),
+    ).toMatch(/keyword/i);
+    expect(
+      getSemanticConfigError({
+        semanticMatchingEnabled: true,
+        embeddingModel: "voyage-3-5",
+        keywordTierRules: [rule, emptyRule],
+      }),
+    ).toMatch(/keyword/i);
+  });
 });

diff --git a/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts b/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts
--- a/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts
+++ b/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts
@@ -40,6 +40,8 @@
   if (!semanticMatchingEnabled) return null;
   if (!embeddingModel) return "Select an embedding model to use semantic keyword matching";
   if (keywordTierRules.length === 0) return "Add at least one keyword tier rule to use semantic keyword matching";
+  if (keywordTierRules.some((rule) => rule.keywords.length === 0))
+    return "Add at least one keyword to every tier rule to use semantic keyword matching";
   return null;
 };

You can send follow-ups to the cloud agent here.

Comment thread ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts Outdated
@mateo-berri
mateo-berri force-pushed the litellm_complexity_router_keyword_tiers branch from d8c46e0 to dc9ee1d Compare July 11, 2026 03:25

Copy link
Copy Markdown
Contributor Author

@greptileai


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

bugbot run


Generated by Claude Code

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

There are 4 total unresolved issues (including 2 from previous reviews).

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Empty keyword rules accepted
    • KeywordTierRule now requires min_length=1 and a validator that trims blanks and rejects rules with no non-blank keywords; the UI's buildComplexityRouterConfig sanitizes and drops empty rules, and getSemanticConfigError requires at least one rule with non-blank keywords.
  • ✅ Fixed: Deleted routers linger after reload
    • clear_cache now pops every auto_routers/complexity_routers entry whose model_name isn't backed by a config model, so DB routers deleted since the last reload no longer linger while config-defined routers survive.

Create PR

Or push these changes by commenting:

@cursor push 7745cd9f71
Preview (7745cd9f71)
diff --git a/litellm/proxy/management_endpoints/model_management_endpoints.py b/litellm/proxy/management_endpoints/model_management_endpoints.py
--- a/litellm/proxy/management_endpoints/model_management_endpoints.py
+++ b/litellm/proxy/management_endpoints/model_management_endpoints.py
@@ -1719,13 +1719,14 @@
         # .clear() would also drop config-defined routers, which are never re-added below
         # (add_deployment only reloads DB models) - leaving them permanently unroutable
         # until a full proxy restart, for every tenant, whenever any team admin updates
-        # any team-owned DB model.
-        db_model_names = {
-            model.get("model_name") for model in current_models if model.get("model_info", {}).get("db_model", False)
-        }
-        for model_name in db_model_names:
-            llm_router.auto_routers.pop(model_name, None)
-            llm_router.complexity_routers.pop(model_name, None)
+        # any team-owned DB model. Anything that is not config-defined is DB-backed
+        # (currently registered or removed since last reload), so pop it and let
+        # add_deployment reregister it below if the DB still has it.
+        config_model_names = {model.get("model_name") for model in config_models}
+        for router_map in (llm_router.auto_routers, llm_router.complexity_routers):
+            for model_name in tuple(router_map.keys()):
+                if model_name not in config_model_names:
+                    router_map.pop(model_name, None)
 
         # Reload only DB models
         await proxy_config.add_deployment(prisma_client=prisma_client, proxy_logging_obj=proxy_logging_obj)

diff --git a/litellm/router_strategy/complexity_router/config.py b/litellm/router_strategy/complexity_router/config.py
--- a/litellm/router_strategy/complexity_router/config.py
+++ b/litellm/router_strategy/complexity_router/config.py
@@ -8,7 +8,7 @@
 from enum import Enum
 from typing import Dict, List, Literal, Optional
 
-from pydantic import BaseModel, ConfigDict, Field, model_validator
+from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
 
 
 class ComplexityTier(str, Enum):
@@ -32,13 +32,24 @@
     """A deterministic override: if any keyword matches, route to this tier."""
 
     keywords: List[str] = Field(
+        min_length=1,
         description="Keywords/phrases that trigger this rule (lexical or semantic match)",
     )
     tier: ComplexityTier = Field(
         description="Tier to route to when this rule matches",
     )
 
+    @field_validator("keywords")
+    @classmethod
+    def _require_non_blank_keywords(cls, keywords: List[str]) -> List[str]:
+        cleaned = [kw.strip() for kw in keywords if kw and kw.strip()]
+        if not cleaned:
+            raise ValueError(
+                "KeywordTierRule.keywords must contain at least one non-blank keyword"
+            )
+        return cleaned
 
+
 # ─── Default Keyword Lists ───
 # Note: Keywords should be full words/phrases to avoid substring false positives.
 # The matching logic uses word boundary detection for single-word keywords.

diff --git a/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py
--- a/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py
+++ b/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py
@@ -523,6 +523,16 @@
                 "model_info": {"id": "db-model-1", "db_model": True},
                 "litellm_params": {"model": "auto_router/complexity_router"},
             },
+            {
+                "model_name": "config-defined-complexity-router",
+                "model_info": {"id": "config-model-1", "db_model": False},
+                "litellm_params": {"model": "auto_router/complexity_router"},
+            },
+            {
+                "model_name": "config-semantic-router",
+                "model_info": {"id": "config-model-2", "db_model": False},
+                "litellm_params": {"model": "auto_router/semantic-router"},
+            },
         ]
         mock_router.delete_deployment = MagicMock(return_value=True)
         mock_router.auto_routers = {"config-semantic-router": MagicMock()}
@@ -550,7 +560,50 @@
         assert "config-defined-complexity-router" in mock_router.complexity_routers
         assert "config-semantic-router" in mock_router.auto_routers
 
+    @pytest.mark.asyncio
+    async def test_orphaned_db_router_entries_are_cleared(self):
+        """Regression: after a DB router is deleted (so it no longer appears in
+        model_list), a subsequent clear_cache must pop its stale entry from
+        auto_routers / complexity_routers, otherwise async_pre_routing_hook keeps
+        dispatching to the deleted router until the proxy restarts."""
+        from litellm.proxy.management_endpoints.model_management_endpoints import (
+            clear_cache,
+        )
 
+        mock_router = MagicMock()
+        mock_router.model_list = [
+            {
+                "model_name": "config-defined-router",
+                "model_info": {"id": "config-1", "db_model": False},
+                "litellm_params": {"model": "auto_router/complexity_router"},
+            },
+        ]
+        mock_router.delete_deployment = MagicMock(return_value=True)
+        mock_router.auto_routers = {
+            "config-defined-router": MagicMock(),
+            "deleted-db-auto-router": MagicMock(),
+        }
+        mock_router.complexity_routers = {
+            "deleted-db-complexity-router": MagicMock(),
+        }
+
+        mock_config = MagicMock()
+        mock_config.add_deployment = AsyncMock(return_value=True)
+
+        with (
+            patch("litellm.proxy.proxy_server.llm_router", mock_router),
+            patch("litellm.proxy.proxy_server.proxy_config", mock_config),
+            patch("litellm.proxy.proxy_server.prisma_client", MagicMock()),
+            patch("litellm.proxy.proxy_server.proxy_logging_obj", MagicMock()),
+            patch("litellm.proxy.proxy_server.verbose_proxy_logger"),
+        ):
+            await clear_cache()
+
+        assert "deleted-db-auto-router" not in mock_router.auto_routers
+        assert "deleted-db-complexity-router" not in mock_router.complexity_routers
+        assert "config-defined-router" in mock_router.auto_routers
+
+
 class TestUpdateModel:
     """
     Tests for the update_model (POST /model/update) handler.

diff --git a/tests/test_litellm/router_strategy/test_complexity_router.py b/tests/test_litellm/router_strategy/test_complexity_router.py
--- a/tests/test_litellm/router_strategy/test_complexity_router.py
+++ b/tests/test_litellm/router_strategy/test_complexity_router.py
@@ -1453,6 +1453,66 @@
         assert router._lexical_tier_override("what is a k8scluster thing") is None
 
 
+class TestKeywordTierRuleValidation:
+    """Regression: KeywordTierRule must reject empty/blank keyword lists.
+
+    An empty keywords list produces a `\\b\\b` regex in the lexical matcher that
+    fires on every prompt, and yields a route with zero utterances in the
+    semantic matcher which breaks the first embedding-backed request. Guard at
+    config parse time so bad rules never reach either matcher.
+    """
+
+    def test_empty_keywords_list_rejected(self):
+        from pydantic import ValidationError
+
+        from litellm.router_strategy.complexity_router.config import (
+            ComplexityTier,
+            KeywordTierRule,
+        )
+
+        with pytest.raises(ValidationError):
+            KeywordTierRule(keywords=[], tier=ComplexityTier.REASONING)
+
+    def test_blank_only_keywords_rejected(self):
+        from pydantic import ValidationError
+
+        from litellm.router_strategy.complexity_router.config import (
+            ComplexityTier,
+            KeywordTierRule,
+        )
+
+        with pytest.raises(ValidationError):
+            KeywordTierRule(keywords=["", "  "], tier=ComplexityTier.REASONING)
+
+    def test_blank_entries_are_stripped_and_dropped(self):
+        from litellm.router_strategy.complexity_router.config import (
+            ComplexityTier,
+            KeywordTierRule,
+        )
+
+        rule = KeywordTierRule(
+            keywords=[" k8s ", "", " ", "docker"], tier=ComplexityTier.REASONING
+        )
+        assert rule.keywords == ["k8s", "docker"]
+
+    def test_empty_rule_via_router_config_rejected(self, mock_router_instance, basic_config):
+        """Even wrapped inside ComplexityRouterConfig, an empty-keywords rule
+        must fail validation instead of silently building a matcher that
+        classifies every prompt as this rule's tier."""
+        from pydantic import ValidationError
+
+        config = {
+            **basic_config,
+            "keyword_tier_rules": [{"keywords": [], "tier": "REASONING"}],
+        }
+        with pytest.raises(ValidationError):
+            ComplexityRouter(
+                model_name="test-router",
+                litellm_router_instance=mock_router_instance,
+                complexity_router_config=config,
+            )
+
+
 def _make_embedding_response(vectors: List[List[float]]) -> "litellm.EmbeddingResponse":
     return litellm.EmbeddingResponse(
         model="fake-embed",

diff --git a/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.test.ts b/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.test.ts
--- a/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.test.ts
+++ b/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.test.ts
@@ -99,6 +99,29 @@
     expect(config.custom_technical_keywords).toBeUndefined();
     expect(config.keyword_tier_rules).toBeUndefined();
   });
+
+  it("drops keyword tier rules whose keywords are empty or blank", () => {
+    const config = buildComplexityRouterConfig({
+      ...baseParams,
+      keywordTierRules: [
+        { id: "empty", keywords: [], tier: "COMPLEX" },
+        { id: "blank", keywords: ["", "  "], tier: "COMPLEX" },
+        { id: "keep", keywords: [" k8s ", "", "docker"], tier: "REASONING" },
+      ],
+    });
+    expect(config.keyword_tier_rules).toEqual([{ keywords: ["k8s", "docker"], tier: "REASONING" }]);
+  });
+
+  it("omits keyword_tier_rules entirely when every rule sanitizes to empty", () => {
+    const config = buildComplexityRouterConfig({
+      ...baseParams,
+      keywordTierRules: [
+        { id: "empty", keywords: [], tier: "COMPLEX" },
+        { id: "blank", keywords: ["  "], tier: "REASONING" },
+      ],
+    });
+    expect(config.keyword_tier_rules).toBeUndefined();
+  });
 });
 
 describe("getSemanticConfigError", () => {
@@ -122,6 +145,19 @@
     ).toMatch(/keyword tier rule/i);
   });
 
+  it("errors when enabled with rules that have no non-blank keywords", () => {
+    expect(
+      getSemanticConfigError({
+        semanticMatchingEnabled: true,
+        embeddingModel: "voyage-3-5",
+        keywordTierRules: [
+          { id: "r1", keywords: [], tier: "REASONING" },
+          { id: "r2", keywords: [" ", ""], tier: "REASONING" },
+        ],
+      }),
+    ).toMatch(/keyword/i);
+  });
+
   it("returns null when enabled with both an embedding model and rules", () => {
     expect(
       getSemanticConfigError({ semanticMatchingEnabled: true, embeddingModel: "voyage-3-5", keywordTierRules: [rule] }),

diff --git a/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts b/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts
--- a/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts
+++ b/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts
@@ -30,6 +30,16 @@
   match_threshold?: number;
 }
 
+const sanitizeRuleKeywords = (keywords: string[]): string[] =>
+  keywords.map((kw) => kw.trim()).filter((kw) => kw.length > 0);
+
+const rulesWithKeywords = (
+  keywordTierRules: KeywordTierRule[],
+): { keywords: string[]; tier: KeywordTierRule["tier"] }[] =>
+  keywordTierRules
+    .map((rule) => ({ keywords: sanitizeRuleKeywords(rule.keywords), tier: rule.tier }))
+    .filter((rule) => rule.keywords.length > 0);
+
 export const getSemanticConfigError = ({
   semanticMatchingEnabled,
   embeddingModel,
@@ -39,7 +49,9 @@
   | null => {
   if (!semanticMatchingEnabled) return null;
   if (!embeddingModel) return "Select an embedding model to use semantic keyword matching";
-  if (keywordTierRules.length === 0) return "Add at least one keyword tier rule to use semantic keyword matching";
+  if (rulesWithKeywords(keywordTierRules).length === 0) {
+    return "Add at least one keyword tier rule with keywords to use semantic keyword matching";
+  }
   return null;
 };
 
@@ -52,17 +64,18 @@
   semanticMatchingEnabled,
   embeddingModel,
   matchThreshold,
-}: BuildComplexityRouterConfigParams): ComplexityRouterConfigPayload => ({
-  tiers,
-  classifier_type: classifierType,
-  ...(classifierType === "llm" && classifierLlmConfig && { classifier_llm_config: classifierLlmConfig }),
-  ...(customTechnicalKeywords.length > 0 && { custom_technical_keywords: customTechnicalKeywords }),
-  ...(keywordTierRules.length > 0 && {
-    keyword_tier_rules: keywordTierRules.map((rule) => ({ keywords: rule.keywords, tier: rule.tier })),
-  }),
-  ...(semanticMatchingEnabled && {
-    semantic_keyword_matching: true,
-    embedding_model: embeddingModel,
-    match_threshold: matchThreshold,
-  }),
-});
+}: BuildComplexityRouterConfigParams): ComplexityRouterConfigPayload => {
+  const sanitizedRules = rulesWithKeywords(keywordTierRules);
+  return {
+    tiers,
+    classifier_type: classifierType,
+    ...(classifierType === "llm" && classifierLlmConfig && { classifier_llm_config: classifierLlmConfig }),
+    ...(customTechnicalKeywords.length > 0 && { custom_technical_keywords: customTechnicalKeywords }),
+    ...(sanitizedRules.length > 0 && { keyword_tier_rules: sanitizedRules }),
+    ...(semanticMatchingEnabled && {
+      semantic_keyword_matching: true,
+      embedding_model: embeddingModel,
+      match_threshold: matchThreshold,
+    }),
+  };
+};

You can send follow-ups to the cloud agent here.

Comment thread litellm/router_strategy/complexity_router/config.py
Comment thread litellm/proxy/management_endpoints/model_management_endpoints.py
@akapur99
akapur99 force-pushed the litellm_complexity_router_keyword_tiers branch from dc9ee1d to 664eab7 Compare July 11, 2026 03:59
@akapur99

Copy link
Copy Markdown
Contributor

@greptile-apps check and score

@akapur99

Copy link
Copy Markdown
Contributor

bugbot run

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

1 issue from previous review remains unresolved.

Fix All in Cursor

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 664eab7. Configure here.

Comment thread litellm/router_strategy/complexity_router/complexity_router.py
…ng for the complexity router

Add deterministic keyword-to-tier overrides and optional embedding-based
(semantic) keyword matching to the complexity router, and surface both in the
Add Auto Router UI behind a Router Type selector: "Auto-Router v2 [Recommended]"
(complexity tiers + keyword overrides + semantic matching, the default) and
"Semantic Router [to be deprecated]" (the existing utterance-based router,
unchanged). Keyword-to-tier overrides resolve to the highest tier matched
rather than the first keyword matched, so match order no longer affects the
routing decision.

Backend:
- config: KeywordTierRule model plus keyword_tier_rules, semantic_keyword_matching,
  embedding_model, and match_threshold on ComplexityRouterConfig, with a validator
  requiring an embedding model and rules when semantic matching is on
- complexity_router: evaluate keyword rules before scoring; lexical matches escalate
  to the most-severe matched tier (order-independent), and semantic mode reuses
  LiteLLMRouterEncoder + SemanticRouter to match paraphrases by cosine similarity,
  falling back to the scorer when nothing matches
- model management: clear complexity_routers on cache reload so config edits take effect

Frontend:
- Add Auto Router tab restores the Router Type radio (Auto-Router v2 recommended
  by default, Semantic Router still available) and sends keyword_tier_rules plus
  the semantic settings on the recommended path, instead of flattening keywords
  into custom_technical_keywords
- client-side guard blocks submit when semantic matching is enabled without an
  embedding model or without any keyword tier rules, mirroring the backend validator
- moved the "How Classification Works" explainer below Custom Technical Keywords
  and above Keyword Tier Overrides
- remove the Test Connection action from the recommended flow, which can't build a
  valid pre-save payload for a router (leaves a TODO for a JSON preview / config
  test follow-up)

Tests cover lexical escalation, semantic matching via the real library with injected
embeddings, the semantic config guard, config validation, the reload-clear
regression, and the frontend payload builder
@akapur99
akapur99 force-pushed the litellm_complexity_router_keyword_tiers branch from 664eab7 to 3c714ed Compare July 11, 2026 05:05

Copy link
Copy Markdown
Contributor Author

@greptileai


Generated by Claude Code

Comment thread litellm/router_strategy/complexity_router/complexity_router.py Outdated
@mateo-berri

Copy link
Copy Markdown
Contributor Author

Hey no need to stay up doing this but I appreciate the passion!

@akapur99

Copy link
Copy Markdown
Contributor

Summary for review

This adds two capabilities to the complexity auto-router and surfaces them in the Add Auto Router UI, plus two proxy router-lifecycle fixes

Keyword tier overrides add a keyword_tier_rules list ({keywords, tier}) to ComplexityRouterConfig that is evaluated before the weighted/LLM scorer; a match routes straight to that tier. When several rules match, routing escalates to the most-severe matched tier rather than the first one authored, so SIMPLE + REASONING resolves to REASONING independent of order. Lexical matching uses word-boundary detection to avoid substring false positives

Semantic keyword matching (opt-in via semantic_keyword_matching + embedding_model + match_threshold) matches paraphrases by embedding similarity, reusing the existing LiteLLMRouterEncoder + SemanticRouter machinery. It embeds the query itself so the caller's metadata/litellm_metadata flow into aembedding() for spend attribution, while stripping the parent budget reservation so the routing embedding doesn't consume the routed completion's budget. It fails open: any embedding error or a below-threshold result falls back to the scorer instead of failing the request. The route index is built lazily off the event loop and exactly once under an asyncio.Lock, so concurrent cold-start requests don't each fire duplicate embedding builds

Two proxy fixes keep the in-memory router registries consistent. clear_cache now selectively pops only DB-backed auto_router/* entries instead of a blanket .clear(), so config-defined routers (which add_deployment never restores) aren't wiped on a reload triggered by an unrelated DB write. delete_model applies the same auto_router/ prefix guard, so deleting a regular DB model that merely shares a name with a config-defined router no longer evicts that router

Frontend restores the Router Type selector ("Auto-Router v2 [Recommended]" default vs "Semantic Router [to be deprecated]"). The v2 path sends keyword_tier_rules plus the semantic settings directly via buildComplexityRouterConfig instead of flattening keywords into custom_technical_keywords, with new KeywordTierRules and SemanticKeywordMatching sub-components and a client-side guard mirroring the backend validator. Test Connection is dropped from the v2 flow since it can't build a valid pre-save router payload

Config validation requires keyword_tier_rules entries to have at least one non-empty keyword (min_length=1 plus a validator), and requires an embedding model and rules when semantic matching is on

Tests cover lexical escalation and order-independence, semantic matching through the real library with injected embeddings, metadata propagation, budget-reservation stripping, config validation, the semantic guard, concurrent-build-once, off-event-loop build, below-threshold and embedding-error fallback, and the reload-clear and delete-model name-collision regressions, plus the frontend payload builder

Two deliberate non-changes worth noting: semantic mode intentionally uses best-match rather than escalation (cosine similarities are directly comparable, so the highest-similarity cluster wins), and keyword overrides intentionally scope to the last user message rather than system text (a keyword in static system boilerplate would otherwise pin every request to one tier)

akapur99 and others added 2 commits July 11, 2026 11:14
…e scoring

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".
…itellm_complexity_router_keyword_tiers

# Conflicts:
#	ui/litellm-dashboard/eslint-metrics.json

Copy link
Copy Markdown
Contributor Author

@greptileai


Generated by Claude Code

@akapur99

Copy link
Copy Markdown
Contributor

Pushed eb0c966: semantic keyword matching now scores each tier's route with max aggregation instead of the semantic_router library's default mean

Each tier's route holds one utterance per configured keyword, so a real match against one keyword was being averaged together with the tier's other, unrelated keywords and dragged below match_threshold. Concretely, a MEDIUM tier configured with keywords [beep, boop, new york] never fired for a genuine "new york" paraphrase like "visiting the big apple next week": raw cosine similarity for that pair was 0.54 against a 0.5 threshold, but mean(sim_to_beep, sim_to_boop, sim_to_new_york) landed around 0.28 since beep/boop are unrelated to the query. Max aggregation fixes this by scoring the tier on its single closest keyword rather than the average across all of them, matching the intended semantics: the tier should fire if the query is close enough to any of its configured keywords, not to all of them at once

Verified against live Voyage embeddings and added a regression test with a tier holding one matching and two unrelated keywords, asserting the tier still fires; the test fails without aggregation="max"

Copy link
Copy Markdown
Contributor Author

bugbot run


Generated by Claude Code

Comment thread litellm/router_strategy/complexity_router/complexity_router.py
Removing user_api_key_auth entirely from classifier/embedding sub-call
metadata (as _BUDGET_RESERVATION_METADATA_KEYS previously did) prevented
_filter_deployments_by_model_access_groups from scoping those sub-calls to
the caller's authorized access groups. An access-group-scoped caller could
therefore reach embedding/classifier deployments outside their group.

Only strip user_api_key_budget_reservation, which is the actual budget-
reservation state that must not reach sub-calls. user_api_key_auth is now
kept so access-group filtering works correctly for both the embedding path
and the LLM classifier path.
@akapur99

Copy link
Copy Markdown
Contributor

Concise data point for how mean vs max aggregation affects keyword_tier_rules under semantic_keyword_matching

Ran this against live voyage-3 embeddings. Setup: four tiers (SIMPLE/MEDIUM/COMPLEX/REASONING), COMPLEX holds ["distributed systems architecture", "kubernetes deployment", "database sharding strategy"]. The prompt is a long paragraph asking for a kubernetes deployment plan, but it also brushes past "explain" and "tradeoffs" without actually asking for either

Per-keyword cosine similarity to the prompt: kubernetes deployment 0.450, distributed systems architecture 0.116, database sharding strategy 0.135

Mean aggregation across COMPLEX's three keywords lands at 0.233, roughly half the real signal from the one keyword that actually matched. Max aggregation keeps it at 0.450, the true best match. At a threshold around 0.35 (realistic for keyword-vs-paragraph voyage-3 cosines, which run lower than word-vs-word matches), COMPLEX fails under mean and passes under max: the exact scenario this commit fixes

Worth noting the fix's scope is narrow: max aggregation only prevents dilution from a tier's own other keywords. It doesn't stop unrelated generic keywords like "explain" or "compare" from picking up stray signal on a long prompt regardless of aggregation method, so tier keyword lists should stay tight and specific

Copy link
Copy Markdown
Contributor Author

@greptileai


Generated by Claude Code

mateo-berri and others added 2 commits July 11, 2026 19:12
…istry eviction, edit-modal controls

- config: KeywordTierRule now strips and drops blank/whitespace keywords (a stray
  "" makes _keyword_matches match every prompt, silently forcing that tier for all
  traffic); still requires at least one real keyword to remain
- frontend build_complexity_router_config: trim keywords and drop rules left empty so
  an unfilled "Add keyword rule" row no longer ships a rule the backend rejects with a
  400 in the heuristic (non-semantic) flow, where the client-side semantic guard doesn't run
- proxy clear_cache / delete_model: the auto_router/ prefix also covers quality_router/
  and adaptive_router/, so pop the model_name from all four router registries (no-op
  where absent) instead of only auto/complexity; otherwise a DB quality_router's stale
  entry made reload raise "already exists" and abort, and adaptive left a leak
- frontend ComplexityRouterConfig: only render the Keyword Tier Overrides and Semantic
  keyword matching sections when their change handlers are provided, so the edit-auto-
  router modal (which omits them) no longer shows interactive-but-dead controls

Copy link
Copy Markdown
Contributor Author

bugbot run


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

@greptileai


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

@greptileai


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

bugbot run


Generated by Claude Code

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 8982e1c. Configure here.

Copy link
Copy Markdown
Contributor Author

@greptileai


Generated by Claude Code

…itellm_complexity_router_keyword_tiers

# Conflicts:
#	ui/litellm-dashboard/eslint-metrics.json
@akapur99

Copy link
Copy Markdown
Contributor

@mateo-berri this is merge-ready. The staging conflict (the removed eslint-metrics.json snapshot) is resolved via merge commit 973a86a, and all 128 CI checks are green (the single skipped one is the diff-scoped tests/e2e basedpyright gate, which correctly skips since this PR touches no e2e files). Only REVIEW_REQUIRED remains, so it just needs your approve + merge. Good to squash-merge whenever you are ready

@akapur99
akapur99 self-requested a review July 11, 2026 21:18
@akapur99
akapur99 merged commit 92dfbdb into litellm_internal_staging Jul 11, 2026
129 checks passed
@akapur99
akapur99 deleted the litellm_complexity_router_keyword_tiers branch July 11, 2026 21:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants