litellm/tests/unit/test_internal_context.py
Mateo Wang 567383c503
feat(proxy)!: model family and access group entries in model_max_budget (#47722)
* feat(ui): let the per-model budget field take a model pattern or access group

* feat(proxy): let model_max_budget entries name a model family or access group, shared across replicas

* fix(proxy): open each shared model budget window once for every replica, and match patterns in linear time

Without Redis, the first replica to charge an entry in a new window now opens it in the window spend table and
the others join it, so no flush replaces spend another replica already added. Budget patterns match by splitting
on `*` instead of a regular expression, a refused request names the entry that refused it, and the key form no
longer offers a typed pattern another row already budgets.

* fix(proxy): read the shared budget window from the writer, and keep concurrent first charges on one replica

Opening a shared window now reads the row from the writer, since a lagging read replica could hand a replica its
own start and let a flush replace the others' spend. A charge that waited on the open no longer resets a local
window another charge on the same replica opened meanwhile. Window tests use fixed timestamps.

* fix(proxy): stop a non proxy admin from lifting a team model cap with a looser group or pattern entry

Group and pattern entries now take precedence over other entries, so a team update by anyone but a proxy admin
must keep each new or changed entry within every existing cap it can take precedence over. A pattern can match a
provider-prefixed or wildcard-routed name, so any two patterns count as overlapping, and a model or group entry
counts as outranking every pattern.

* fix(proxy): narrow model budget pattern overlap, keep team caps under key patterns, and accept only typed patterns in the UI

* fix(proxy): keep model family budgets through database blips, team models and key entries

A failed window open no longer restarts the window at this replica's clock, so it cannot wipe shared spend. Spend charged meanwhile stays local and joins the shared window once the database answers

A key entry named after a model replaces only the team entry of that name, so team pattern and group caps still apply. Team-scoped models are gated and charged under their public name, and group lookups see team deployments

The team update guard compares caps by window length and only ranks patterns that can match the same name, including Bedrock vendor patterns behind a bedrock wildcard route

* fix(proxy): refuse team entries that take precedence over a capped model_max_budget entry

A non-proxy-admin could add a carve-out (an exact model, a narrower pattern or
a group) at or under the family cap, and its own counter then multiplied the
team's spend on that family. Any new entry that takes precedence over a capped
entry now needs a proxy admin, and group members include the team's own public
model names so team deployments cannot hide a group overlap.

* test(router): cover team_public_model_names_of

* docs(proxy): name pattern and access group keys in model_max_budget docstrings

* fix(proxy): name the entry holding the cap in the team model budget raise refusal

* fix(proxy): check precedence when a team edit makes an unenforced model_max_budget entry take effect

* fix(proxy): let an org admin give a legacy model_max_budget row a budget_duration at the same or a lower cap

* fix(proxy): keep a legacy model_max_budget row's stored cap when an org admin gives it a budget_duration
2026-10-28 15:40:57 -07:00

267 lines
12 KiB
Python

"""``with_service_target`` and ``service_caller`` carry the purpose and the caller of a datastore call
to code that cannot see them from its own frames, and every Redis producer on the proxy request path
declares a key family so no request-path span renders as a bare ``redis.get``."""
import ast
import asyncio
import contextvars
import re
from collections.abc import Generator
from dataclasses import dataclass
from pathlib import Path
from typing import Final
import pytest
from litellm._internal_context import (
current_service_caller,
current_service_target,
service_caller,
service_target,
with_service_target,
)
_REPO: Final = Path(__file__).resolve().parents[2]
_REDIS_PRODUCER_ROOTS: Final = ("litellm", "enterprise")
# The cache implementations and facades: they emit the service events, their callers declare the family.
_CACHE_LAYER_DIRS: Final = ("litellm/caching", "litellm/_v2/cache")
# Helpers that act on a cache handed in by the declaring caller, or forward to the response-cache facade.
_CACHE_PARAMETER_HELPERS: Final = frozenset(
{
"litellm/llms/anthropic/prompt_cache_prediction.py",
"litellm/proxy/common_utils/cache_coordinator.py",
"litellm/proxy/common_utils/user_api_key_cache.py",
"litellm/utils.py",
}
)
# Callers whose every cache call hits a process-local ``InMemoryCache`` (a ``DualCache`` built without
# ``redis_cache``, a ``local_only=True`` call, the client / logger / tool-name caches), so no Redis span exists.
_IN_MEMORY_ONLY_CALLERS: Final = frozenset(
{
"litellm/integrations/datadog/datadog_team_handler.py",
"litellm/integrations/humanloop.py",
"litellm/integrations/langfuse/langfuse_handler.py",
"litellm/integrations/langfuse/langfuse_prompt_management.py",
"litellm/integrations/newrelic/newrelic_team_handler.py",
"litellm/integrations/shadow_eval_logger.py",
"litellm/litellm_core_utils/litellm_logging.py",
"litellm/litellm_core_utils/oauth_token_exchange.py",
"litellm/litellm_core_utils/prompt_templates/factory.py",
"litellm/litellm_core_utils/prompt_templates/image_handling.py",
"litellm/litellm_core_utils/specialty_caches/dynamic_logging_cache.py",
"litellm/litellm_core_utils/specialty_caches/service_trace_id_cache.py",
"litellm/llms/azure/common_utils.py",
"litellm/llms/bedrock/base_aws_llm.py",
"litellm/llms/custom_httpx/http_handler.py",
"litellm/llms/databricks/common_utils.py",
"litellm/llms/gigachat/authenticator.py",
"litellm/llms/github_copilot/per_user_auth.py",
"litellm/llms/litellm_proxy/skills/handler.py",
"litellm/llms/openai/common_utils.py",
"litellm/llms/openai_like/model_info.py",
"litellm/llms/vertex_ai/vertex_ai_non_gemini.py",
"litellm/llms/watsonx/common_utils.py",
"litellm/proxy/mcp_server/byok_credential_cache.py",
"litellm/proxy/mcp_server/catalog.py",
"litellm/proxy/mcp_server/discoverable_endpoints.py",
"litellm/proxy/mcp_server/oauth_identity_binding.py",
"litellm/proxy/mcp_server/operations.py",
"litellm/proxy/mcp_server/outbound_credentials/sso_assertion_store.py",
"litellm/proxy/mcp_server/outbound_credentials/token_endpoint.py",
"litellm/proxy/agent_endpoints/databricks_oauth.py",
"litellm/proxy/common_utils/registry_read_through.py",
"litellm/proxy/container_endpoints/ownership.py",
"litellm/proxy/discovery_endpoints/agent_skills_endpoints.py",
"litellm/proxy/guardrails/guardrail_hooks/straiker/straiker.py",
"litellm/proxy/hooks/model_budget_shared_windows.py",
"litellm/proxy/spend_tracking/key_metadata_recovery.py",
"litellm/proxy/ui_crud_endpoints/latest_release_endpoints.py",
"litellm/responses/litellm_completion_transformation/transformation.py",
"litellm/router_utils/client_initalization_utils.py",
"litellm/router_utils/router_callbacks/track_deployment_metrics.py",
"litellm/secret_managers/cyberark_secret_manager.py",
"litellm/secret_managers/google_secret_manager.py",
"litellm/secret_managers/hashicorp_secret_manager.py",
"litellm/secret_managers/main.py",
}
)
_CACHE_CALL: Final = re.compile(
r"\.(?:async_)?(?:get_cache|set_cache|batch_get_cache|batch_get_cache_shared|increment_cache|increment_if_present|increment"
r"|set_cache_pipeline|set_cache_pipeline_with_ttls|set_cache_sadd|delete_cache|batch_set_cache|increment_pipeline"
r"|rpush|lpop|scan_iter|get_ttl|mget)\("
r"|\b(?:reserve_redis_batch_reads|_prepare_batch_get)\("
r"|\bbatch\.(?:set|delete|script|increment)\("
)
_DECLARES_TARGET: Final = re.compile(r"\b(?:with_service_target|service_target|response_cache_phase)\(")
_BUILDS_A_REDIS_CACHE: Final = re.compile(r"\bRedisCache\(|\bredis_cache=(?!None\b)")
def _redis_producers() -> tuple[str, ...]:
files: Final = tuple(
path for root in _REDIS_PRODUCER_ROOTS for path in sorted((_REPO / root).rglob("*.py"))
) # comprehension-ok: flatten the producer roots
relative: Final = tuple(
path.relative_to(_REPO).as_posix() for path in files if _CACHE_CALL.search(path.read_text())
)
return tuple(name for name in relative if not name.startswith(_CACHE_LAYER_DIRS))
def test_every_redis_producer_declares_a_key_family() -> None:
"""A module that reads or writes a shared cache without a declared target renders as a
bare ``redis.get`` / ``redis.mget`` (flat under the request span, or an unnamed INTERNAL root
for a background job), which is exactly what the sensitive-data pin read, the rate-limiter
MGET and the budget-reset job did in production. Only process-local callers are exempt."""
exempt: Final = _CACHE_PARAMETER_HELPERS | _IN_MEMORY_ONLY_CALLERS
undeclared: Final = tuple(
name
for name in _redis_producers()
if name not in exempt and not _DECLARES_TARGET.search((_REPO / name).read_text())
)
assert undeclared == ()
def test_every_in_memory_exemption_still_only_touches_a_process_local_cache() -> None:
"""The exemption list is a claim about each file, so a file that is deleted or starts building
or receiving a ``RedisCache`` has to leave the list (and declare a family) rather than stay exempt."""
producers: Final = frozenset(_redis_producers())
stale: Final = tuple(sorted(_IN_MEMORY_ONLY_CALLERS - producers))
assert stale == ()
redis_backed: Final = tuple(
name for name in sorted(_IN_MEMORY_ONLY_CALLERS) if _BUILDS_A_REDIS_CACHE.search((_REPO / name).read_text())
)
assert redis_backed == ()
def test_with_service_target_sets_the_target_for_sync_and_async_calls_and_restores_it() -> None:
@with_service_target("rate_limits")
def read() -> str | None:
return current_service_target()
@with_service_target("rate_limits")
async def read_async() -> str | None:
await asyncio.sleep(0)
return current_service_target()
assert read() == "rate_limits"
assert asyncio.run(read_async()) == "rate_limits"
assert current_service_target() is None
with service_target("auth_objects"):
assert read() == "rate_limits"
assert current_service_target() == "auth_objects"
def test_with_service_target_keeps_the_wrapped_signature_and_coroutine_ness() -> None:
import inspect
@with_service_target("rate_limits")
async def hook(self: object, data: dict[str, str], call_type: str) -> None:
return None
assert inspect.iscoroutinefunction(hook)
assert tuple(inspect.signature(hook).parameters) == ("self", "data", "call_type")
assert hook.__name__ == "hook"
def test_service_caller_is_inherited_by_a_task_spawned_inside_it_and_cleared_after() -> None:
async def spawned() -> str | None:
return current_service_caller()
async def main() -> tuple[str | None, str | None]:
with service_caller("prefetch <- auth"):
task = asyncio.create_task(spawned())
return await task, current_service_caller()
assert asyncio.run(main()) == ("prefetch <- auth", None)
@pytest.mark.parametrize("value", [None, "x"])
def test_service_caller_restores_the_outer_value(value: str | None) -> None:
with service_caller(value):
with service_caller("inner"):
assert current_service_caller() == "inner"
assert current_service_caller() == value
assert current_service_caller() is None
class _Suspend:
def __await__(self) -> Generator[None]:
yield
def test_a_targeted_coroutine_closed_from_another_context_does_not_raise() -> None:
@with_service_target("router_usage")
async def sync_forever() -> None:
await _Suspend()
suspended: Final = sync_forever()
contextvars.copy_context().run(suspended.send, None)
contextvars.copy_context().run(suspended.close)
assert current_service_target() is None
_DIRECT_REDIS_CALL: Final = re.compile(r"\b_?redis_cache\.(?!async_register_script\b)(?:async_)?\w+\(")
@dataclass(frozen=True, slots=True)
class _FunctionScan:
name: str
reaches_redis_directly: bool
declares_a_family: bool
referenced_names: frozenset[str]
def _scan_function(source: str, fn: ast.FunctionDef | ast.AsyncFunctionDef) -> _FunctionScan:
body: Final = ast.get_source_segment(source, fn) or ""
decorators: Final = "\n".join(ast.get_source_segment(source, d) or "" for d in fn.decorator_list)
nodes: Final = tuple(ast.walk(fn))
names: Final = frozenset(n.id for n in nodes if isinstance(n, ast.Name))
attrs: Final = frozenset(n.attr for n in nodes if isinstance(n, ast.Attribute))
return _FunctionScan(
name=fn.name,
reaches_redis_directly=bool(_DIRECT_REDIS_CALL.search(body)),
declares_a_family=bool(_DECLARES_TARGET.search(body + "\n" + decorators)),
referenced_names=(names | attrs) - {fn.name},
)
def _covered_by_callers(scans: tuple[_FunctionScan, ...], covered: frozenset[str]) -> frozenset[str]:
"""Close ``covered`` over functions whose every in-file caller already declares a family."""
callers: Final = {
scan.name: frozenset(
other.name for other in scans if other.name != scan.name and scan.name in other.referenced_names
)
for scan in scans
}
grown: Final = covered | frozenset(
name for name, callers_of in callers.items() if callers_of and callers_of <= covered
)
return grown if grown == covered else _covered_by_callers(scans, grown)
def _direct_redis_callers_without_a_family(name: str) -> tuple[str, ...]:
source: Final = (_REPO / name).read_text()
scans: Final = tuple(
_scan_function(source, node)
for node in ast.walk(ast.parse(source))
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
)
declared: Final = frozenset(scan.name for scan in scans if scan.declares_a_family)
covered: Final = _covered_by_callers(scans, declared)
return tuple(f"{name}::{scan.name}" for scan in scans if scan.reaches_redis_directly and scan.name not in covered)
def test_every_function_that_reaches_redis_directly_declares_its_family() -> None:
"""A file-level declaration hides the producer that lacks one: the Claude Code session router
binding read sat in ``router.py`` beside dozens of declared families and still shipped as a bare
``redis.get``. A function that bypasses the cache facades and calls ``redis_cache`` itself must
carry the family on itself, its decorator, or every one of its in-file callers."""
exempt_files: Final = _CACHE_PARAMETER_HELPERS | _IN_MEMORY_ONLY_CALLERS
undeclared: Final = tuple(
function
for name in _redis_producers()
if name not in exempt_files
for function in _direct_redis_callers_without_a_family(name)
) # comprehension-ok: flatten per-file findings
assert undeclared == ()