litellm/tests/code_coverage_tests/router_code_coverage.py
tessellark 5b546259d5
fix(cache_control)!: keep one deployment's prompt-cache opt-in from reaching siblings on the same model (#49685)
* fix(cache_control): read the routed deployment's own prompt-cache flag, not a sibling's shared cost-map entry

Two deployments that share one model string share one key in the live
litellm.model_cost map, and router registration writes each deployment's
supports_prompt_cache_breakpoint into that key. The cache control hook read
the live map, so a flagless deployment picked up a sibling's opt-in and sent
prompt_cache_options and prompt_cache_breakpoint upstream, which the provider
rejects with a 400

When a call carries the routed deployment's model_info, the prompt cache
checks now use its declared supports_prompt_cache_breakpoint, and fall back
to the loaded catalog instead of the live map. This covers the hook on
completion, acompletion, Responses and the /v1/messages route, the azure_ai
chat transform, the OpenAI Responses transform under drop_params, and the
/v1/messages to Responses adapter

* fix(prompt caching): read each deployment's own breakpoint flag in the openai and bedrock readers

A deployment's own supports_prompt_cache_breakpoint now picks OpenAI's
prompt_cache_breakpoint only where the request goes out OpenAI-shaped on a
provider other than openai, such as azure_ai OpenAI models and bedrock_mantle.
Bedrock on every route and providers that build Anthropic messages keep
cache_control, which Bedrock Converse turns into cachePoint

The openai chat reader now passes the routed model_info and checks base_model.
The Bedrock cache point reader takes the deployment's flag first and, for a
routed call with no flag, reads the catalog as loaded instead of the live map
that sibling deployments write into. model_info is threaded from the converse
transform, the factory message and tool builders, and the responses bridge

* fix(prompt caching): keep gateways on cache_control and read register_model opt-ins on routed calls

A declared supports_prompt_cache_breakpoint picks OpenAI's breakpoint only on providers hosting OpenAI's API or for a model the cost map lists under the provider, so OpenRouter, LiteLLM proxy, and vLLM deployments keep cache_control as before. Routed calls read the loaded catalog plus the user's own register_model entries, never router registrations.

* fix(prompt caching): honor a deployment's own supports_prompt_caching on Bedrock and its declared litellm_provider on gateways

* fix(prompt caching): keep wildcard cache flags off matched models, read deployment flags for auto caching and base_model aliases

* test(anthropic): cover auto caching on an unmapped Claude deployment

* fix(cache_control): keep the merge base's reads where the per-deployment flag changed them

A register_model entry under a provider-prefixed name reaches routed calls again, since the
overlay now resolves the bare and prefixed names the way runtime registrations are stored.

The hook and the openai chat reader no longer judge base_model, as at the merge base, so a
client base_model sent under allow_client_pricing_override cannot pick OpenAI's breakpoint
and get a 400.

A Bedrock deployment flag that is not a real true reads as false again, as at the merge base.

An azure_ai wildcard's supports_prompt_cache_breakpoint speaks for each model it matches again,
as at the merge base. Other wildcards still leave both cache flags to the catalog.

The s10/s11 Bedrock wire test asserted that a flagless deployment inherits its sibling's flag
on a shared ARN, which is the leak this PR removes. It now asserts that each deployment keeps
its own flag and that a flagless unmapped ARN keeps its cache points after the delete.

* fix(cache_control): let the later register_model entry win between a bare and a provider-prefixed name

Routed calls read register_model entries through an overlay that merged the bare
name after the provider-prefixed one whatever order they were registered in, so a
later opt-out under one name lost to an earlier opt-in under the other. Each
registration now moves its key to the end of the kept registrations, and the
overlay merges both names oldest first, which matches what SDK calls read from
the live model map.

Also lists the wildcard cache flag helper in the router coverage script, since
the hook's wildcard tests drive it rather than calling it by name.

* fix(cache_control): keep register_model entries visible while a cost map reload replays them

The previous commit moved a re-registered name to the end of the saved
registrations by removing and re-adding it, and the overlay walked that dict to
order a bare and a provider-prefixed name. A reload replays every saved entry,
so a request on another thread could briefly miss an entry or fail on a dict
that changed size mid-walk. Entries are written in place again, a per-name
sequence records which registration came last, and both the overlay and the
reload replay order by it, so the later registration still wins after a reload.

* fix(cache_control): read a string or numeric supports_prompt_caching as the bool it spells and find register_model entries under any route prefix

A deployment flag stored as "true", 1, "false" or 0 (POST /model/new and quoted YAML keep strings) now means that bool on the Bedrock cachePoint reader and Anthropic auto caching, and any other value falls to the catalog. register_model records which names wrote to each catalog row, so an opt-out under bedrock/ or bedrock/converse/ reaches routed calls like one under anthropic/.

---------

Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
2026-11-03 07:55:51 +00:00

245 lines
18 KiB
Python

import ast
import os
def get_function_names_from_file(file_path):
"""
Extracts all function names from a given Python file.
"""
with open(file_path, "r", encoding="utf-8") as file:
tree = ast.parse(file.read())
function_names = []
for node in tree.body:
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
# Top-level functions
function_names.append(node.name)
elif isinstance(node, ast.ClassDef):
# Functions inside classes
for class_node in node.body:
if isinstance(class_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
function_names.append(class_node.name)
return function_names
def get_all_functions_called_in_tests(base_dir):
"""
Returns a set of function names that are called in test functions
inside the unit test directories,
specifically in files containing the word 'router'.
"""
called_functions = set()
test_dirs = ["unit"]
for test_dir in test_dirs:
dir_path = os.path.join(base_dir, test_dir)
if not os.path.exists(dir_path):
print(f"Warning: Directory {dir_path} does not exist.")
continue
print("dir_path: ", dir_path)
for root, _, files in os.walk(dir_path):
for file in files:
file_path = os.path.join(root, file)
if file.endswith(".py") and (
"router" in os.path.relpath(file_path, dir_path).lower() or test_dir == "unit"
):
print("file: ", file)
with open(file_path, "r", encoding="utf-8") as f:
try:
tree = ast.parse(f.read())
except SyntaxError:
print(f"Warning: Syntax error in file {file_path}")
continue
if file == "test_router_validate_fallbacks.py":
print(f"tree: {tree}")
for node in ast.walk(tree):
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
called_functions.add(node.func.id)
elif isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute):
called_functions.add(node.func.attr)
return called_functions
def get_functions_from_router(file_path):
"""
Extracts all functions defined in router.py.
"""
return get_function_names_from_file(file_path)
ignored_function_names = [
"_acancel_batch",
"_acreate_batch",
"_acreate_file",
"__init__",
"avector_store_create", # Tested via proxy vector_store_endpoints (files lack "router" in name)
"_override_vector_store_methods_for_router", # No-op placeholder, called during Router init
"_merge_tools_from_deployment", # Tested indirectly via _update_kwargs_with_deployment (test files lack "router" in name)
"_invalidate_access_groups_cache", # Tested indirectly via set_model_list, upsert_model etc. (test files lack "router" in name)
"has_buffered_provider_output", # Property, so its reads in test_router.py are never an ast.Call
"chunks", # Property on FallbackAwareAnthropicMessagesStream, so its reads in tests are never an ast.Call
"messages", # Property on FallbackAwareAnthropicMessagesStream, so its reads in tests are never an ast.Call
"model", # Property on FallbackAwareAnthropicMessagesStream, so its reads in tests are never an ast.Call
"_routing_groups", # Property getter and setter reads are never ast.Call nodes
"_request_header", # Tested through Claude Code session routing in test_router.py
"_claude_code_session_router_cache_key", # Tested through Claude Code session routing in test_router.py
"_delete_claude_code_session_router_binding", # Tested through Redis cleanup failure in test_router.py
"_resolve_claude_code_session_router", # Tested through Claude Code session routing in test_router.py
"_get_claude_code_session_router_binding", # Tested through the two-worker session routing test in test_router.py
"_apply_updated_routing_strategy_args", # Tested via update_settings in test_lowest_latency.py (file lacks "router" in name)
"_wait_for_scheduler_turn", # Tested through prioritized acompletion and atext_completion in test_router.py
"arm_routing_read_prefetch", # Tested in tests/unit/caching/test_request_redis_batch_pre_call.py (file lacks "router" in name)
"_deployment_params_base_model", # Tested through log_retry in test_router_helper_utils.py
"_configured_model_info", # Tested through get_configured_service_tiers in test_router.py
"_every_deployment_refused", # Tested through the unsupported transcription retry tests in test_router.py
"_routable_deployments", # Tested through get_configured_service_tiers and get_routable_upstream_model in test_router.py
"_async_get_available_deployment", # Body of the `route {model}` phase wrapper, exercised through async_get_available_deployment in test_router.py
"_async_get_available_deployment_for_pass_through", # Same, through async_get_available_deployment_for_pass_through in test_router.py
"_embedding",
"_aembedding",
"_stream_pre_content_error", # Tested through the non-retriable retry error tests in test_router.py
"_narrow_to_video_deployment", # Tested through video_status and avideo_status polls in test_router_provider_endpoints.py
"_video_call_pin", # Tested through the video_status pin tests and the chat video_id test in test_router_provider_endpoints.py
"_deployment_num_retries", # Tested through the deployment num_retries mid-stream budget test in test_router.py
"_request_fallback_list", # Tested through every mid-stream retry test in test_router.py
"_request_model_group", # Tested through test_anthropic_messages_retry_budget_precedence_direct_call
"_search_tool_support", # Tested through the test_get_search_tool_support_* tests
"_mid_stream_retry_trigger", # Tested through the retry policy mid-stream budget test in test_router.py
"_anthropic_messages_group_retry_policy", # Tested through the retry budget precedence test in test_router.py
"_anthropic_messages_resolved_retry_policy", # Tested through the malformed retry policy tests in test_router.py
"_anthropic_messages_plain_retry_budget", # Tested through the retry budget precedence test in test_router.py
"_stream_should_retry", # Tested through every mid-stream retry test in test_router.py
"_astream_retry_same_group", # Tested through the dropped-before-content retry tests in test_router.py
"_aanthropic_messages_yield_recovered", # Tested through every mid-stream retry and fallback test in test_router.py
"_anthropic_messages_policy_retries", # Tested through the retry budget precedence test in test_router.py
"_get_wildcard_deployments", # Tested through the get_model_list_of_routed_group wildcard test in test_router.py
"_prompt_management_acompletion", # Tested through the scheduled prompt-management model test in test_router.py
"_request_deployment_params", # Tested through the team-route and deployment-id drop_params priority tests in test_router.py
"split_request_priority", # Tested through the acompletion, atext_completion and queue route priority tests
"_pinned_deployment", # Tested through the deployment-id scheduler test in test_router.py
"_is_fallback_hop", # Tested through the order fallback hop tests in test_router_order_fallback.py
"_deployment_that_just_failed", # Tested through the same-boundary hop test in test_router_order_fallback.py
"_merged_tools", # Tested through the _update_kwargs_with_deployment tools merge tests in test_router.py
"_malformed_request_params_the_deployment_sets", # Run by _update_kwargs_with_deployment, tested through the drop_params deployment test in test_thought_signature_in_tool_call_id.py
"_drop_malformed_request_params_the_deployment_sets", # Run by _update_kwargs_with_deployment, tested through the drop_params deployment test in test_thought_signature_in_tool_call_id.py
"_drops_malformed_request_tools", # Tested through the _update_kwargs_with_deployment tools merge tests in test_router.py
"_collect_ptu_share_teams", # Cached behind team_ptu_ceiling, tested through the live model list test in test_router.py
"_resolve_team_ptu_ceiling", # Cached behind team_ptu_ceiling, tested through the live model list test in test_router.py
"_register_continuation_prefill_check", # Tested through the continuation tests in test_router.py
"_set_mid_stream_fallback_continuation", # Tested through the update_settings continuation test in test_router.py
"_is_flag_enabled", # Tested through the string-flag continuation tests in test_router.py
"_params_sent_to_deployment", # Tested through the configured-constraint continuation tests in test_router.py
"_names_deployment_that_cannot_continue", # Tested through the deployment-id continuation tests in test_router.py
"_set_retry_policy", # Tested through update_settings in test_router_retry_policy_update.py
"_resolve_pre_call_check_input_tokens", # Tested through the token-count failure pre-call check tests in test_router.py
"_params_neither_dropped_nor_allowed", # Tested through the allowed-or-dropped param pre-call check tests in test_router.py
"_deployment_rejects_params", # Tested through the unsupported-param pre-call check tests in test_router.py
"_cache_flags_left_to_the_catalog", # Tested through the wildcard prompt cache flag tests in test_anthropic_cache_control_hook.py
"_deployment_lacks_params", # Tested through the unsupported-param pre-call check tests in test_router.py
"_pre_call_check_requested_chat_params", # Tested through the unsupported-param pre-call check tests in test_router.py
"_deployment_supports_param", # Tested through the deployment-capability pre-call check tests in test_router.py
"_capability_model_and_provider", # Tested through the authenticating-provider pre-call check test in test_router.py
"_route_capability_pin", # Exercised through Decisions routing tests in test_router.py
"_deployment_serves_route", # Exercised through mixed-group Decisions tests in test_router.py
"_without_deployments_that_cannot_serve_route", # Exercised through Decisions routing tests in test_router.py
"_exact_base_model_max_input_tokens", # Tested through get_router_model_info in test_router_model_cost_isolation.py
"_base_model_widened_window", # Tested through get_router_model_info in test_router_model_cost_isolation.py
"_provider_model_id", # Tested through get_router_model_info in test_router_model_cost_isolation.py
"_names_route_model", # Tested through get_router_model_info in test_router_model_cost_isolation.py
"_declared_base_model", # Tested through get_router_model_info in test_router_model_cost_isolation.py
"async_admit_deployment_turn", # Bound as the WebSocket turn_admission partial, tested through it in test_router_helper_utils.py
"_deployment_as_configured_now", # Read by async_admit_deployment_turn, tested through turn_admission in test_router_helper_utils.py
"_admit_turn_through_routing_strategy", # Run by async_admit_deployment_turn, tested through turn_admission in test_router_helper_utils.py
"_callback_selectors", # Run by discard, tested through the discard tests in test_router_helper_utils.py
"bind_to_request_logging", # Run by the proxy user_config route, tested through the user_config tests in test_route_llm_request.py
"serve_through_request_logging", # Run by the proxy user_config route, tested through the user_config tests in test_route_llm_request.py
"_request_adaptive_router_hooks", # Run by serve_through_request_logging, tested through the user_config adaptive test
"_release_global_callbacks", # Run by discard, tested through the discard tests in test_router_helper_utils.py
"_routing_callbacks", # Read by the pre-call checks, tested through the user_config limit tests in test_route_llm_request.py
"_request_policies_not_set_globally", # Read by bind_to_request_logging, tested through the user_config budget test in test_route_llm_request.py
"_optional_loggers", # Read by discard and serve_through_request_logging, tested through the budget limiter discard test
"retire_routing_state", # Run by discard, tested through the budget limiter discard test
"_slack_alerting_loggers", # Run by discard, tested through the Slack alerting discard test in test_router_helper_utils.py
"_adaptive_router_hooks", # Run by discard, tested through the adaptive router discard tests in test_router_helper_utils.py
"_global_callback_candidates", # Run by discard, tested through the survivor discard tests in test_router_helper_utils.py
"without_deployments_that_cannot_batch", # Tested through the Azure and OpenAI mixed-group acreate_batch tests in test_router.py
"batch_create_routing", # Tested through the mixed-group acreate_batch tests in test_router.py
"is_batch_create_routing", # Tested through the chat-after-batch-create test in test_router.py
"is_batch_capable", # Tested through the mixed-group acreate_batch and model group info tests in test_router.py
"deployments_the_caller_may_use", # Tested through the usage-based-routing team batch create test in test_router.py
"deployment_supported_endpoints", # Tested through the model group info batch endpoint tests in test_router.py
"_endpoint_paths", # Read by deployment_supported_endpoints, tested through the malformed endpoint list group info test in test_router.py
"_deployment_field", # Read by is_batch_capable, tested through the mixed-group tests in test_router.py
"_deployment_provider", # Read by without_deployments_that_cannot_batch, tested through the mixed-group tests in test_router.py
"_cannot_run_batches", # Tested through the Azure and OpenAI mixed-group acreate_batch tests in test_router.py
"_responses_continuation", # Tested through the Responses mid-stream fallback tests in tests/unit/llms/base_llm/responses/test_transformation.py
"_responses_continuation_ceilings", # Tested through the Responses mid-stream fallback tests in tests/unit/llms/base_llm/responses/test_transformation.py
"_team_deployments_named", # Tested through the team lookups in the concurrent delete tests in test_router.py
"_served_openai_models", # Cached as served_openai_models, tested through the pre-call check token count tests in test_router.py
"priority_header_update", # Tested through the priority header tests in test_common_request_processing.py
"_drops_params", # Read by split_request_priority and priority_header_update, tested through both
"_responses_event_announces_background_run", # Tested through the background Responses stream tests in test_router.py
"_aresponses_background_stream_can_release", # Tested through the background Responses stream tests in test_router.py
"_raise_released_stream_error", # Tested through the background Responses stream content-policy tests in test_router.py
"_mid_stream_error_before_output", # Tested through the Responses mid-stream fallback and background stream tests
"_without_decisions_deployments_refusing_media", # Tested through the mixed media-flag group tests in tests/unit/decisions/test_main.py
"_decisions_deployment_refuses_media", # Read by _without_decisions_deployments_refusing_media, tested through the same group tests
"_active_default_litellm_params", # Tested through the router default drop_params test in tests/unit/decisions/test_main.py
]
def public_name(function_name):
"""
The deprecated public name a ``_core_<name>`` router member is reached through. Tests that call the
old name run the core member, since ``DeprecatedRouterMembers`` forwards it there.
"""
return function_name.removeprefix("_core_")
def main():
router_file = [
"./litellm/router.py",
"./litellm/litellm_core_utils/batch_utils.py",
"./litellm/litellm_core_utils/pattern_match_deployments.py",
]
# router_file = [
# "../../litellm/router.py",
# "../../litellm/litellm_core_utils/pattern_match_deployments.py",
# "../../litellm/litellm_core_utils/batch_utils.py",
# ] ## LOCAL TESTING
tests_dir = "./tests/" # Update this path if your tests directory is located elsewhere
# tests_dir = "../../tests/" # LOCAL TESTING
router_functions = []
for file in router_file:
router_functions.extend(get_functions_from_router(file))
print("router_functions: ", router_functions)
called_functions_in_tests = get_all_functions_called_in_tests(tests_dir)
untested_functions = [
fn
for fn in router_functions
if fn not in called_functions_in_tests and public_name(fn) not in called_functions_in_tests
]
if untested_functions:
all_untested_functions = []
for func in untested_functions:
if func not in ignored_function_names and public_name(func) not in ignored_function_names:
all_untested_functions.append(func)
untested_perc = (len(all_untested_functions)) / len(router_functions)
print("untested_perc: ", untested_perc)
if untested_perc > 0:
print("The following functions in router.py are not tested:")
raise Exception(
f"{untested_perc * 100:.2f}% of functions in router.py are not tested: {all_untested_functions}"
)
else:
print("All functions in router.py are covered by tests.")
if __name__ == "__main__":
main()