mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-11 03:38:38 +00:00
Response cache reads and writes open cache.get llm_response and cache.set llm_response phase spans with their Redis spans nested underneath, on the Python path and on the native Rust path, and deployment selection runs inside a route {model_group} phase so the cooldown, usage and model-id reads the router issues nest under it before chat {model}. The autorouter classifier call nests under that route phase as well and carries its typed internal origin on litellm.request.purpose, so it is told apart from the provider attempt. Service spans are named {service}.{verb} {target} from a low-cardinality key family the producer declares (llm_response, auth_objects, spend_counters, router_cooldowns, claude_code_session_router_binding, rate_limits, pod_lock, budget_reset, ...) instead of the raw method or a per-request pipeline length; a pipeline flush is targeted by the one family its ops share or by mixed with the sorted families on litellm.redis.families, a batch op keeps the family it was declared under whichever pipeline or standalone read settles it, and the ambient family labels Redis spans only, never the DB write-back a task spawned inside that context performs later. The raw method stays on litellm.service.call_type and on the Prometheus and Datadog labels. Caller attribution is carried across asyncio task boundaries on a ContextVar so forwarder-only chains no longer surface, the raw cache key is dropped from Redis span metadata, pipeline op counts land as an integer attribute, every call_type the Redis cache layer emits maps to a verb, and a scan over litellm/ and enterprise/ fails when a Redis producer, batch reservation included, declares no key family.
A V2 logger built for a key or team logging entry while the operator's V2 logger is already registered keeps only the exporters its own preset contributed, whether or not the operator holds credentials for that backend, so every chat span no longer reaches the operator's collector twice. A span the success callback has to open itself, with no pre-call carrier, starts at the provider handoff (api_call_start_time) instead of the logging object's creation.
Co-authored-by: yassin <yassin@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
127 lines
4.7 KiB
Python
127 lines
4.7 KiB
Python
"""Atomic affinity claims shared by deployment and tier-model selection."""
|
|
|
|
import json
|
|
from collections.abc import Mapping
|
|
from typing import (
|
|
Final,
|
|
cast, # noqa: TID251 # Redis script results are narrowed only to object, then validated
|
|
)
|
|
|
|
from pydantic import JsonValue, TypeAdapter, ValidationError
|
|
|
|
from litellm._logging import verbose_router_logger
|
|
from litellm.caching.dual_cache import DualCache
|
|
|
|
ROUTER_SESSION_PINS_TARGET: Final = "router_session_pins"
|
|
|
|
_PIN_JSON_ADAPTER: Final = TypeAdapter[JsonValue](JsonValue)
|
|
|
|
_CLAIM_PIN_SCRIPT: Final = """
|
|
local current = redis.call('GET', KEYS[1])
|
|
if current == false then
|
|
redis.call('SET', KEYS[1], ARGV[1], 'EX', ARGV[2])
|
|
return ARGV[1]
|
|
end
|
|
if ARGV[3] then
|
|
local decoded, stored = pcall(cjson.decode, current)
|
|
if decoded and type(stored) == 'table' then
|
|
for _, eligible in ipairs(cjson.decode(ARGV[3])) do
|
|
local matches = true
|
|
for key, value in pairs(eligible) do
|
|
if stored[key] ~= value then matches = false; break end
|
|
end
|
|
for key, _ in pairs(stored) do
|
|
if eligible[key] == nil then matches = false; break end
|
|
end
|
|
if matches then
|
|
redis.call('EXPIRE', KEYS[1], ARGV[2])
|
|
return current
|
|
end
|
|
end
|
|
end
|
|
redis.call('SET', KEYS[1], ARGV[1], 'EX', ARGV[2])
|
|
return ARGV[1]
|
|
end
|
|
if current == ARGV[1] then
|
|
redis.call('EXPIRE', KEYS[1], ARGV[2])
|
|
end
|
|
return current
|
|
"""
|
|
|
|
|
|
def set_local_affinity_pin(cache: DualCache, cache_key: str, value: object, ttl_seconds: int) -> None:
|
|
"""Replace the entry because InMemoryCache.set_cache preserves a live key's expiry."""
|
|
cache.in_memory_cache.delete_cache(cache_key)
|
|
cache.in_memory_cache.set_cache(cache_key, value, ttl=ttl_seconds)
|
|
|
|
|
|
def _legacy_pin_matches(stored: object, pin_value: Mapping[str, str]) -> bool:
|
|
if isinstance(stored, dict):
|
|
return all(stored.get(key) is not None and str(stored[key]) == value for key, value in pin_value.items())
|
|
return isinstance(stored, str) and len(pin_value) == 1 and stored in pin_value.values()
|
|
|
|
|
|
def claim_affinity_pin_in_memory(
|
|
cache: DualCache,
|
|
cache_key: str,
|
|
pin_value: Mapping[str, str],
|
|
ttl_seconds: int,
|
|
*,
|
|
eligible_values: tuple[Mapping[str, str], ...] | None = None,
|
|
) -> object:
|
|
"""No await between read and write, so same-loop claims agree during a Redis outage."""
|
|
existing: Final[object] = cache.in_memory_cache.get_cache(cache_key)
|
|
if existing is not None and eligible_values is None:
|
|
if _legacy_pin_matches(existing, pin_value):
|
|
set_local_affinity_pin(cache, cache_key, pin_value, ttl_seconds)
|
|
return existing
|
|
winner: Final = existing if existing is not None and existing in (eligible_values or ()) else pin_value
|
|
set_local_affinity_pin(cache, cache_key, winner, ttl_seconds)
|
|
return winner
|
|
|
|
|
|
def _decode_pin(value: str) -> object:
|
|
try:
|
|
return _PIN_JSON_ADAPTER.validate_json(value)
|
|
except ValidationError:
|
|
return value
|
|
|
|
|
|
async def claim_affinity_pin(
|
|
cache: DualCache,
|
|
cache_key: str,
|
|
pin_value: Mapping[str, str],
|
|
ttl_seconds: int,
|
|
*,
|
|
eligible_values: tuple[Mapping[str, str], ...] | None = None,
|
|
) -> object:
|
|
"""Return the authoritative first writer, replacing it only when it becomes ineligible.
|
|
|
|
Eligible claims refresh the returned winner. Legacy deployment claims only refresh
|
|
a matching candidate. Resolve Redis per call because the proxy attaches it lazily.
|
|
"""
|
|
redis_cache: Final = cache.redis_cache
|
|
if redis_cache is not None:
|
|
try:
|
|
claim_script: Final = redis_cache.async_register_script(_CLAIM_PIN_SCRIPT)
|
|
args: Final = (
|
|
json.dumps(dict(pin_value)),
|
|
int(ttl_seconds),
|
|
*(
|
|
(json.dumps(tuple(dict(value) for value in eligible_values)),)
|
|
if eligible_values is not None
|
|
else ()
|
|
),
|
|
)
|
|
raw: Final = cast( # cast-ok: Redis scripts return heterogeneous values; only object is asserted here
|
|
object, await claim_script(keys=(cache_key,), args=args)
|
|
)
|
|
decoded: Final = raw.decode("utf-8") if isinstance(raw, bytes) else raw
|
|
if not isinstance(decoded, str):
|
|
return pin_value
|
|
winner: Final = _decode_pin(decoded)
|
|
set_local_affinity_pin(cache, cache_key, winner, ttl_seconds)
|
|
return winner
|
|
except Exception as error: # noqa: BLE001 # Redis/Lua faults retain same-pod affinity through local claims
|
|
verbose_router_logger.debug("Affinity cache: Redis claim failed, using pod-local claim. error=%s", error)
|
|
return claim_affinity_pin_in_memory(cache, cache_key, pin_value, ttl_seconds, eligible_values=eligible_values)
|