mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-12 08:42:18 +00:00
* fix(router): pin /v1/responses requests for prompt caching affinity * fix(router): pin previous_response_id turns and keep native tools in the Responses pin key A Responses turn that continues a stored response now goes to the deployment that answered it, unless that deployment already failed this request. Native Responses tools are hashed into the pin key as sent instead of converted, so two requests with different native tools no longer share a pin and a native request no longer logs a false dropped-tool warning. The pin keeps its cacheable deployment ids in a sorted tuple so loggers can serialize request metadata. * fix(router): leave a Responses request with a malformed tool to the provider * fix(router): keep the Responses prompt cache pin out of logged metadata The pre-call check now keeps the pin in a router-owned in-flight store and puts only a random token in litellm_metadata, so loggers that ship metadata never see the routing key or deployment ids. The store is shared across check instances, so turning prompt_caching off and on at runtime keeps pinning, and a retry drops its previous attempt's token. The skipped-input debug log now names only the error type. * fix(router): apply the prompt caching pin after tag, region and pre-call filters, and leave it on retries The prompt caching check no longer narrows the candidates inside the callback filters. It names the deployment it prefers, and the router core applies that preference at the pinned deployment step, after tag, region, pre-call and plugin filters, only when the deployment is still a candidate. A pin can no longer send a request to a deployment its tags do not allow. A retry skips the pin when the pinned deployment was already tried in this request, read from the retry breadcrumbs in both metadata buckets and the excluded and retry-skipped ids. When the retry count outruns the breadcrumbs the check stands down, so a pinned deployment that keeps failing is not retried on every attempt. * test(router): list the prompt caching check as an in-memory cache caller The check's in-flight Responses pins live in a process-local InMemoryCache, so it opens no Redis span and needs no key family, like the other in-memory callers * fix(router): pin a lone healthy deployment, free every in-flight Responses pin, and clear a reused metadata dict's retry records * test(router): lift test cooldowns through the cooldown store's evict_local * fix(router): key Responses tools like their chat twin and drop the pin token after success A Responses function tool now hashes only the function fields it sent, so a chat request and a Responses request with the same tool share one pin. The success event also removes the pin token from the caller's metadata dict. * test(router): cover untyped tool parameters and custom tool grammars in the Responses pin key --------- Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
270 lines
12 KiB
Python
270 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",)
|
|
# 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/router_utils/routing_prefetch.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/github_copilot/authenticator.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/pre_call_checks/prompt_caching_deployment_check.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 == ()
|