litellm/tests/router_unit_tests/test_router_index_management.py
Ishaan Jaff 3852fc96c1
[Oct Staging Branch] (#15460)
* Implement fix for thinking_blocks and converse API calls

This fixes Claude's models via the Converse API, which should also fix
Claude Code.

* Add thinking literal

* Fix mypy issues

* Type fix for redacted thinking

* Add voyage model integration in sagemaker

* Add config file logic

* Use already exiting voyage transformation

* refactor code as per comments

* fix merge error

* refactor code as per comments

* refactor code as per comments

* UI new build

* [Fix] router - regression when adding/removing models  (#15451)

* fix(router): update model_name_to_deployment_indices on deployment removal

When a deployment is deleted, the model_name_to_deployment_indices map
was not being updated, causing stale index references. This could lead
to incorrect routing behavior when deployments with the same model_name
were dynamically removed.

Changes:
- Update _update_deployment_indices_after_removal to maintain
  model_name_to_deployment_indices mapping
- Remove deleted indices and decrement indices greater than removed index
- Clean up empty entries when no deployments remain for a model name
- Update test to verify proper index shifting and cleanup behavior

* fix(router): remove redundant index building during initialization

Remove duplicate index building operations that were causing unnecessary
work during router initialization:

1. Removed redundant `_build_model_id_to_deployment_index_map` call in
   __init__ - `set_model_list` already builds all indices from scratch

2. Removed redundant `_build_model_name_index` call at end of
   `set_model_list` - the index is already built incrementally via
   `_create_deployment` -> `_add_model_to_list_and_index_map`

Both indices (model_id_to_deployment_index_map and
model_name_to_deployment_indices) are properly maintained as lookup
indexes through existing helper methods. This change eliminates O(N)
duplicate work during initialization without any behavioral changes.

The indices continue to be correctly synchronized with model_list on
all operations (add/remove/upsert).

* fix(prometheus): Fix Prometheus metric collection in a multi-workers environment (#14929)

Co-authored-by: sotazhang <sotazhang@tencent.com>

* Add tiered pricing and cost calculation for xai

* Use generic cost calculator

* Resolve conflicts in generated HTML files

* Remove penalty params as supported params for gemini preview model (#15503)

* fix conversion of thinking block

* add application level encryption in SQS (#15512)

* docs: fix doc

* docs(index.md): bump rc

* [Fix] GEMINI - CLI -  add google_routes to llm_api_routes (#15500)

* fix: add google_routes to llm_api_routes

* test: test_virtual_key_llm_api_routes_allows_google_routes

* build: bump version

* bump: version 1.78.0 → 1.78.1

* add application level encryption in SQS

* add application level encryption in SQS

---------

Co-authored-by: Krrish Dholakia <krrishdholakia@gmail.com>
Co-authored-by: Ishaan Jaff <ishaanjaffer0324@gmail.com>
Co-authored-by: deepanshu <deepanshu.lulla@hq.bill.com>

* [Feat] Bedrock Knowledgebase - return search_response when using /chat/completions API with LiteLLM (#15509)

* docs: fix doc

* docs(index.md): bump rc

* [Fix] GEMINI - CLI -  add google_routes to llm_api_routes (#15500)

* fix: add google_routes to llm_api_routes

* test: test_virtual_key_llm_api_routes_allows_google_routes

* add AnthropicCitation

* fix async_post_call_success_deployment_hook

* fix add vector_store_custom_logger to global callbacks

* test_e2e_bedrock_knowledgebase_retrieval_with_llm_api_call

* async_post_call_success_deployment_hook

* add async_post_call_streaming_deployment_hook

* async def test_e2e_bedrock_knowledgebase_retrieval_with_llm_api_call_streaming(setup_vector_store_registry):

* fix _call_post_streaming_deployment_hook

* fix async_post_call_streaming_deployment_hook

* test update

* docs: Accessing Search Results

* docs KB

* fix chatUI

* fix searchResults

* fix onSearchResults

* fix kb

---------

Co-authored-by: Krrish Dholakia <krrishdholakia@gmail.com>

* [Feat] Add dynamic rate limits on LiteLLM Gateway  (#15518)

* docs: fix doc

* docs(index.md): bump rc

* [Fix] GEMINI - CLI -  add google_routes to llm_api_routes (#15500)

* fix: add google_routes to llm_api_routes

* test: test_virtual_key_llm_api_routes_allows_google_routes

* build: bump version

* bump: version 1.78.0 → 1.78.1

* fix: KeyRequestBase

* fix rpm_limit_type

* fix dynamic rate limits

* fix use dynamic limits here

* fix _should_enforce_rate_limit

* fix _should_enforce_rate_limit

* fix counter

* test_dynamic_rate_limiting_v3

* use _create_rate_limit_descriptors

---------

Co-authored-by: Krrish Dholakia <krrishdholakia@gmail.com>

* Add google rerank endpoint

* Add docs

* fix mypy error

* fix mypy and lint errors

* Add haiku 4.5 integration

* Add haiku 4.5 integration for other regions as well

* Handle citation field correctly

* Fix filtering headers for signature calcs

* Add haiku 4.5 integration (#15650)

---------

Co-authored-by: Leslie Cheng <leslie.cheng5@gmail.com>
Co-authored-by: Sameer Kankute <sameer@berri.ai>
Co-authored-by: Alexsander Hamir <alexsanderhamirgomesbaptista@gmail.com>
Co-authored-by: Lucas <10226902+LoadingZhang@users.noreply.github.com>
Co-authored-by: sotazhang <sotazhang@tencent.com>
Co-authored-by: Deepanshu Lulla <deepanshu.lulla@gmail.com>
Co-authored-by: Krrish Dholakia <krrishdholakia@gmail.com>
Co-authored-by: deepanshu <deepanshu.lulla@hq.bill.com>
2025-10-17 17:52:25 -07:00

289 lines
12 KiB
Python

import sys
import os
import pytest
import ast
import ast
sys.path.insert(
0, os.path.abspath("../..")
) # Adds the parent directory to the system path
from litellm import Router
class TestRouterIndexManagement:
"""Test cases for router index management functions"""
@pytest.fixture
def router(self):
"""Create a router instance for testing"""
return Router(model_list=[])
def test_deletion_updates_model_name_indices(self, router):
"""Test that deleting a deployment updates model_name_to_deployment_indices correctly"""
router.model_list = [
{"model_name": "gpt-3.5", "model_info": {"id": "model-1"}},
{"model_name": "gpt-4", "model_info": {"id": "model-2"}},
{"model_name": "gpt-4", "model_info": {"id": "model-3"}},
{"model_name": "claude", "model_info": {"id": "model-4"}}
]
router.model_id_to_deployment_index_map = {
"model-1": 0, "model-2": 1, "model-3": 2, "model-4": 3
}
router.model_name_to_deployment_indices = {
"gpt-3.5": [0],
"gpt-4": [1, 2],
"claude": [3]
}
# Remove one of the duplicate gpt-4 deployments
router._update_deployment_indices_after_removal(model_id="model-2", removal_idx=1)
# Verify indices are shifted correctly
assert router.model_name_to_deployment_indices["gpt-3.5"] == [0]
assert router.model_name_to_deployment_indices["gpt-4"] == [1] # was [1,2], removed 1, shifted 2->1
assert router.model_name_to_deployment_indices["claude"] == [2] # was [3], shifted to [2]
# Remove the last gpt-4 deployment
router._update_deployment_indices_after_removal(model_id="model-3", removal_idx=1)
# Verify gpt-4 is removed from dict when no deployments remain
assert "gpt-4" not in router.model_name_to_deployment_indices
assert router.model_name_to_deployment_indices["gpt-3.5"] == [0]
assert router.model_name_to_deployment_indices["claude"] == [1]
def test_build_model_id_to_deployment_index_map(self, router):
"""Test _build_model_id_to_deployment_index_map function"""
model_list = [
{
"model_name": "gpt-3.5-turbo",
"litellm_params": {"model": "gpt-3.5-turbo"},
"model_info": {"id": "model-1"},
},
{
"model_name": "gpt-4",
"litellm_params": {"model": "gpt-4"},
"model_info": {"id": "model-2"},
},
]
# Test: Build index from model list
router._build_model_id_to_deployment_index_map(model_list)
# Verify: model_list is populated
assert len(router.model_list) == 2
# Verify: model_id_to_deployment_index_map is correctly built
assert router.model_id_to_deployment_index_map["model-1"] == 0
assert router.model_id_to_deployment_index_map["model-2"] == 1
def test_add_model_to_list_and_index_map_from_model_info(self, router):
"""Test _add_model_to_list_and_index_map extracting model_id from model_info"""
# Setup: Empty router
router.model_list = []
router.model_id_to_deployment_index_map = {}
# Test: Add model without explicit model_id
model = {"model": "test-model", "model_info": {"id": "model-info-id"}}
router._add_model_to_list_and_index_map(model=model)
# Verify: Model added to list
assert len(router.model_list) == 1
assert router.model_list[0] == model
# Verify: Index map uses model_info.id
assert router.model_id_to_deployment_index_map["model-info-id"] == 0
def test_add_model_to_list_and_index_map_multiple_models(self, router):
"""Test _add_model_to_list_and_index_map with multiple models to verify indexing"""
# Setup: Empty router
router.model_list = []
router.model_id_to_deployment_index_map = {}
# Test: Add multiple models
model1 = {"model": "model1", "model_info": {"id": "id-1"}}
model2 = {"model": "model2", "model_info": {"id": "id-2"}}
model3 = {"model": "model3", "model_info": {"id": "id-3"}}
router._add_model_to_list_and_index_map(model=model1, model_id="id-1")
router._add_model_to_list_and_index_map(model=model2, model_id="id-2")
router._add_model_to_list_and_index_map(model=model3, model_id="id-3")
# Verify: All models added to list
assert len(router.model_list) == 3
assert router.model_list[0] == model1
assert router.model_list[1] == model2
assert router.model_list[2] == model3
# Verify: Correct indices in map
assert router.model_id_to_deployment_index_map["id-1"] == 0
assert router.model_id_to_deployment_index_map["id-2"] == 1
assert router.model_id_to_deployment_index_map["id-3"] == 2
def test_has_model_id(self, router):
"""Test has_model_id function for O(1) membership check"""
# Setup: Add models to router
router.model_list = [
{"model": "test1", "model_info": {"id": "model-1"}},
{"model": "test2", "model_info": {"id": "model-2"}},
{"model": "test3", "model_info": {"id": "model-3"}}
]
router.model_id_to_deployment_index_map = {"model-1": 0, "model-2": 1, "model-3": 2}
# Test: Check existing model IDs
assert router.has_model_id("model-1") == True
assert router.has_model_id("model-2") == True
assert router.has_model_id("model-3") == True
# Test: Check non-existing model IDs
assert router.has_model_id("non-existent") == False
assert router.has_model_id("") == False
assert router.has_model_id("model-4") == False
# Test: Empty router
empty_router = Router(model_list=[])
assert empty_router.has_model_id("any-id") == False
def test_build_model_name_index(self, router):
"""Test _build_model_name_index function"""
model_list = [
{
"model_name": "gpt-3.5-turbo",
"litellm_params": {"model": "gpt-3.5-turbo"},
"model_info": {"id": "model-1"},
},
{
"model_name": "gpt-4",
"litellm_params": {"model": "gpt-4"},
"model_info": {"id": "model-2"},
},
{
"model_name": "gpt-4", # Duplicate model_name, different deployment
"litellm_params": {"model": "gpt-4"},
"model_info": {"id": "model-3"},
},
]
# Test: Build index from model list
router._build_model_name_index(model_list)
# Verify: model_name_to_deployment_indices is correctly built
assert "gpt-3.5-turbo" in router.model_name_to_deployment_indices
assert "gpt-4" in router.model_name_to_deployment_indices
# Verify: gpt-3.5-turbo has single deployment
assert router.model_name_to_deployment_indices["gpt-3.5-turbo"] == [0]
# Verify: gpt-4 has multiple deployments
assert router.model_name_to_deployment_indices["gpt-4"] == [1, 2]
# Test: Rebuild index (should clear and rebuild)
new_model_list = [
{
"model_name": "claude-3",
"litellm_params": {"model": "claude-3"},
"model_info": {"id": "model-4"},
},
]
router._build_model_name_index(new_model_list)
# Verify: Old entries are cleared
assert "gpt-3.5-turbo" not in router.model_name_to_deployment_indices
assert "gpt-4" not in router.model_name_to_deployment_indices
# Verify: New entry is added
assert "claude-3" in router.model_name_to_deployment_indices
assert router.model_name_to_deployment_indices["claude-3"] == [0]
def test_no_linear_scans_in_router(self):
"""
Static analysis test to ensure Router doesn't use O(n) linear scans.
Scans router.py for 'in self.model_list' pattern which indicates
inefficient O(n) iteration instead of using index-based O(1) lookups.
Methods should use:
- model_id_to_deployment_index_map for O(1) model_id lookups
- model_name_to_deployment_indices for O(1) + O(k) model_name lookups
"""
# Methods that are allowed to iterate through self.model_list
ALLOWED_METHODS = [
"_get_deployment_by_litellm_model", # Edge case: lookup by litellm_params.model (not indexed)
]
# Get path to router.py
router_file = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
"litellm",
"router.py"
)
# Read the file
with open(router_file, 'r') as f:
content = f.read()
# Parse with AST
tree = ast.parse(content)
# Find violations
violations = []
ignore_methods = set(ALLOWED_METHODS)
for node in ast.walk(tree):
if isinstance(node, ast.FunctionDef):
method_name = node.name
# Skip ignored methods
if method_name in ignore_methods:
continue
# Get source for this method
try:
method_source = ast.get_source_segment(content, node)
if not method_source:
continue
# Check for the anti-pattern: "in self.model_list"
# This catches: for x in self.model_list, if x in self.model_list, etc.
if "in self.model_list" in method_source:
# Extract the specific line for better error reporting
lines = method_source.split('\n')
pattern_line = None
for line in lines:
if "in self.model_list" in line:
pattern_line = line.strip()
break
violations.append({
"method": method_name,
"line": node.lineno,
"pattern": pattern_line or "in self.model_list"
})
except Exception:
# Skip if we can't get source segment
pass
# Assert no violations
if violations:
error_msg = "\n".join([
f" - {v['method']}() at line {v['line']}: {v['pattern']}"
for v in violations
])
pytest.fail(
f"\n{'='*70}\n"
f"Found O(n) linear scan pattern in router.py:\n\n"
f"{error_msg}\n\n"
f"These methods should use index maps instead:\n"
f" - model_id_to_deployment_index_map (for model_id lookups)\n"
f" - model_name_to_deployment_indices (for model_name lookups)\n\n"
f"If a method legitimately needs O(n) iteration, add it to\n"
f"ALLOWED_METHODS in this test method.\n"
f"{'='*70}\n"
)
def test_model_names_is_set(self):
"""Verify that model_names uses a set for O(1) lookups, not a list (O(n))"""
router = Router(model_list=[])
assert isinstance(router.model_names, set), (
f"model_names should be a set for O(1) lookups, but got {type(router.model_names)}"
)