mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-14 09:03:16 +00:00
Fix latest alias Rust fallback and failure accounting
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
35f8a298ae
commit
e48ab681d9
8 changed files with 152 additions and 29 deletions
|
|
@ -800,6 +800,31 @@ def _as_float(value: object) -> float | None:
|
|||
return None
|
||||
|
||||
|
||||
def _entity_model_budget_entries(
|
||||
model: str,
|
||||
entity_budget: tuple[Litellm_EntityType, str | None, object],
|
||||
model_access_groups: ModelAccessGroups,
|
||||
team_id: str | None,
|
||||
) -> tuple[tuple[Litellm_EntityType, str, ResolvedModelBudget], ...]:
|
||||
entity_type, entity_id, model_max_budget = entity_budget
|
||||
if entity_id is None or not isinstance(model_max_budget, Mapping) or not model_max_budget:
|
||||
return ()
|
||||
return tuple(
|
||||
(entity_type, entity_id, resolved)
|
||||
for candidate in expand_model_latest_alias(model)
|
||||
if (
|
||||
resolved := resolve_model_budget(
|
||||
model=candidate,
|
||||
model_max_budget=model_max_budget,
|
||||
model_access_groups=model_access_groups,
|
||||
team_id=team_id,
|
||||
)
|
||||
)
|
||||
is not None
|
||||
and resolved.budget_config.budget_duration is not None
|
||||
)
|
||||
|
||||
|
||||
def _resolve_entity_model_budgets(
|
||||
model: str,
|
||||
entity_budgets: Iterable[tuple[Litellm_EntityType, str | None, object]],
|
||||
|
|
@ -807,21 +832,14 @@ def _resolve_entity_model_budgets(
|
|||
team_id: str | None,
|
||||
) -> tuple[tuple[Litellm_EntityType, str, ResolvedModelBudget], ...]:
|
||||
"""Drop the scopes that do not budget `model`, keeping only what can be incremented."""
|
||||
entries: Final = itertools.chain.from_iterable(
|
||||
_entity_model_budget_entries(model, entity_budget, model_access_groups, team_id)
|
||||
for entity_budget in entity_budgets
|
||||
)
|
||||
return tuple(
|
||||
{
|
||||
(entity_type, entity_id, resolved.budget_model): (entity_type, entity_id, resolved)
|
||||
for entity_type, entity_id, model_max_budget in entity_budgets
|
||||
if entity_id is not None and isinstance(model_max_budget, Mapping) and model_max_budget
|
||||
for candidate in expand_model_latest_alias(model)
|
||||
for resolved in (
|
||||
resolve_model_budget(
|
||||
model=candidate,
|
||||
model_max_budget=model_max_budget,
|
||||
model_access_groups=model_access_groups,
|
||||
team_id=team_id,
|
||||
),
|
||||
)
|
||||
if resolved is not None and resolved.budget_config.budget_duration is not None
|
||||
for entity_type, entity_id, resolved in entries
|
||||
}.values()
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -5882,14 +5882,6 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
|
|||
)
|
||||
)
|
||||
|
||||
elif tpm_actual > 0:
|
||||
failed_kwargs: Final = dict(failed_call)
|
||||
failed_model: Final = get_model_group_from_litellm_kwargs(failed_kwargs)
|
||||
if failed_model is not None and len(expand_model_latest_alias(failed_model)) > 1:
|
||||
pipeline_operations.extend(
|
||||
self._build_success_event_pipeline_operations(failed_kwargs, None, self.get_rate_limit_type())
|
||||
)
|
||||
|
||||
# Settle project ITPM/OTPM reservations the same way: at the
|
||||
# recovered partial usage, or a full refund when there is none.
|
||||
itpm_operations: Final = (
|
||||
|
|
|
|||
|
|
@ -110,6 +110,7 @@ class Feature(str, Enum):
|
|||
"""A model_list that Python fans out across its deployments."""
|
||||
MODEL_ALIAS_MAP = "model_alias_map"
|
||||
"""A model that litellm.model_alias_map rewrites before the call."""
|
||||
LATEST_ALIAS = "latest_alias"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
|
@ -153,6 +154,7 @@ COMMON_PYTHON_FEATURES: Final = frozenset(
|
|||
Feature.TIMEOUT_VARIANTS,
|
||||
Feature.AGENTIC_LOOP_HOOK,
|
||||
Feature.TLS_POLICY,
|
||||
Feature.LATEST_ALIAS,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ from typing import Final
|
|||
from pydantic import TypeAdapter
|
||||
|
||||
import litellm
|
||||
from litellm.litellm_core_utils.model_latest_aliases import is_model_latest_alias
|
||||
from litellm.rust_bridge.detectors import (
|
||||
agentic_loop_hook,
|
||||
api_base,
|
||||
|
|
@ -40,9 +39,7 @@ def model_list(request: NativeCall) -> bool:
|
|||
def model_alias_map(request: NativeCall) -> bool:
|
||||
"""Python rewrites this model through the configured alias map."""
|
||||
model: Final = str(request.resolved["model"])
|
||||
return model in litellm.model_alias_map or is_model_latest_alias(
|
||||
model, optional_str(request.resolved.get("custom_llm_provider"))
|
||||
)
|
||||
return model in litellm.model_alias_map
|
||||
|
||||
|
||||
def chat_retries_fallbacks(request: NativeCall) -> bool:
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import httpx
|
|||
import litellm
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.litellm_core_utils.core_helpers import normalize_drop_params
|
||||
from litellm.litellm_core_utils.model_latest_aliases import is_model_latest_alias
|
||||
from litellm.rust_bridge.catalog import Feature
|
||||
from litellm.rust_bridge.public_call import NativeCall, optional_mapping, optional_sequence, optional_str
|
||||
|
||||
|
|
@ -35,6 +36,13 @@ def mock_response(request: NativeCall) -> bool:
|
|||
return present(request, "mock_response")
|
||||
|
||||
|
||||
def latest_alias(request: NativeCall) -> bool:
|
||||
return is_model_latest_alias(
|
||||
optional_str(request.resolved.get("model")) or "",
|
||||
optional_str(request.resolved.get("custom_llm_provider")),
|
||||
)
|
||||
|
||||
|
||||
def caller_client(request: NativeCall) -> bool:
|
||||
"""Python sends through the caller's HTTP client or shared session."""
|
||||
return present(request, "client") or present(request, "shared_session")
|
||||
|
|
@ -179,5 +187,6 @@ COMMON: Final[Detectors] = MappingProxyType(
|
|||
Feature.TIMEOUT_VARIANTS: timeout_variants,
|
||||
Feature.AGENTIC_LOOP_HOOK: agentic_loop_hook,
|
||||
Feature.TLS_POLICY: tls_policy,
|
||||
Feature.LATEST_ALIAS: latest_alias,
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -192,7 +192,8 @@ async def test_latest_alias_collision_charges_and_settles_one_counter_per_metric
|
|||
await handler.async_log_success_event(kwargs, ModelResponse(model=target, usage=usage), None, None)
|
||||
else:
|
||||
await handler.async_log_failure_event(kwargs, None, None, None)
|
||||
assert await cache.async_get_cache(tpm_key) == (0 if event == "failure" else 5) + (0 if reservation else 1)
|
||||
actual_tokens: Final = 5 if event == "success" or (event == "partial" and reservation) else 0
|
||||
assert await cache.async_get_cache(tpm_key) == actual_tokens + (0 if reservation else 1)
|
||||
alias_key: Final = handler.create_rate_limit_keys(f"model_per_{scope}", f"{entity}:{alias}", "tokens")
|
||||
assert await cache.async_get_cache(alias_key) is None
|
||||
assert await handler.async_pre_call_hook(auth, cache, {"model": alias}, "acompletion") is None
|
||||
|
|
@ -270,9 +271,10 @@ async def test_latest_alias_collision_settles_the_target_team_limit_beside_the_k
|
|||
await handler.async_log_success_event(kwargs, ModelResponse(model=target, usage=usage), None, None)
|
||||
else:
|
||||
await handler.async_log_failure_event(kwargs, None, None, None)
|
||||
actual_tokens: Final = 5 if reservation or event == "success" else 0
|
||||
for scope, entity in (("model_per_key", auth.api_key), ("model_per_team", auth.team_id)):
|
||||
counter: Final = handler.create_rate_limit_keys(scope, f"{entity}:{target}", "tokens")
|
||||
assert await cache.async_get_cache(counter) == 5 + (0 if reservation else 1)
|
||||
assert await cache.async_get_cache(counter) == actual_tokens + (0 if reservation else 1)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -446,8 +448,81 @@ async def test_latest_alias_tpm_settles_both_counters(
|
|||
)
|
||||
else:
|
||||
await handler.async_log_failure_event(kwargs, None, None, None)
|
||||
actual_tokens: Final = 5 if event == "success" or (event == "partial" and reservation) else 0
|
||||
for key in (*keys, handler.create_rate_limit_keys("api_key", auth.api_key, "tokens")):
|
||||
assert int(await cache.async_get_cache(key) or 0) == (0 if event == "failure" else 5) + (0 if reservation else 1)
|
||||
assert int(await cache.async_get_cache(key) or 0) == actual_tokens + (0 if reservation else 1)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("scope", ("key", "team", "project", "organization"))
|
||||
@pytest.mark.parametrize("request_alias", (False, True))
|
||||
@pytest.mark.parametrize("reservation_released", (False, True))
|
||||
async def test_failed_latest_alias_without_open_reservation_matches_direct_model_accounting(
|
||||
scope: str,
|
||||
request_alias: bool,
|
||||
reservation_released: bool,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setenv("LITELLM_TPM_TOKEN_RESERVATION_ENABLED", str(reservation_released).lower())
|
||||
cache: Final = DualCache()
|
||||
handler: Final = _PROXY_MaxParallelRequestsHandler(
|
||||
internal_usage_cache=InternalUsageCache(cache),
|
||||
time_provider=lambda: datetime(2026, 1, 1, tzinfo=timezone.utc),
|
||||
)
|
||||
alias: Final = "anthropic/claude-opus-latest"
|
||||
target: Final = f"anthropic/{get_model_latest_aliases()['anthropic']['claude-opus-latest']}"
|
||||
model: Final = alias if request_alias else target
|
||||
limits: Final = {"model_tpm_limit": {alias: 10000, target: 10000}}
|
||||
auth: Final = UserAPIKeyAuth(
|
||||
api_key="failed-latest-key",
|
||||
team_id="failed-latest-team",
|
||||
project_id="failed-latest-project",
|
||||
org_id="failed-latest-org",
|
||||
tpm_limit=10000,
|
||||
metadata=limits if scope == "key" else {},
|
||||
team_metadata=limits if scope == "team" else {},
|
||||
project_metadata=limits if scope == "project" else {},
|
||||
organization_metadata=limits if scope == "organization" else {},
|
||||
)
|
||||
request_data: Final = {"model": model, "messages": [{"role": "user", "content": "hi"}], "max_tokens": 64}
|
||||
await handler.async_pre_call_hook(auth, cache, request_data, "acompletion")
|
||||
if reservation_released:
|
||||
await handler.async_post_call_failure_hook(
|
||||
request_data=request_data,
|
||||
original_exception=Exception("rejected"),
|
||||
user_api_key_dict=auth,
|
||||
)
|
||||
entity: Final = {
|
||||
"key": auth.api_key,
|
||||
"team": auth.team_id,
|
||||
"project": auth.project_id,
|
||||
"organization": auth.org_id,
|
||||
}[scope]
|
||||
models: Final = (alias, target) if request_alias else (target,)
|
||||
keys: Final = (
|
||||
handler.create_rate_limit_keys("api_key", auth.api_key, "tokens"),
|
||||
*(handler.create_rate_limit_keys(f"model_per_{scope}", f"{entity}:{name}", "tokens") for name in models),
|
||||
)
|
||||
await handler.async_log_failure_event(
|
||||
{
|
||||
"standard_logging_object": {
|
||||
"model_group": model,
|
||||
"metadata": {
|
||||
"user_api_key_hash": auth.api_key,
|
||||
"user_api_key_team_id": auth.team_id,
|
||||
"user_api_key_project_id": auth.project_id,
|
||||
"user_api_key_org_id": auth.org_id,
|
||||
},
|
||||
},
|
||||
"litellm_params": {"metadata": {"model_group": model, "user_api_key_metadata": auth.metadata}},
|
||||
"combined_usage_object": Usage(prompt_tokens=3, completion_tokens=2, total_tokens=5),
|
||||
},
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
for key in keys:
|
||||
assert int(await cache.async_get_cache(key) or 0) == (0 if reservation_released else 1)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ from typing import Final
|
|||
|
||||
import pytest
|
||||
|
||||
from litellm.rust_bridge import catalog, configuration
|
||||
from litellm.litellm_core_utils.model_latest_aliases import get_model_latest_aliases
|
||||
from litellm.rust_bridge import catalog, configuration, detectors
|
||||
from litellm.rust_bridge.catalog import (
|
||||
Feature,
|
||||
Route,
|
||||
|
|
@ -15,6 +16,32 @@ from litellm.rust_bridge.catalog import (
|
|||
Target,
|
||||
)
|
||||
from litellm.rust_bridge.configuration import Decision, Rollout
|
||||
from litellm.rust_bridge.public_call import NativeCall
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("model", "expected"),
|
||||
(
|
||||
("anthropic/claude-opus-latest", Rollout.PYTHON_ONLY),
|
||||
(f"anthropic/{get_model_latest_aliases()['anthropic']['claude-opus-latest']}", Rollout.RUST_OPT_IN),
|
||||
),
|
||||
)
|
||||
def test_latest_alias_messages_falls_back_without_changing_the_pinned_model_rollout(
|
||||
model: str,
|
||||
expected: Rollout,
|
||||
) -> None:
|
||||
request: Final = NativeCall(args=(), kwargs={"model": model}, base={})
|
||||
context: Final = RouteContext(
|
||||
Route.MESSAGES,
|
||||
provider="anthropic",
|
||||
model=model,
|
||||
features=detectors.detected(detectors.COMMON, request),
|
||||
)
|
||||
rules: Final = (
|
||||
RouteRule(Route.MESSAGES, Rollout.PYTHON_ONLY, features=catalog.MESSAGES_PYTHON_FEATURES),
|
||||
RouteRule(Route.MESSAGES, Rollout.RUST_OPT_IN, providers=frozenset({"anthropic"})),
|
||||
)
|
||||
assert catalog.rollout(context, rules) is expected
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
|
|
|
|||
|
|
@ -52,10 +52,13 @@ def call(**kwargs: object) -> NativeCall:
|
|||
("gpt-luna-latest", "openai", True),
|
||||
("gemini/gemini-pro-latest", None, False),
|
||||
("gpt-luna-latest", None, False),
|
||||
("anthropic/claude-opus-latest", None, True),
|
||||
("claude-opus-latest", "anthropic", True),
|
||||
("", None, False),
|
||||
),
|
||||
)
|
||||
def test_floating_alias_requires_python(model: str, provider: str | None, expected: bool) -> None:
|
||||
assert chat_detectors.model_alias_map(call(model=model, custom_llm_provider=provider)) is expected
|
||||
assert detectors.latest_alias(call(model=model, custom_llm_provider=provider)) is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue