mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-15 09:12:18 +00:00
fix(sdk): stop global model_fallbacks from recursing on every completion call (#51817)
* fix(sdk): stop global model_fallbacks from recursing on every completion call With litellm.model_fallbacks set, acompletion and completion read the global chain on every call, including the calls the fallback runner itself makes for each attempt. Each attempt started a new fallback run, so the calls grew into a tree until Python hit RecursionError and no provider was ever reached. The fallback runner now marks every attempt with an internal _litellm_ kwarg, and one resolver in fallback_utils decides the chain for a call. A marked attempt gets no chain. Otherwise a per-call fallbacks value wins, including an empty list to opt out, and the global list is used only when the call sets none. An empty chain skips the runner. The resolved chain is what the runner gets, so completion's sync path passes it explicitly. The marker is dropped from provider params and cache keys like every other _litellm_ kwarg * fix(fallbacks): keep each provider's credentials, typed errors, sync streams and one success log A fallback to another provider drops the original request's credentials and endpoint, and a fallback to another model drops per-call custom pricing. When every attempt fails, the original model's typed error is re-raised. The sync runner calls litellm.completion directly so sync streams work, and the sync wrapper leaves the success log to the attempt inside the runner. Health checks never fall back, and every attempt keeps the caller's litellm_call_id. * fix(fallbacks): set up fallbacks only when tried and keep health checks off the global list in every mode Each fallback is now built only when the runner reaches it, using the provider lookup that never starts a ChatGPT or GitHub Copilot device login, so an unused fallback cannot block a healthy call Health checks run inside a scope that turns the global model_fallbacks off, which also covers modes bridged to chat. The per-site fallbacks=() overrides are removed The sync logging test now forces the inner and outer success handlers to overlap with a barrier instead of sleeping * fix(fallbacks): keep guardrails on sync attempts and keep the global list out of Router calls The sync fallback runner now runs the deployment pre and post call hooks around every attempt, so guardrails a sync call names still run on each model, and it skips the post hook for streams like the async wrapper does Router deployment calls run with the global list turned off, so on the proxy the Router owns fallbacks and a fallback answer is never priced or logged as the failed deployment A fallback to another provider now also drops the request's headers and litellm_credential_name, and an original model whose provider cannot be looked up counts as a switch When every model fails the caller gets main's documented "All fallback attempts failed" error again * fix(fallbacks): keep custom provider settings for unprefixed fallbacks and never cache a rejected answer An unprefixed fallback whose name resolves to no provider on its own now inherits the request's custom_llm_provider, so a fallback on the same OpenAI-compatible server keeps its api_base and key as it did on main Sync fallback attempts no longer write to the cache, since their guardrail runs after the inner call returns. The outer call still caches the answer that passed, so a rejected answer never lands in the cache * fix(fallbacks): run a sync attempt's output guardrail before it is cached or logged The sync runner ran the post-call guardrail after litellm.completion returned, so the attempt's success log already held the raw answer and a rejected answer was logged as a success. The sync wrapper now runs the guardrail for a fallback attempt at the same point the async wrapper does, before the cache write and the success log, which also replaces the no-store workaround * fix(fallbacks): keep search grants, deployment hooks and logs intact Router deployment calls wrap their handler to turn the global list off, which hid asearch from the search fallback grant check. The check now unwraps the handler down to asearch A failed sync fallback attempt runs the post-call failure deployment hook again, as main did through its async detour atext_completion through the global list uses the async runner so it logs one success, and check_valid_key turns the global list off so a fallback answer never makes a bad key look valid * fix(proxy): keep the global fallback list off on direct SDK calls Pass-through models and routes the Router does not serve reach litellm directly, so the global list ran there with no check that the caller's key may use the fallback model. Those calls now turn the global list off, as Router deployment calls already do * fix(fallbacks): price a fallback by its own model and skip the proxy global list A fallback to another model kept the original call's base_model, so its answer was billed at the original model's rates, as on main. base_model now drops with the other per-call pricing params The proxy turns the global fallback list off on every model call, so litellm_settings.model_fallbacks now logs a startup warning that points to router_settings.fallbacks and is not applied --------- Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
This commit is contained in:
parent
84f975d128
commit
fd6e4fb6a5
13 changed files with 1241 additions and 104 deletions
|
|
@ -82,6 +82,7 @@ def _admin_config_fields_to_clear_on_base_override() -> list[str]:
|
|||
|
||||
|
||||
_ADMIN_CONFIG_FIELDS_TO_CLEAR_ON_BASE_OVERRIDE: Final = _admin_config_fields_to_clear_on_base_override()
|
||||
UPSTREAM_BOUND_PARAMS: Final = frozenset((*clientside_credential_keys, *_ADMIN_CONFIG_FIELDS_TO_CLEAR_ON_BASE_OVERRIDE))
|
||||
|
||||
|
||||
def is_clientside_credential(request_kwargs: dict) -> bool:
|
||||
|
|
|
|||
|
|
@ -1,85 +1,241 @@
|
|||
from typing import Final
|
||||
import functools
|
||||
import inspect
|
||||
from collections.abc import Awaitable, Callable, Generator, Iterator, Mapping
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import dataclass
|
||||
from types import MappingProxyType
|
||||
from typing import Final, ParamSpec, TypeAlias, TypeVar
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
import litellm
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm._uuid import uuid
|
||||
from litellm.constants import INTERNAL_KWARG_PREFIX
|
||||
from litellm.litellm_core_utils.add_retry_fallback_headers import (
|
||||
add_fallback_headers_to_response,
|
||||
)
|
||||
from litellm.litellm_core_utils.asyncify import run_async_function
|
||||
from litellm.litellm_core_utils.clientside_credential_handler import UPSTREAM_BOUND_PARAMS
|
||||
from litellm.litellm_core_utils.core_helpers import (
|
||||
filter_internal_params,
|
||||
safe_deep_copy,
|
||||
)
|
||||
from litellm.types.utils import CallTypes, CustomPricingLiteLLMParams
|
||||
|
||||
from .asyncify import run_async_function
|
||||
FALLBACK_ATTEMPT_KWARG: Final = f"{INTERNAL_KWARG_PREFIX}fallback_attempt"
|
||||
|
||||
FallbackChain: TypeAlias = tuple[str | dict[str, object], ...]
|
||||
_FALLBACK_CHAIN: Final = TypeAdapter(FallbackChain)
|
||||
_REQUEST_KWARGS: Final = TypeAdapter(dict[str, object])
|
||||
_NO_OVERRIDES: Final[Mapping[str, object]] = MappingProxyType({})
|
||||
_RUNNER_PARAMS: Final = frozenset({"kwargs", "fallbacks", "acompletion", "model", "litellm_logging_obj"})
|
||||
_PROVIDER_BOUND_PARAMS: Final = UPSTREAM_BOUND_PARAMS | {
|
||||
"custom_llm_provider",
|
||||
"client",
|
||||
"headers",
|
||||
"litellm_credential_name",
|
||||
}
|
||||
_MODEL_BOUND_PARAMS: Final = frozenset(CustomPricingLiteLLMParams.model_fields) | {"base_model"}
|
||||
_GLOBAL_FALLBACKS_OFF: Final[ContextVar[bool]] = ContextVar("litellm_global_model_fallbacks_off", default=False)
|
||||
_P = ParamSpec("_P")
|
||||
_R = TypeVar("_R")
|
||||
|
||||
|
||||
async def async_completion_with_fallbacks(**kwargs):
|
||||
"""
|
||||
Asynchronously attempts completion with fallback models if the primary model fails.
|
||||
@contextmanager
|
||||
def without_global_fallbacks() -> Generator[None]:
|
||||
token: Final = _GLOBAL_FALLBACKS_OFF.set(True)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_GLOBAL_FALLBACKS_OFF.reset(token)
|
||||
|
||||
Args:
|
||||
**kwargs: Keyword arguments for completion, including:
|
||||
- model (str): Primary model to use
|
||||
- fallbacks (List[Union[str, dict]]): List of fallback models/configs
|
||||
- Other completion parameters
|
||||
|
||||
Returns:
|
||||
ModelResponse: The completion response from the first successful model
|
||||
async def _awaited(pending: Awaitable[object]) -> object:
|
||||
return await pending
|
||||
|
||||
Raises:
|
||||
Exception: If all models fail and no response is generated
|
||||
"""
|
||||
# Extract and prepare parameters
|
||||
nested_kwargs: Final = kwargs.pop("kwargs", {})
|
||||
original_model: Final = kwargs["model"]
|
||||
model = original_model
|
||||
fallbacks: Final = [original_model] + nested_kwargs.pop("fallbacks", [])
|
||||
kwargs.pop("acompletion", None) # Remove to prevent keyword conflicts
|
||||
litellm_call_id: Final = str(uuid.uuid4())
|
||||
base_kwargs: Final = {**kwargs, **nested_kwargs, "litellm_call_id": litellm_call_id}
|
||||
|
||||
# fields to remove
|
||||
base_kwargs.pop("model", None) # Remove model as it will be set per fallback
|
||||
litellm_logging_obj: Final = base_kwargs.pop("litellm_logging_obj", None)
|
||||
def without_global_fallbacks_around(endpoint: Callable[_P, _R]) -> Callable[_P, _R]:
|
||||
if not inspect.iscoroutinefunction(endpoint):
|
||||
|
||||
# Try each fallback model
|
||||
most_recent_exception_str: str | None = None
|
||||
for attempted_fallbacks, fallback in enumerate(fallbacks):
|
||||
try:
|
||||
completion_kwargs = safe_deep_copy(base_kwargs)
|
||||
# Handle dictionary fallback configurations
|
||||
if isinstance(fallback, dict):
|
||||
fallback_config = safe_deep_copy(dict(fallback))
|
||||
model = fallback_config.pop("model", original_model)
|
||||
completion_kwargs.update(fallback_config)
|
||||
else:
|
||||
model = fallback
|
||||
@functools.wraps(endpoint)
|
||||
def call(*args: _P.args, **kwargs: _P.kwargs) -> _R: # kwargs-ok: endpoint payload
|
||||
with without_global_fallbacks():
|
||||
return endpoint(*args, **kwargs)
|
||||
|
||||
# Filter out internal parameters that shouldn't be sent to provider APIs
|
||||
completion_kwargs = filter_internal_params(completion_kwargs)
|
||||
return call
|
||||
|
||||
response = await litellm.acompletion(
|
||||
**completion_kwargs,
|
||||
model=model,
|
||||
litellm_logging_obj=litellm_logging_obj,
|
||||
)
|
||||
@functools.wraps(endpoint)
|
||||
async def acall(*args: _P.args, **kwargs: _P.kwargs) -> object: # kwargs-ok: endpoint payload
|
||||
with without_global_fallbacks():
|
||||
result: Final = endpoint(*args, **kwargs)
|
||||
return await _awaited(result) if inspect.isawaitable(result) else result
|
||||
|
||||
if response is not None:
|
||||
return add_fallback_headers_to_response(
|
||||
response=response,
|
||||
attempted_fallbacks=attempted_fallbacks,
|
||||
)
|
||||
return acall # pyright: ignore[reportReturnType] # a coroutine endpoint's _R is the coroutine acall returns
|
||||
|
||||
except Exception as e:
|
||||
verbose_logger.exception("Fallback attempt failed for model %s: %s", model, e)
|
||||
most_recent_exception_str = str(e)
|
||||
continue
|
||||
|
||||
raise Exception(
|
||||
f"{most_recent_exception_str}. All fallback attempts failed. Enable verbose logging with `litellm.set_verbose=True` for details."
|
||||
def _configured_fallbacks(request_kwargs: Mapping[str, object]) -> object:
|
||||
if request_kwargs.get(FALLBACK_ATTEMPT_KWARG) is True:
|
||||
return None
|
||||
per_call: Final = request_kwargs.get("fallbacks")
|
||||
if per_call is not None:
|
||||
return per_call
|
||||
return None if _GLOBAL_FALLBACKS_OFF.get() else litellm.model_fallbacks
|
||||
|
||||
|
||||
def uses_fallbacks(request_kwargs: Mapping[str, object]) -> bool:
|
||||
return bool(_configured_fallbacks(request_kwargs))
|
||||
|
||||
|
||||
def resolve_fallbacks(request_kwargs: Mapping[str, object]) -> FallbackChain | None:
|
||||
configured: Final = _configured_fallbacks(request_kwargs)
|
||||
return _FALLBACK_CHAIN.validate_python(configured) if configured else None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _FallbackAttempt:
|
||||
model: str
|
||||
kwargs: Mapping[str, object]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _FallbackPlan:
|
||||
original_model: str
|
||||
base_kwargs: Mapping[str, object]
|
||||
fallbacks: FallbackChain
|
||||
logging_obj: object
|
||||
|
||||
|
||||
def _provider_of(model: str, custom_llm_provider: object) -> str | None:
|
||||
from litellm.litellm_core_utils.get_llm_provider_logic import resolve_model_and_provider_for_metadata
|
||||
|
||||
try:
|
||||
return resolve_model_and_provider_for_metadata(
|
||||
model, custom_llm_provider if isinstance(custom_llm_provider, str) else None
|
||||
)[1]
|
||||
except litellm.exceptions.BadRequestError:
|
||||
return None
|
||||
|
||||
|
||||
def _inherited_kwargs(
|
||||
base_kwargs: Mapping[str, object], original_model: str, model: str, custom_llm_provider: object
|
||||
) -> Mapping[str, object]:
|
||||
if model == original_model:
|
||||
return base_kwargs
|
||||
requested_provider: Final = base_kwargs.get("custom_llm_provider")
|
||||
original_provider: Final = _provider_of(original_model, requested_provider)
|
||||
provider: Final = _provider_of(model, custom_llm_provider) or _provider_of(model, requested_provider)
|
||||
switches_provider: Final = original_provider is None or original_provider != provider
|
||||
dropped: Final = (_MODEL_BOUND_PARAMS | _PROVIDER_BOUND_PARAMS) if switches_provider else _MODEL_BOUND_PARAMS
|
||||
return {key: value for key, value in base_kwargs.items() if key not in dropped}
|
||||
|
||||
|
||||
def _fallback_attempt(plan: _FallbackPlan, fallback: str | Mapping[str, object]) -> _FallbackAttempt:
|
||||
overrides: Final = _NO_OVERRIDES if isinstance(fallback, str) else fallback
|
||||
model: Final = fallback if isinstance(fallback, str) else str(overrides.get("model", plan.original_model))
|
||||
inherited: Final = _inherited_kwargs(
|
||||
plan.base_kwargs, plan.original_model, model, overrides.get("custom_llm_provider")
|
||||
)
|
||||
request: Final = filter_internal_params(safe_deep_copy({**inherited, **overrides}))
|
||||
return _FallbackAttempt(
|
||||
model=model,
|
||||
kwargs={**request, "model": model, "litellm_logging_obj": plan.logging_obj, FALLBACK_ATTEMPT_KWARG: True},
|
||||
)
|
||||
|
||||
|
||||
def completion_with_fallbacks(**kwargs):
|
||||
return run_async_function(async_function=async_completion_with_fallbacks, **kwargs)
|
||||
def _fallback_plan(runner_kwargs: Mapping[str, object]) -> _FallbackPlan:
|
||||
nested_kwargs: Final = _REQUEST_KWARGS.validate_python(runner_kwargs.get("kwargs") or {})
|
||||
merged: Final = {**runner_kwargs, **nested_kwargs}
|
||||
return _FallbackPlan(
|
||||
original_model=str(runner_kwargs["model"]),
|
||||
base_kwargs={
|
||||
**{key: value for key, value in merged.items() if key not in _RUNNER_PARAMS},
|
||||
"litellm_call_id": merged.get("litellm_call_id") or str(uuid.uuid4()),
|
||||
},
|
||||
fallbacks=_FALLBACK_CHAIN.validate_python(nested_kwargs.get("fallbacks") or ()),
|
||||
logging_obj=merged.get("litellm_logging_obj"),
|
||||
)
|
||||
|
||||
|
||||
def _fallback_attempts(plan: _FallbackPlan) -> Iterator[_FallbackAttempt]:
|
||||
return (_fallback_attempt(plan, fallback) for fallback in plan.fallbacks)
|
||||
|
||||
|
||||
def _all_attempts_failed(last_error: Exception) -> Exception:
|
||||
return Exception(
|
||||
f"{last_error}. All fallback attempts failed. Enable verbose logging with `litellm.set_verbose=True` for details."
|
||||
)
|
||||
|
||||
|
||||
async def async_completion_with_fallbacks(**kwargs: object) -> object:
|
||||
plan: Final = _fallback_plan(kwargs)
|
||||
primary: Final = _fallback_attempt(plan, plan.original_model)
|
||||
try:
|
||||
return add_fallback_headers_to_response(
|
||||
response=await litellm.acompletion(**primary.kwargs), attempted_fallbacks=0
|
||||
)
|
||||
except Exception as primary_error: # noqa: BLE001 # every failed attempt moves on to the next fallback
|
||||
verbose_logger.exception("Fallback attempt failed for model %s: %s", primary.model, primary_error)
|
||||
last_error: Exception = primary_error # rebind-ok: the newest failure names the final error
|
||||
for attempted_fallbacks, attempt in enumerate(_fallback_attempts(plan), start=1):
|
||||
try:
|
||||
return add_fallback_headers_to_response(
|
||||
response=await litellm.acompletion(**attempt.kwargs), attempted_fallbacks=attempted_fallbacks
|
||||
)
|
||||
except Exception as fallback_error: # noqa: BLE001 # any failed attempt moves on to the next fallback
|
||||
verbose_logger.exception("Fallback attempt failed for model %s: %s", attempt.model, fallback_error)
|
||||
last_error = fallback_error
|
||||
raise _all_attempts_failed(last_error) from last_error
|
||||
|
||||
|
||||
def _is_fallback_attempt(request_kwargs: Mapping[str, object], call_type: str) -> bool:
|
||||
return call_type == CallTypes.completion.value and request_kwargs.get(FALLBACK_ATTEMPT_KWARG) is True
|
||||
|
||||
|
||||
def checked_fallback_attempt(request_kwargs: Mapping[str, object], call_type: str, response: object) -> object:
|
||||
if not _is_fallback_attempt(request_kwargs, call_type):
|
||||
return response
|
||||
from litellm.utils import async_post_call_success_deployment_hook
|
||||
|
||||
return run_async_function(
|
||||
async_post_call_success_deployment_hook, request_kwargs, response, CallTypes.completion, include_v2=True
|
||||
)
|
||||
|
||||
|
||||
def report_failed_sync_fallback_attempt(request_kwargs: Mapping[str, object], call_type: str, error: Exception) -> None:
|
||||
if not _is_fallback_attempt(request_kwargs, call_type) or request_kwargs.get("acompletion") is True:
|
||||
return
|
||||
from litellm.utils import async_post_call_failure_deployment_hook
|
||||
|
||||
run_async_function(async_post_call_failure_deployment_hook, request_kwargs, error, call_type, include_v2=True)
|
||||
|
||||
|
||||
def _completion_with_pre_call_hooks(request_kwargs: Mapping[str, object]) -> object:
|
||||
from litellm.utils import async_pre_call_deployment_hook
|
||||
|
||||
request: Final = _REQUEST_KWARGS.validate_python(
|
||||
run_async_function(async_pre_call_deployment_hook, request_kwargs, CallTypes.completion.value, include_v2=True)
|
||||
)
|
||||
return litellm.completion(**request)
|
||||
|
||||
|
||||
def completion_with_fallbacks(**kwargs: object) -> object:
|
||||
plan: Final = _fallback_plan(kwargs)
|
||||
primary: Final = _fallback_attempt(plan, plan.original_model)
|
||||
try:
|
||||
return add_fallback_headers_to_response(
|
||||
response=_completion_with_pre_call_hooks(primary.kwargs), attempted_fallbacks=0
|
||||
)
|
||||
except Exception as primary_error: # noqa: BLE001 # every failed attempt moves on to the next fallback
|
||||
verbose_logger.exception("Fallback attempt failed for model %s: %s", primary.model, primary_error)
|
||||
last_error: Exception = primary_error # rebind-ok: the newest failure names the final error
|
||||
for attempted_fallbacks, attempt in enumerate(_fallback_attempts(plan), start=1):
|
||||
try:
|
||||
return add_fallback_headers_to_response(
|
||||
response=_completion_with_pre_call_hooks(attempt.kwargs), attempted_fallbacks=attempted_fallbacks
|
||||
)
|
||||
except Exception as fallback_error: # noqa: BLE001 # any failed attempt moves on to the next fallback
|
||||
verbose_logger.exception("Fallback attempt failed for model %s: %s", attempt.model, fallback_error)
|
||||
last_error = fallback_error
|
||||
raise _all_attempts_failed(last_error) from last_error
|
||||
|
|
|
|||
|
|
@ -243,6 +243,8 @@ from .litellm_core_utils.core_helpers import (
|
|||
from .litellm_core_utils.fallback_utils import (
|
||||
async_completion_with_fallbacks,
|
||||
completion_with_fallbacks,
|
||||
resolve_fallbacks,
|
||||
without_global_fallbacks,
|
||||
)
|
||||
from .litellm_core_utils.prompt_templates.common_utils import (
|
||||
add_system_prompt_to_messages,
|
||||
|
|
@ -748,7 +750,6 @@ async def acompletion(
|
|||
- The `completion` function is called using `run_in_executor` to execute synchronously in the event loop.
|
||||
- If `stream` is True, the function returns an async generator that yields completion lines.
|
||||
"""
|
||||
fallbacks: Final = kwargs.get("fallbacks", None) or litellm.model_fallbacks
|
||||
mock_timeout = kwargs.get("mock_timeout", None)
|
||||
|
||||
if mock_timeout is True:
|
||||
|
|
@ -765,6 +766,7 @@ async def acompletion(
|
|||
custom_llm_provider = kwargs.get("custom_llm_provider", None)
|
||||
_ = _resolve_control_options(kwargs, model)
|
||||
caller_kwargs: Final = cast("Mapping[str, object]", {**kwargs}) # cast-ok: acompletion takes untyped kwargs
|
||||
fallbacks: Final = resolve_fallbacks(caller_kwargs)
|
||||
|
||||
## PROMPT MANAGEMENT HOOKS ##
|
||||
#########################################################
|
||||
|
|
@ -922,7 +924,7 @@ async def acompletion(
|
|||
|
||||
if fallbacks is not None:
|
||||
response = await async_completion_with_fallbacks(
|
||||
**completion_kwargs, kwargs={"fallbacks": fallbacks, **caller_kwargs}
|
||||
**completion_kwargs, kwargs={**caller_kwargs, "fallbacks": fallbacks}
|
||||
)
|
||||
if response is None:
|
||||
raise Exception(
|
||||
|
|
@ -5784,7 +5786,6 @@ def completion(
|
|||
metadata: Final = kwargs.get("metadata", None)
|
||||
model_info: Final = kwargs.get("model_info", None)
|
||||
proxy_server_request: Final = kwargs.get("proxy_server_request", None)
|
||||
fallbacks = kwargs.get("fallbacks", None)
|
||||
provider_specific_header: Final = cast(
|
||||
ProviderSpecificHeader | Sequence[ProviderSpecificHeader] | None,
|
||||
kwargs.get("provider_specific_header", None),
|
||||
|
|
@ -5917,10 +5918,14 @@ def completion(
|
|||
elif num_retries is not None:
|
||||
max_retries = num_retries
|
||||
logging: Final[LiteLLMLoggingObj] = cast(LiteLLMLoggingObj, litellm_logging_obj)
|
||||
fallbacks = fallbacks or litellm.model_fallbacks
|
||||
routing_kwargs: Final = cast("Mapping[str, object]", kwargs) # cast-ok: kwargs is the untyped request dict
|
||||
fallbacks: Final = resolve_fallbacks(routing_kwargs)
|
||||
if fallbacks is not None:
|
||||
return completion_with_fallbacks( # pyright: ignore[reportReturnType] # fallback runner is untyped; resolves to ModelResponse|CustomStreamWrapper at runtime
|
||||
**args
|
||||
fallback_runner: Final = (
|
||||
async_completion_with_fallbacks if acompletion is True else completion_with_fallbacks
|
||||
)
|
||||
return fallback_runner( # pyright: ignore[reportReturnType] # fallback runner is untyped; resolves to ModelResponse|CustomStreamWrapper (or its coroutine) at runtime
|
||||
**{**args, "kwargs": {**kwargs, "fallbacks": fallbacks}}
|
||||
)
|
||||
if model_list is not None:
|
||||
deployments: Final = [m["litellm_params"] for m in model_list if m["model_name"] == model]
|
||||
|
|
@ -5940,7 +5945,6 @@ def completion(
|
|||
if deployment_id is not None: # azure llms
|
||||
model = deployment_id
|
||||
custom_llm_provider = "azure"
|
||||
routing_kwargs: Final = cast("Mapping[str, object]", kwargs) # cast-ok: kwargs is the untyped request dict
|
||||
requested_model: Final = model
|
||||
model, custom_llm_provider, dynamic_api_key, api_base = get_llm_provider(
|
||||
model=model,
|
||||
|
|
@ -9619,12 +9623,13 @@ async def ahealth_check(
|
|||
"batch": ("alist_batches" if custom_llm_provider in LIST_BATCHES_SUPPORTED_PROVIDERS else "acompletion"),
|
||||
}.get("decisions" if is_decisions_model_mode(mode) else mode, "acompletion")
|
||||
if "*" in model:
|
||||
return await HealthCheckHelpers.ahealth_check_wildcard_models(
|
||||
model=model,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
model_params=model_params,
|
||||
litellm_logging_obj=litellm_logging_obj,
|
||||
)
|
||||
with without_global_fallbacks():
|
||||
return await HealthCheckHelpers.ahealth_check_wildcard_models(
|
||||
model=model,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
model_params=model_params,
|
||||
litellm_logging_obj=litellm_logging_obj,
|
||||
)
|
||||
|
||||
mode_handlers: Final = HealthCheckHelpers.get_mode_handlers(
|
||||
model=model,
|
||||
|
|
@ -9636,7 +9641,8 @@ async def ahealth_check(
|
|||
|
||||
handler_mode: Final = "decisions" if is_decisions_model_mode(mode) else mode
|
||||
if handler_mode in mode_handlers:
|
||||
_response: Final = await mode_handlers[handler_mode]()
|
||||
with without_global_fallbacks():
|
||||
_response: Final = await mode_handlers[handler_mode]()
|
||||
_response_headers: Final = cast( # cast-ok: provider headers are stored as a string-keyed mapping
|
||||
Mapping[str, object], (get_hidden_params(_response) or {}).get("headers", {}) or {}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ checks for each fallback target before it is attempted. Enabled by default. Opt
|
|||
key, team and user search tool grants instead, the same check /search runs on the requested tool.
|
||||
"""
|
||||
|
||||
import inspect
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import Final
|
||||
|
|
@ -67,7 +68,8 @@ async def is_search_tool_authorized_for_token(*, search_tool_name: str, valid_to
|
|||
|
||||
|
||||
def _is_search_request(request_kwargs: Mapping[str, object]) -> bool:
|
||||
return request_kwargs.get("original_generic_function") is asearch
|
||||
handler: Final = request_kwargs.get("original_generic_function")
|
||||
return callable(handler) and inspect.unwrap(handler, stop=lambda function: function is asearch) is asearch
|
||||
|
||||
|
||||
def _token_in_metadata(metadata: object) -> UserAPIKeyAuth | None:
|
||||
|
|
|
|||
|
|
@ -7428,6 +7428,10 @@ class ProxyConfig:
|
|||
litellm.drop_params = drop_params_flag(value, "litellm_settings.drop_params", verbose_proxy_logger)
|
||||
elif key == "suppress_startup_output":
|
||||
continue
|
||||
elif key == "model_fallbacks":
|
||||
verbose_proxy_logger.warning(
|
||||
"litellm_settings.model_fallbacks has no effect on the proxy, set router_settings.fallbacks instead"
|
||||
)
|
||||
else:
|
||||
verbose_proxy_logger.debug(
|
||||
"%s setting litellm.%s=%s%s",
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ from pydantic import BeforeValidator, TypeAdapter, ValidationError
|
|||
|
||||
import litellm
|
||||
from litellm.litellm_core_utils.core_helpers import as_str_mapping, normalize_drop_params
|
||||
from litellm.litellm_core_utils.fallback_utils import without_global_fallbacks_around
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
from litellm.litellm_core_utils.routing_common_utils import is_proxy_admin_request
|
||||
from litellm.litellm_core_utils.total_deadline import enforce_total_timeout_scope
|
||||
|
|
@ -898,6 +899,9 @@ async def _route_request_single_attempt( # noqa: ANN202 # returns unawaited pr
|
|||
}
|
||||
)
|
||||
|
||||
def direct_litellm_endpoint(): # noqa: ANN202 # the endpoint is looked up by name, so its call stays as untyped as before
|
||||
return without_global_fallbacks_around(getattr(litellm, f"{route_type}"))
|
||||
|
||||
team_id: Final = get_team_id_from_data(data)
|
||||
router_model_names: Final = llm_router.registry().names(kind="group_set") if llm_router is not None else []
|
||||
is_proxy_admin_without_team: Final = team_id is None and is_proxy_admin_request(data)
|
||||
|
|
@ -911,7 +915,7 @@ async def _route_request_single_attempt( # noqa: ANN202 # returns unawaited pr
|
|||
if llm_router is not None:
|
||||
return getattr(llm_router, f"{route_type}")(**data)
|
||||
else:
|
||||
return getattr(litellm, f"{route_type}")(**direct_litellm_kwargs())
|
||||
return direct_litellm_endpoint()(**direct_litellm_kwargs())
|
||||
|
||||
elif (
|
||||
route_type == "acompletion"
|
||||
|
|
@ -966,7 +970,7 @@ async def _route_request_single_attempt( # noqa: ANN202 # returns unawaited pr
|
|||
# If we can't get deployment creds, continue without them
|
||||
pass
|
||||
|
||||
return getattr(litellm, f"{route_type}")(**direct_litellm_kwargs())
|
||||
return direct_litellm_endpoint()(**direct_litellm_kwargs())
|
||||
# Skip model-based routing for container operations
|
||||
if route_type in [
|
||||
"acreate_container",
|
||||
|
|
@ -1006,7 +1010,7 @@ async def _route_request_single_attempt( # noqa: ANN202 # returns unawaited pr
|
|||
else await _authorized_team_file_deployment(llm_router, model_less_team_file_call)
|
||||
)
|
||||
if team_file_deployment is not None:
|
||||
return getattr(litellm, f"{route_type}")(
|
||||
return direct_litellm_endpoint()(
|
||||
**_with_team_deployment_attribution(
|
||||
{**direct_litellm_kwargs(), **team_file_deployment.credentials},
|
||||
team_file_deployment,
|
||||
|
|
@ -1034,7 +1038,7 @@ async def _route_request_single_attempt( # noqa: ANN202 # returns unawaited pr
|
|||
"aingest",
|
||||
] and (data.get("model") is None or data.get("model") == ""):
|
||||
# These endpoints don't need a model, use custom_llm_provider directly
|
||||
return getattr(litellm, f"{route_type}")(**direct_litellm_kwargs())
|
||||
return direct_litellm_endpoint()(**direct_litellm_kwargs())
|
||||
|
||||
if "model" not in data:
|
||||
raise ProxyMissingRequiredParamError(
|
||||
|
|
@ -1075,7 +1079,7 @@ async def _route_request_single_attempt( # noqa: ANN202 # returns unawaited pr
|
|||
# Check wildcards before checking deployment_names
|
||||
# Priority: 1. Exact model_name match, 2. Wildcard match, 3. deployment_names match
|
||||
if llm_router.settings().router_general_settings.pass_through_all_models:
|
||||
return getattr(litellm, f"{route_type}")(**direct_litellm_kwargs())
|
||||
return direct_litellm_endpoint()(**direct_litellm_kwargs())
|
||||
elif (
|
||||
llm_router.settings().default_deployment is not None
|
||||
or len(llm_router.registry().patterns().patterns) > 0
|
||||
|
|
@ -1130,7 +1134,7 @@ async def _route_request_single_attempt( # noqa: ANN202 # returns unawaited pr
|
|||
return getattr(llm_router, f"{route_type}")(**data)
|
||||
except Exception:
|
||||
# If router fails (e.g., model not found in router), fall back to direct call
|
||||
return getattr(litellm, f"{route_type}")(**direct_litellm_kwargs())
|
||||
return direct_litellm_endpoint()(**direct_litellm_kwargs())
|
||||
elif _is_a2a_agent_model(data.get("model", "")):
|
||||
from litellm.proxy.agent_endpoints.a2a_routing import (
|
||||
route_a2a_agent_request,
|
||||
|
|
@ -1142,7 +1146,7 @@ async def _route_request_single_attempt( # noqa: ANN202 # returns unawaited pr
|
|||
# Fall through to raise exception below if result is None
|
||||
|
||||
elif user_model is not None or route_type == "allm_passthrough_route":
|
||||
return getattr(litellm, f"{route_type}")(**direct_litellm_kwargs())
|
||||
return direct_litellm_endpoint()(**direct_litellm_kwargs())
|
||||
|
||||
# if no route found then it's a bad request
|
||||
route_name: Final = ROUTE_ENDPOINT_MAPPING.get(route_type, route_type)
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ from pydantic import TypeAdapter
|
|||
from typing_extensions import overload
|
||||
|
||||
import litellm
|
||||
from litellm.litellm_core_utils.fallback_utils import without_global_fallbacks_around
|
||||
from litellm.router_config import RouterConfig, RouterDynamicConfig, live_dynamic_config
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
|
@ -111,7 +112,7 @@ class PythonHost:
|
|||
raise AttributeError(f"litellm has no endpoint {op!r}")
|
||||
if not callable(endpoint):
|
||||
raise TypeError(f"litellm.{op} is not an endpoint")
|
||||
return endpoint
|
||||
return without_global_fallbacks_around(endpoint)
|
||||
|
||||
def sleep(self, seconds: float) -> Awaitable[None]:
|
||||
return asyncio.sleep(seconds)
|
||||
|
|
|
|||
|
|
@ -106,6 +106,12 @@ from litellm.litellm_core_utils.fallback_generalizations import (
|
|||
match_capability_generalizations,
|
||||
match_fill_missing_generalizations,
|
||||
)
|
||||
from litellm.litellm_core_utils.fallback_utils import (
|
||||
checked_fallback_attempt,
|
||||
report_failed_sync_fallback_attempt,
|
||||
uses_fallbacks,
|
||||
without_global_fallbacks,
|
||||
)
|
||||
from litellm.litellm_core_utils.get_model_cost_map import (
|
||||
GetModelCostMap,
|
||||
fetch_registration_document,
|
||||
|
|
@ -2360,7 +2366,11 @@ def client(original_function):
|
|||
|
||||
clamp_max_tokens(model, args, kwargs, call_type)
|
||||
# MODEL CALL
|
||||
result = original_function(*args, **kwargs)
|
||||
try:
|
||||
result = original_function(*args, **kwargs)
|
||||
except Exception as deployment_error:
|
||||
report_failed_sync_fallback_attempt(kwargs, call_type, deployment_error)
|
||||
raise
|
||||
end_time = datetime.datetime.now()
|
||||
if _returns_requested_stream(kwargs, call_type, result) or _is_converted_stream_result(result):
|
||||
if "complete_response" in kwargs and kwargs["complete_response"] is True:
|
||||
|
|
@ -2414,17 +2424,18 @@ def client(original_function):
|
|||
original_function=original_function,
|
||||
rules_obj=rules_obj,
|
||||
)
|
||||
checked_result: Final = checked_fallback_attempt(kwargs, call_type, result)
|
||||
|
||||
# [OPTIONAL] ADD TO CACHE
|
||||
_llm_caching_handler.sync_set_cache(
|
||||
result=result,
|
||||
result=checked_result,
|
||||
args=args,
|
||||
kwargs=kwargs,
|
||||
)
|
||||
|
||||
_update_response_metadata: Final[_ResponseMetadataUpdater] = litellm_utils.update_response_metadata
|
||||
_update_response_metadata(
|
||||
result=result,
|
||||
result=checked_result,
|
||||
logging_obj=logging_obj,
|
||||
model=model,
|
||||
kwargs=kwargs,
|
||||
|
|
@ -2434,7 +2445,8 @@ def client(original_function):
|
|||
|
||||
# LOG SUCCESS - handle streaming success logging in the _next_ object, remove `handle_success` once it's deprecated
|
||||
verbose_logger.info("Wrapper: Completed Call, calling success_handler")
|
||||
if not is_internal_call.get():
|
||||
logged_by_fallback_attempt: Final = call_type == CallTypes.completion.value and uses_fallbacks(kwargs)
|
||||
if not is_internal_call.get() and not logged_by_fallback_attempt:
|
||||
# Copy the current context to propagate it to the background thread
|
||||
# This is essential for OpenTelemetry span context propagation
|
||||
ctx: Final = contextvars.copy_context()
|
||||
|
|
@ -2442,12 +2454,12 @@ def client(original_function):
|
|||
executor.submit(
|
||||
ctx.run,
|
||||
logging_obj.success_handler,
|
||||
result,
|
||||
checked_result,
|
||||
start_time,
|
||||
end_time,
|
||||
)
|
||||
# RETURN RESULT
|
||||
return result
|
||||
return checked_result
|
||||
except Exception as e:
|
||||
if call_type == CallTypes.completion.value:
|
||||
num_retries = kwargs.get("num_retries", None) or litellm.num_retries or None
|
||||
|
|
@ -2546,7 +2558,7 @@ def client(original_function):
|
|||
kwargs["litellm_call_id"] = str(uuid.uuid4())
|
||||
|
||||
model: Final[str | None] = args[0] if len(args) > 0 else kwargs.get("model", None)
|
||||
is_completion_with_fallbacks: Final = kwargs.get("fallbacks") is not None
|
||||
is_completion_with_fallbacks: Final = bool(kwargs.get("fallbacks"))
|
||||
kwargs.pop("_is_litellm_internal_call", None) # discard if injected
|
||||
_is_litellm_internal_call: Final = is_internal_call.get()
|
||||
_deployment_call_end_time: datetime.datetime | None = None
|
||||
|
|
@ -8501,7 +8513,8 @@ def check_valid_key(model: str, api_key: str):
|
|||
"""
|
||||
messages: Final = [{"role": "user", "content": "Hey, how's it going?"}]
|
||||
try:
|
||||
litellm.completion(model=model, messages=messages, api_key=api_key, max_tokens=10)
|
||||
with without_global_fallbacks():
|
||||
litellm.completion(model=model, messages=messages, api_key=api_key, max_tokens=10)
|
||||
return True
|
||||
except AuthenticationError:
|
||||
return False
|
||||
|
|
|
|||
|
|
@ -1,23 +1,39 @@
|
|||
"""Tests for litellm.litellm_core_utils.fallback_utils."""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import pathlib
|
||||
from typing import Final
|
||||
import threading
|
||||
from collections.abc import Mapping
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Final, NoReturn
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
import litellm
|
||||
from litellm.caching.caching import Cache, DualCache
|
||||
from litellm.integrations.custom_guardrail import CustomGuardrail
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.litellm_core_utils import litellm_logging
|
||||
from litellm.litellm_core_utils.core_helpers import process_response_headers
|
||||
from litellm.litellm_core_utils.fallback_utils import (
|
||||
async_completion_with_fallbacks,
|
||||
)
|
||||
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
|
||||
from litellm.llms.chatgpt.common_utils import CHATGPT_DEVICE_CODE_URL
|
||||
from litellm.main import acompletion as python_acompletion
|
||||
from litellm.main import completion as python_completion
|
||||
from litellm.types.guardrails import GuardrailEventHooks
|
||||
from litellm.types.proxy.auth.user_api_key_auth import UserAPIKeyAuth
|
||||
from litellm.types.utils import CallTypes, CallTypesLiteral, Choices, CredentialItem, LLMResponseTypes, Message, ModelResponse
|
||||
from litellm.vector_stores.vector_store_registry import LiteLLM_ManagedVectorStore, VectorStoreRegistry
|
||||
|
||||
LITELLM_PARAMS: Final = TypeAdapter(dict[str, object])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_dict_not_mutated(monkeypatch):
|
||||
|
|
@ -179,9 +195,7 @@ def test_process_response_headers_ignores_preserve_flag_for_httpx_headers():
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_acompletion_fallbacks_bad_models(
|
||||
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
async def test_acompletion_fallbacks_bad_models(respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
route: Final = respx_mock.post("https://api.openai.com/v1/chat/completions").mock(
|
||||
side_effect=(
|
||||
|
|
@ -210,7 +224,7 @@ async def test_acompletion_fallbacks_bad_models(
|
|||
)
|
||||
)
|
||||
|
||||
with pytest.raises(Exception, match="All fallback attempts failed"):
|
||||
with pytest.raises(Exception, match=r"fallback unavailable\. All fallback attempts failed"):
|
||||
await python_acompletion(
|
||||
model="openai/first",
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
|
|
@ -222,9 +236,7 @@ async def test_acompletion_fallbacks_bad_models(
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_acompletion_fallbacks_basic(
|
||||
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
async def test_acompletion_fallbacks_basic(respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
route: Final = respx_mock.post("https://api.openai.com/v1/chat/completions").mock(
|
||||
side_effect=(
|
||||
|
|
@ -271,9 +283,7 @@ async def test_acompletion_fallbacks_basic(
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_acompletion_fallbacks_empty_list(
|
||||
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
async def test_acompletion_fallbacks_empty_list(respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
route: Final = respx_mock.post("https://api.openai.com/v1/chat/completions").mock(
|
||||
return_value=httpx.Response(
|
||||
|
|
@ -401,9 +411,7 @@ async def test_acompletion_fallbacks_with_dict_config(
|
|||
)
|
||||
|
||||
|
||||
def test_completion_fallbacks_sync(
|
||||
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
def test_completion_fallbacks_sync(respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
route: Final = respx_mock.post("https://api.openai.com/v1/chat/completions").mock(
|
||||
side_effect=(
|
||||
|
|
@ -449,6 +457,27 @@ def test_completion_fallbacks_sync(
|
|||
assert tuple(json.loads(call.request.content)["model"] for call in route.calls) == ("first", "second")
|
||||
|
||||
|
||||
@pytest.mark.timeout(30, method="thread")
|
||||
def test_completion_fallbacks_name_the_last_error_when_every_model_fails(
|
||||
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
fallback_unavailable: Final = {
|
||||
"error": {"message": "fallback unavailable", "type": "invalid_request_error", "param": None, "code": None}
|
||||
}
|
||||
_ = respx_mock.post("https://api.openai.com/v1/chat/completions").mock(
|
||||
side_effect=(_model_unavailable(), httpx.Response(404, json=fallback_unavailable))
|
||||
)
|
||||
|
||||
with pytest.raises(Exception, match=r"fallback unavailable\. All fallback attempts failed"):
|
||||
python_completion(
|
||||
model="openai/first",
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
api_key="test-key",
|
||||
fallbacks=["openai/second"],
|
||||
)
|
||||
|
||||
|
||||
CACHE_POINTS: Final = ({"location": "message", "role": "system"},)
|
||||
CACHED_MESSAGES: Final = (
|
||||
{"role": "system", "content": "You are a careful assistant."},
|
||||
|
|
@ -631,3 +660,763 @@ async def test_acompletion_fallback_sends_the_prompt_the_primary_model_prefix_te
|
|||
direct_body, fallback_body = (json.loads(call.request.content) for call in anthropic_route.calls)
|
||||
assert "What color is the sky?" in json.dumps(direct_body)
|
||||
assert fallback_body == direct_body
|
||||
|
||||
|
||||
def _model_unavailable() -> httpx.Response:
|
||||
return httpx.Response(
|
||||
404,
|
||||
json={
|
||||
"error": {
|
||||
"message": "model unavailable",
|
||||
"type": "invalid_request_error",
|
||||
"param": None,
|
||||
"code": "model_not_found",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _replies(contents: tuple[str | None, ...]) -> tuple[httpx.Response, ...]:
|
||||
return tuple(
|
||||
_model_unavailable()
|
||||
if content is None
|
||||
else httpx.Response(
|
||||
200,
|
||||
json={
|
||||
**OPENAI_ANSWER,
|
||||
"choices": [
|
||||
{"index": 0, "message": {"role": "assistant", "content": content}, "finish_reason": "stop"}
|
||||
],
|
||||
},
|
||||
)
|
||||
for content in contents
|
||||
)
|
||||
|
||||
|
||||
def _requested_models(route: respx.Route) -> tuple[str, ...]:
|
||||
return tuple(json.loads(call.request.content)["model"] for call in route.calls)
|
||||
|
||||
|
||||
GLOBAL_CHAIN_OUTCOMES: Final = (
|
||||
pytest.param(("first answer",), ("first",), id="primary-answers"),
|
||||
pytest.param((None, None, "third answer"), ("first", "second", "third"), id="falls-through-in-order"),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.timeout(30, method="thread")
|
||||
@pytest.mark.parametrize(("contents", "expected_models"), GLOBAL_CHAIN_OUTCOMES)
|
||||
@pytest.mark.asyncio
|
||||
async def test_acompletion_tries_the_global_model_fallbacks_once_each_in_order(
|
||||
respx_mock: respx.MockRouter,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
contents: tuple[str | None, ...],
|
||||
expected_models: tuple[str, ...],
|
||||
) -> None:
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
monkeypatch.setattr(litellm, "model_fallbacks", ["openai/second", "openai/third"])
|
||||
route: Final = respx_mock.post("https://api.openai.com/v1/chat/completions").mock(side_effect=_replies(contents))
|
||||
|
||||
response: Final = await python_acompletion(
|
||||
model="openai/first", messages=[{"role": "user", "content": "hello"}], api_key="test-key"
|
||||
)
|
||||
|
||||
assert response.choices[0].message.content == contents[-1]
|
||||
assert _requested_models(route) == expected_models
|
||||
|
||||
|
||||
@pytest.mark.timeout(30, method="thread")
|
||||
@pytest.mark.parametrize(("contents", "expected_models"), GLOBAL_CHAIN_OUTCOMES)
|
||||
def test_completion_tries_the_global_model_fallbacks_once_each_in_order(
|
||||
respx_mock: respx.MockRouter,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
contents: tuple[str | None, ...],
|
||||
expected_models: tuple[str, ...],
|
||||
) -> None:
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
monkeypatch.setattr(litellm, "model_fallbacks", ["openai/second", "openai/third"])
|
||||
route: Final = respx_mock.post("https://api.openai.com/v1/chat/completions").mock(side_effect=_replies(contents))
|
||||
|
||||
response: Final = python_completion(
|
||||
model="openai/first", messages=[{"role": "user", "content": "hello"}], api_key="test-key"
|
||||
)
|
||||
|
||||
assert response.choices[0].message.content == contents[-1]
|
||||
assert _requested_models(route) == expected_models
|
||||
|
||||
|
||||
@pytest.mark.timeout(30, method="thread")
|
||||
@pytest.mark.asyncio
|
||||
async def test_acompletion_per_call_fallbacks_win_over_the_global_model_fallbacks(
|
||||
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
monkeypatch.setattr(litellm, "model_fallbacks", ["openai/global-fallback"])
|
||||
route: Final = respx_mock.post("https://api.openai.com/v1/chat/completions").mock(
|
||||
side_effect=_replies((None, "per-call answer"))
|
||||
)
|
||||
|
||||
response: Final = await python_acompletion(
|
||||
model="openai/first",
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
api_key="test-key",
|
||||
fallbacks=["openai/second"],
|
||||
)
|
||||
|
||||
assert response.choices[0].message.content == "per-call answer"
|
||||
assert _requested_models(route) == ("first", "second")
|
||||
|
||||
|
||||
@pytest.mark.timeout(30, method="thread")
|
||||
@pytest.mark.asyncio
|
||||
async def test_acompletion_empty_per_call_fallbacks_opt_out_of_the_global_model_fallbacks(
|
||||
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
monkeypatch.setattr(litellm, "model_fallbacks", ["openai/second"])
|
||||
route: Final = respx_mock.post("https://api.openai.com/v1/chat/completions").mock(
|
||||
side_effect=_replies((None, "fallback answer"))
|
||||
)
|
||||
|
||||
with pytest.raises(litellm.NotFoundError, match="model unavailable"):
|
||||
await python_acompletion(
|
||||
model="openai/first", messages=[{"role": "user", "content": "hello"}], api_key="test-key", fallbacks=[]
|
||||
)
|
||||
|
||||
assert _requested_models(route) == ("first",)
|
||||
|
||||
|
||||
async def _flush_success_logs() -> None:
|
||||
await asyncio.sleep(0)
|
||||
GLOBAL_LOGGING_WORKER.start()
|
||||
await asyncio.wait_for(GLOBAL_LOGGING_WORKER.flush(), timeout=10.0)
|
||||
|
||||
|
||||
class _SuccessRecorder(CustomLogger):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.models: list[object] = []
|
||||
self.call_ids: list[object] = []
|
||||
|
||||
async def async_log_success_event(
|
||||
self, kwargs: dict[str, object], response_obj: object, start_time: object, end_time: object
|
||||
) -> None:
|
||||
self.models.append(kwargs.get("model"))
|
||||
self.call_ids.append(LITELLM_PARAMS.validate_python(kwargs.get("litellm_params")).get("litellm_call_id"))
|
||||
|
||||
|
||||
@pytest.mark.timeout(30, method="thread")
|
||||
@pytest.mark.asyncio
|
||||
async def test_acompletion_through_the_global_model_fallbacks_logs_one_success(
|
||||
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
recorder: Final = _SuccessRecorder()
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
monkeypatch.setattr(litellm, "model_fallbacks", ["openai/second"])
|
||||
monkeypatch.setattr(litellm, "callbacks", [recorder])
|
||||
_ = respx_mock.post("https://api.openai.com/v1/chat/completions").mock(
|
||||
side_effect=_replies((None, "fallback answer"))
|
||||
)
|
||||
|
||||
await python_acompletion(
|
||||
model="openai/first",
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
api_key="test-key",
|
||||
litellm_call_id="caller-call-id",
|
||||
)
|
||||
await _flush_success_logs()
|
||||
|
||||
assert recorder.models == ["second"]
|
||||
assert recorder.call_ids == ["caller-call-id"]
|
||||
|
||||
|
||||
@pytest.mark.timeout(30, method="thread")
|
||||
@pytest.mark.parametrize(
|
||||
"global_fallbacks",
|
||||
(pytest.param(["openai/second"], id="global-set"), pytest.param(None, id="global-unset")),
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_acompletion_with_empty_per_call_fallbacks_logs_one_success(
|
||||
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch, global_fallbacks: list[str] | None
|
||||
) -> None:
|
||||
recorder: Final = _SuccessRecorder()
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
monkeypatch.setattr(litellm, "model_fallbacks", global_fallbacks)
|
||||
monkeypatch.setattr(litellm, "callbacks", [recorder])
|
||||
_ = respx_mock.post("https://api.openai.com/v1/chat/completions").mock(side_effect=_replies(("first answer",)))
|
||||
|
||||
await python_acompletion(
|
||||
model="openai/first", messages=[{"role": "user", "content": "hello"}], api_key="test-key", fallbacks=[]
|
||||
)
|
||||
await _flush_success_logs()
|
||||
|
||||
assert recorder.models == ["first"]
|
||||
|
||||
|
||||
class _EverySuccessRecorder(CustomLogger):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.events: list[tuple[str, object]] = []
|
||||
|
||||
def log_success_event(
|
||||
self, kwargs: dict[str, object], response_obj: object, start_time: object, end_time: object
|
||||
) -> None:
|
||||
self.events.append(("sync", kwargs.get("model")))
|
||||
|
||||
async def async_log_success_event(
|
||||
self, kwargs: dict[str, object], response_obj: object, start_time: object, end_time: object
|
||||
) -> None:
|
||||
self.events.append(("async", kwargs.get("model")))
|
||||
|
||||
|
||||
@pytest.mark.timeout(30, method="thread")
|
||||
@pytest.mark.asyncio
|
||||
async def test_atext_completion_through_the_global_model_fallbacks_logs_one_success(
|
||||
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
recorder: Final = _EverySuccessRecorder()
|
||||
logging_executor: Final = ThreadPoolExecutor(max_workers=2)
|
||||
monkeypatch.setattr(litellm.utils, "executor", logging_executor)
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
monkeypatch.setattr(litellm, "model_fallbacks", ["openai/second"])
|
||||
monkeypatch.setattr(litellm, "callbacks", [recorder])
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "test-key")
|
||||
chat_route: Final = respx_mock.post("https://api.openai.com/v1/chat/completions").mock(
|
||||
side_effect=_replies(("fallback answer",))
|
||||
)
|
||||
text_route: Final = respx_mock.post("https://api.openai.com/v1/completions").mock(
|
||||
side_effect=(_model_unavailable(),)
|
||||
)
|
||||
|
||||
response: Final = await litellm.atext_completion(model="openai/first", prompt="hello")
|
||||
await _flush_success_logs()
|
||||
logging_executor.shutdown(wait=True)
|
||||
|
||||
assert response.choices[0].text == "fallback answer"
|
||||
assert _requested_models(text_route) == ("first",)
|
||||
assert _requested_models(chat_route) == ("second",)
|
||||
assert len(recorder.events) == 1
|
||||
|
||||
|
||||
class _DeploymentAttemptRecorder(CustomLogger):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.attempts: list[tuple[str, object]] = []
|
||||
|
||||
async def async_post_call_failure_deployment_hook(
|
||||
self,
|
||||
request_data: Mapping[str, object],
|
||||
exception: Exception,
|
||||
call_type: CallTypes | None,
|
||||
fallback_depth: int | None = None,
|
||||
) -> None:
|
||||
self.attempts.append(("failure", request_data.get("model")))
|
||||
|
||||
async def async_post_call_success_deployment_hook(
|
||||
self, request_data: dict[str, object], response: LLMResponseTypes, call_type: CallTypes | None
|
||||
) -> LLMResponseTypes | None:
|
||||
self.attempts.append(("success", request_data.get("model")))
|
||||
return response
|
||||
|
||||
|
||||
@pytest.mark.timeout(30, method="thread")
|
||||
def test_completion_with_fallbacks_reports_every_attempt_to_the_deployment_hooks_in_order(
|
||||
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
recorder: Final = _DeploymentAttemptRecorder()
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
monkeypatch.setattr(litellm, "callbacks", [recorder])
|
||||
_ = respx_mock.post("https://api.openai.com/v1/chat/completions").mock(
|
||||
side_effect=_replies((None, None, "third answer"))
|
||||
)
|
||||
|
||||
response: Final = python_completion(
|
||||
model="openai/first",
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
api_key="test-key",
|
||||
fallbacks=["openai/second", "openai/third"],
|
||||
)
|
||||
|
||||
assert response.choices[0].message.content == "third answer"
|
||||
assert recorder.attempts == [("failure", "openai/first"), ("failure", "openai/second"), ("success", "openai/third")]
|
||||
|
||||
|
||||
CREDENTIAL_ROUTES: Final = (
|
||||
pytest.param(
|
||||
"openai/second",
|
||||
"https://gateway.example/v1/chat/completions",
|
||||
("authorization", "Bearer caller-key"),
|
||||
id="same-provider-keeps-the-callers-credentials",
|
||||
),
|
||||
pytest.param(
|
||||
"anthropic/claude-haiku-5-5",
|
||||
"https://api.anthropic.com/v1/messages",
|
||||
("x-api-key", "anthropic-env-key"),
|
||||
id="other-provider-uses-its-own-credentials",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.timeout(30, method="thread")
|
||||
@pytest.mark.parametrize(("fallback", "fallback_url", "credential"), CREDENTIAL_ROUTES)
|
||||
@pytest.mark.asyncio
|
||||
async def test_acompletion_fallback_sends_credentials_only_to_the_provider_they_belong_to(
|
||||
respx_mock: respx.MockRouter,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
fallback: str,
|
||||
fallback_url: str,
|
||||
credential: tuple[str, str],
|
||||
) -> None:
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
monkeypatch.setattr(litellm, "model_fallbacks", [fallback])
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "anthropic-env-key")
|
||||
_ = respx_mock.post("https://gateway.example/v1/chat/completions").mock(
|
||||
side_effect=(_model_unavailable(), httpx.Response(200, json=OPENAI_ANSWER))
|
||||
)
|
||||
_ = respx_mock.post("https://api.anthropic.com/v1/messages").mock(
|
||||
return_value=httpx.Response(200, json=ANTHROPIC_ANSWER)
|
||||
)
|
||||
|
||||
response: Final = await python_acompletion(
|
||||
model="openai/first",
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
api_key="caller-key",
|
||||
api_base="https://gateway.example/v1",
|
||||
)
|
||||
|
||||
header, value = credential
|
||||
assert response.choices[0].message.content == "ok"
|
||||
assert str(respx_mock.calls.last.request.url) == fallback_url
|
||||
assert respx_mock.calls.last.request.headers[header] == value
|
||||
|
||||
|
||||
GATEWAY_CREDENTIAL_CARRIERS: Final = (
|
||||
pytest.param("openai/first", {"litellm_credential_name": "gateway"}, id="named-credential"),
|
||||
pytest.param(
|
||||
"openai/first",
|
||||
{
|
||||
"api_key": "gateway-key",
|
||||
"api_base": "https://gateway.example/v1",
|
||||
"headers": {"authorization": "Bearer gateway-key"},
|
||||
},
|
||||
id="request-headers",
|
||||
),
|
||||
pytest.param(
|
||||
"first-custom-model",
|
||||
{"custom_llm_provider": "openai", "api_key": "gateway-key", "api_base": "https://gateway.example/v1"},
|
||||
id="explicit-provider",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.timeout(30, method="thread")
|
||||
@pytest.mark.parametrize(("model", "gateway_credentials"), GATEWAY_CREDENTIAL_CARRIERS)
|
||||
@pytest.mark.asyncio
|
||||
async def test_acompletion_fallback_to_another_provider_never_reuses_the_first_providers_credentials(
|
||||
respx_mock: respx.MockRouter,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
model: str,
|
||||
gateway_credentials: dict[str, object],
|
||||
) -> None:
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
monkeypatch.setattr(
|
||||
litellm,
|
||||
"credential_list",
|
||||
[
|
||||
CredentialItem(
|
||||
credential_name="gateway",
|
||||
credential_values={"api_key": "gateway-key", "api_base": "https://gateway.example/v1"},
|
||||
credential_info={},
|
||||
)
|
||||
],
|
||||
)
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "anthropic-env-key")
|
||||
_ = respx_mock.post("https://gateway.example/v1/chat/completions").mock(return_value=_model_unavailable())
|
||||
_ = respx_mock.post("https://api.anthropic.com/v1/messages").mock(
|
||||
return_value=httpx.Response(200, json=ANTHROPIC_ANSWER)
|
||||
)
|
||||
|
||||
response: Final = await python_acompletion(
|
||||
model=model,
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
fallbacks=["anthropic/claude-haiku-5-5"],
|
||||
**gateway_credentials,
|
||||
)
|
||||
|
||||
fallback_request: Final = respx_mock.calls.last.request
|
||||
assert response.choices[0].message.content == "ok"
|
||||
assert str(fallback_request.url) == "https://api.anthropic.com/v1/messages"
|
||||
assert fallback_request.headers["x-api-key"] == "anthropic-env-key"
|
||||
assert not any("gateway-key" in value for value in fallback_request.headers.values())
|
||||
|
||||
|
||||
@pytest.mark.timeout(30, method="thread")
|
||||
@pytest.mark.asyncio
|
||||
async def test_acompletion_fallback_to_an_unprefixed_model_keeps_the_requests_provider_and_credentials(
|
||||
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
route: Final = respx_mock.post("https://models.example/v1/chat/completions").mock(
|
||||
side_effect=_replies((None, "fallback answer"))
|
||||
)
|
||||
|
||||
response: Final = await python_acompletion(
|
||||
model="first-custom-model",
|
||||
custom_llm_provider="openai",
|
||||
api_base="https://models.example/v1",
|
||||
api_key="models-key",
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
fallbacks=["second-custom-model"],
|
||||
)
|
||||
|
||||
assert response.choices[0].message.content == "fallback answer"
|
||||
assert _requested_models(route) == ("first-custom-model", "second-custom-model")
|
||||
assert route.calls.last.request.headers["authorization"] == "Bearer models-key"
|
||||
|
||||
|
||||
@pytest.mark.timeout(30, method="thread")
|
||||
def test_completion_fallback_from_a_model_without_a_provider_never_reuses_its_credentials(
|
||||
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "anthropic-env-key")
|
||||
route: Final = respx_mock.post("https://api.anthropic.com/v1/messages").mock(
|
||||
return_value=httpx.Response(200, json=ANTHROPIC_ANSWER)
|
||||
)
|
||||
|
||||
response: Final = python_completion(
|
||||
model="first-without-a-provider",
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
api_key="first-key",
|
||||
fallbacks=["anthropic/claude-haiku-5-5"],
|
||||
)
|
||||
|
||||
assert response.choices[0].message.content == "ok"
|
||||
assert route.calls.last.request.headers["x-api-key"] == "anthropic-env-key"
|
||||
|
||||
|
||||
async def _router_completion(router: litellm.Router, *, is_async: bool) -> object:
|
||||
messages: Final = [{"role": "user", "content": "hello"}]
|
||||
if is_async:
|
||||
return await router.acompletion(model="first", messages=messages)
|
||||
return router.completion(model="first", messages=messages)
|
||||
|
||||
|
||||
@pytest.mark.timeout(30, method="thread")
|
||||
@pytest.mark.parametrize("is_async", [True, False])
|
||||
@pytest.mark.asyncio
|
||||
async def test_router_leaves_fallbacks_to_the_router_when_global_model_fallbacks_are_set(
|
||||
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch, is_async: bool
|
||||
) -> None:
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
monkeypatch.setattr(litellm, "model_fallbacks", ["openai/second"])
|
||||
route: Final = respx_mock.post("https://api.openai.com/v1/chat/completions").mock(
|
||||
side_effect=_replies((None, "global fallback answer"))
|
||||
)
|
||||
router: Final = litellm.Router(
|
||||
model_list=[{"model_name": "first", "litellm_params": {"model": "openai/first", "api_key": "test-key"}}],
|
||||
num_retries=0,
|
||||
)
|
||||
|
||||
with pytest.raises(litellm.NotFoundError, match="model unavailable"):
|
||||
await _router_completion(router, is_async=is_async)
|
||||
|
||||
assert _requested_models(route) == ("first",)
|
||||
|
||||
|
||||
STREAMED_ANSWER: Final = (
|
||||
"".join(
|
||||
f"data: {json.dumps(chunk)}\n\n"
|
||||
for chunk in (
|
||||
{
|
||||
"id": "chatcmpl-1",
|
||||
"object": "chat.completion.chunk",
|
||||
"created": 1,
|
||||
"model": "first",
|
||||
"choices": [{"index": 0, "delta": {"role": "assistant", "content": "streamed answer"}}],
|
||||
},
|
||||
{
|
||||
"id": "chatcmpl-1",
|
||||
"object": "chat.completion.chunk",
|
||||
"created": 1,
|
||||
"model": "first",
|
||||
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
|
||||
},
|
||||
)
|
||||
)
|
||||
+ "data: [DONE]\n\n"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.timeout(30, method="thread")
|
||||
def test_completion_streams_through_the_global_model_fallbacks(
|
||||
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
monkeypatch.setattr(litellm, "model_fallbacks", ["openai/second"])
|
||||
_ = respx_mock.post("https://api.openai.com/v1/chat/completions").mock(
|
||||
return_value=httpx.Response(200, text=STREAMED_ANSWER, headers={"content-type": "text/event-stream"})
|
||||
)
|
||||
|
||||
stream: Final = python_completion(
|
||||
model="openai/first", messages=[{"role": "user", "content": "hello"}], api_key="test-key", stream=True
|
||||
)
|
||||
|
||||
assert "".join(chunk.choices[0].delta.content or "" for chunk in stream) == "streamed answer"
|
||||
|
||||
|
||||
class _ConcurrentSyncSuccessRecorder(CustomLogger):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.models: list[object] = []
|
||||
self._pair_concurrent_logs: Final = threading.Barrier(2, timeout=0.5)
|
||||
|
||||
def logging_hook(
|
||||
self, kwargs: dict[str, object], result: object, call_type: str
|
||||
) -> tuple[dict[str, object], object]:
|
||||
with contextlib.suppress(threading.BrokenBarrierError):
|
||||
_ = self._pair_concurrent_logs.wait()
|
||||
return kwargs, result
|
||||
|
||||
def log_success_event(
|
||||
self, kwargs: dict[str, object], response_obj: object, start_time: object, end_time: object
|
||||
) -> None:
|
||||
self.models.append(kwargs.get("model"))
|
||||
|
||||
|
||||
@pytest.mark.timeout(30, method="thread")
|
||||
def test_completion_through_the_global_model_fallbacks_logs_one_success(
|
||||
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
recorder: Final = _ConcurrentSyncSuccessRecorder()
|
||||
logging_executor: Final = ThreadPoolExecutor(max_workers=2)
|
||||
monkeypatch.setattr(litellm.utils, "executor", logging_executor)
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
monkeypatch.setattr(litellm, "model_fallbacks", ["openai/second"])
|
||||
monkeypatch.setattr(litellm, "callbacks", [recorder])
|
||||
_ = respx_mock.post("https://api.openai.com/v1/chat/completions").mock(
|
||||
side_effect=_replies((None, "fallback answer"))
|
||||
)
|
||||
|
||||
python_completion(model="openai/first", messages=[{"role": "user", "content": "hello"}], api_key="test-key")
|
||||
logging_executor.shutdown(wait=True)
|
||||
|
||||
assert recorder.models == ["second"]
|
||||
|
||||
|
||||
class _RedactingGuardrail(CustomGuardrail):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
guardrail_name="redact", event_hook=[GuardrailEventHooks.pre_call, GuardrailEventHooks.post_call]
|
||||
)
|
||||
|
||||
async def async_pre_call_hook(
|
||||
self, user_api_key_dict: UserAPIKeyAuth, cache: DualCache, data: dict, call_type: CallTypesLiteral
|
||||
) -> dict[str, object]:
|
||||
return {**data, "messages": [{"role": "user", "content": "[REDACTED]"}]}
|
||||
|
||||
async def async_post_call_success_hook(
|
||||
self, data: dict, user_api_key_dict: UserAPIKeyAuth, response: LLMResponseTypes
|
||||
) -> ModelResponse:
|
||||
return ModelResponse(model="checked", choices=[Choices(message=Message(content="[CHECKED]"))])
|
||||
|
||||
|
||||
@pytest.mark.timeout(30, method="thread")
|
||||
def test_completion_with_fallbacks_runs_the_requested_guardrail_before_every_attempt(
|
||||
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
monkeypatch.setattr(litellm, "callbacks", [_RedactingGuardrail()])
|
||||
route: Final = respx_mock.post("https://api.openai.com/v1/chat/completions").mock(
|
||||
side_effect=_replies((None, "fallback answer"))
|
||||
)
|
||||
|
||||
response: Final = python_completion(
|
||||
model="openai/first",
|
||||
messages=[{"role": "user", "content": "my card is 4111 1111 1111 1111"}],
|
||||
api_key="test-key",
|
||||
fallbacks=["openai/second"],
|
||||
guardrails=["redact"],
|
||||
)
|
||||
|
||||
assert response.choices[0].message.content == "[CHECKED]"
|
||||
assert [json.loads(call.request.content)["messages"] for call in route.calls] == [
|
||||
[{"role": "user", "content": "[REDACTED]"}]
|
||||
] * 2
|
||||
|
||||
|
||||
class _RejectingGuardrail(CustomGuardrail):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(guardrail_name="reject", event_hook=GuardrailEventHooks.post_call)
|
||||
|
||||
async def async_post_call_success_hook(
|
||||
self, data: dict, user_api_key_dict: UserAPIKeyAuth, response: LLMResponseTypes
|
||||
) -> NoReturn:
|
||||
raise ValueError("answer rejected")
|
||||
|
||||
|
||||
def _completion_checked_by_the_rejecting_guardrail() -> object:
|
||||
return python_completion(
|
||||
model="openai/first",
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
api_key="test-key",
|
||||
fallbacks=["openai/second"],
|
||||
guardrails=["reject"],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.timeout(30, method="thread")
|
||||
def test_completion_with_fallbacks_never_caches_an_answer_its_guardrail_rejected(
|
||||
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
monkeypatch.setattr(litellm, "cache", Cache(type="local"))
|
||||
monkeypatch.setattr(litellm, "callbacks", [_RejectingGuardrail()])
|
||||
route: Final = respx_mock.post("https://api.openai.com/v1/chat/completions").mock(
|
||||
side_effect=_replies(("rejected answer",) * 4)
|
||||
)
|
||||
|
||||
with pytest.raises(Exception, match="answer rejected"):
|
||||
_ = _completion_checked_by_the_rejecting_guardrail()
|
||||
with pytest.raises(Exception, match="answer rejected"):
|
||||
_ = _completion_checked_by_the_rejecting_guardrail()
|
||||
|
||||
assert _requested_models(route) == ("first", "second", "first", "second")
|
||||
|
||||
|
||||
class _SyncAnswerRecorder(CustomLogger):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.answers: list[object] = []
|
||||
|
||||
def log_success_event(
|
||||
self, kwargs: dict[str, object], response_obj: object, start_time: object, end_time: object
|
||||
) -> None:
|
||||
self.answers.append(
|
||||
response_obj.choices[0].message.content if isinstance(response_obj, ModelResponse) else None
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("guardrail_class", "logged_answers"),
|
||||
[(_RedactingGuardrail, ["[CHECKED]"]), (_RejectingGuardrail, [])],
|
||||
ids=["rewritten", "rejected"],
|
||||
)
|
||||
@pytest.mark.timeout(30, method="thread")
|
||||
def test_completion_with_fallbacks_logs_only_the_answer_its_guardrail_returned(
|
||||
respx_mock: respx.MockRouter,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
guardrail_class: type[CustomGuardrail],
|
||||
logged_answers: list[str],
|
||||
) -> None:
|
||||
guardrail: Final = guardrail_class()
|
||||
recorder: Final = _SyncAnswerRecorder()
|
||||
logging_executor: Final = ThreadPoolExecutor(max_workers=2)
|
||||
monkeypatch.setattr(litellm.utils, "executor", logging_executor)
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
monkeypatch.setattr(litellm, "callbacks", [guardrail, recorder])
|
||||
_ = respx_mock.post("https://api.openai.com/v1/chat/completions").mock(side_effect=_replies((None, "raw answer")))
|
||||
|
||||
with contextlib.suppress(Exception):
|
||||
_ = python_completion(
|
||||
model="openai/first",
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
api_key="test-key",
|
||||
fallbacks=["openai/second"],
|
||||
guardrails=[guardrail.guardrail_name],
|
||||
)
|
||||
logging_executor.shutdown(wait=True)
|
||||
|
||||
assert recorder.answers == logged_answers
|
||||
|
||||
|
||||
@pytest.mark.timeout(30, method="thread")
|
||||
def test_completion_streams_through_fallbacks_with_the_requested_guardrail(
|
||||
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
monkeypatch.setattr(litellm, "callbacks", [_RedactingGuardrail()])
|
||||
route: Final = respx_mock.post("https://api.openai.com/v1/chat/completions").mock(
|
||||
return_value=httpx.Response(200, text=STREAMED_ANSWER, headers={"content-type": "text/event-stream"})
|
||||
)
|
||||
|
||||
stream: Final = python_completion(
|
||||
model="openai/first",
|
||||
messages=[{"role": "user", "content": "my card is 4111 1111 1111 1111"}],
|
||||
api_key="test-key",
|
||||
stream=True,
|
||||
fallbacks=["openai/second"],
|
||||
guardrails=["redact"],
|
||||
)
|
||||
|
||||
assert "".join(chunk.choices[0].delta.content or "" for chunk in stream) == "streamed answer"
|
||||
assert json.loads(route.calls.last.request.content)["messages"] == [{"role": "user", "content": "[REDACTED]"}]
|
||||
|
||||
|
||||
@pytest.mark.timeout(30, method="thread")
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"primary_price",
|
||||
({"input_cost_per_token": 1.0, "output_cost_per_token": 1.0}, {"base_model": "gpt-5.4"}),
|
||||
)
|
||||
async def test_acompletion_fallback_to_another_model_is_priced_like_a_direct_call_to_it(
|
||||
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch, primary_price: dict[str, object]
|
||||
) -> None:
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
_ = respx_mock.post("https://api.openai.com/v1/chat/completions").mock(
|
||||
side_effect=(
|
||||
_model_unavailable(),
|
||||
httpx.Response(200, json=OPENAI_ANSWER),
|
||||
httpx.Response(200, json=OPENAI_ANSWER),
|
||||
)
|
||||
)
|
||||
through_fallback: Final = await python_acompletion(
|
||||
model="openai/first",
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
api_key="test-key",
|
||||
fallbacks=["openai/gpt-5.4-nano"],
|
||||
**primary_price,
|
||||
)
|
||||
direct: Final = await python_acompletion(
|
||||
model="openai/gpt-5.4-nano", messages=[{"role": "user", "content": "hello"}], api_key="test-key"
|
||||
)
|
||||
|
||||
assert through_fallback._hidden_params["response_cost"] == direct._hidden_params["response_cost"]
|
||||
|
||||
|
||||
@pytest.mark.timeout(30, method="thread")
|
||||
def test_completion_never_signs_in_to_a_chatgpt_fallback_the_call_does_not_use(
|
||||
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
|
||||
) -> None:
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
monkeypatch.setattr(litellm, "model_fallbacks", ["chatgpt/gpt-5.4"])
|
||||
monkeypatch.setenv("CHATGPT_TOKEN_DIR", str(tmp_path))
|
||||
device_login: Final = respx_mock.post(CHATGPT_DEVICE_CODE_URL).mock(return_value=httpx.Response(503))
|
||||
_ = respx_mock.post("https://api.openai.com/v1/chat/completions").mock(side_effect=_replies(("first answer",)))
|
||||
|
||||
response: Final = python_completion(
|
||||
model="openai/first", messages=[{"role": "user", "content": "hello"}], api_key="test-key"
|
||||
)
|
||||
|
||||
assert response.choices[0].message.content == "first answer"
|
||||
assert not device_login.called
|
||||
|
||||
|
||||
@pytest.mark.timeout(30, method="thread")
|
||||
@pytest.mark.asyncio
|
||||
async def test_acompletion_falls_back_from_a_chatgpt_model_that_is_not_signed_in(
|
||||
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path
|
||||
) -> None:
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
monkeypatch.setattr(litellm, "model_fallbacks", ["openai/second"])
|
||||
monkeypatch.setenv("CHATGPT_TOKEN_DIR", str(tmp_path))
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "openai-env-key")
|
||||
route: Final = respx_mock.post("https://api.openai.com/v1/chat/completions").mock(
|
||||
side_effect=_replies(("fallback answer",))
|
||||
)
|
||||
|
||||
response: Final = await python_acompletion(
|
||||
model="gpt-5.4", custom_llm_provider="chatgpt", messages=[{"role": "user", "content": "hello"}]
|
||||
)
|
||||
|
||||
assert response.choices[0].message.content == "fallback answer"
|
||||
assert _requested_models(route) == ("second",)
|
||||
|
|
|
|||
|
|
@ -1987,3 +1987,77 @@ async def test_wildcard_health_check_with_pinned_provider_calls_a_model_without_
|
|||
assert "error" not in result, result
|
||||
assert json.loads(together_route.calls.last.request.content)["model"] == "fake-together-chat"
|
||||
assert not other_hosts.called
|
||||
|
||||
|
||||
HEALTH_CHECK_ROUTES: Final = (
|
||||
pytest.param(
|
||||
"chat",
|
||||
{"model": "openai/missing-model", "api_base": "https://deployment.example/v1"},
|
||||
"https://deployment.example/v1/chat/completions",
|
||||
id="chat",
|
||||
),
|
||||
pytest.param(
|
||||
"responses",
|
||||
{"model": "anthropic/missing-model", "api_base": "https://deployment.example"},
|
||||
"https://deployment.example/v1/messages",
|
||||
id="responses-bridged-to-chat",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _mock_failing_deployment_with_a_healthy_openai_fallback(
|
||||
respx_mock: respx.MockRouter, deployment_url: str
|
||||
) -> respx.Route:
|
||||
_ = respx_mock.post("https://api.openai.com/v1/chat/completions").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"id": "chatcmpl-1",
|
||||
"object": "chat.completion",
|
||||
"created": 1,
|
||||
"model": "healthy-fallback",
|
||||
"choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}],
|
||||
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
|
||||
},
|
||||
)
|
||||
)
|
||||
return respx_mock.post(deployment_url).mock(
|
||||
return_value=httpx.Response(404, json={"error": {"message": "model unavailable", "type": "not_found"}})
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.timeout(30, method="thread")
|
||||
@pytest.mark.parametrize(("mode", "deployment_params", "deployment_url"), HEALTH_CHECK_ROUTES)
|
||||
@pytest.mark.asyncio
|
||||
async def test_ahealth_check_reports_a_failing_deployment_whatever_the_global_model_fallbacks(
|
||||
respx_mock: respx.MockRouter,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
mode: Literal["chat", "responses"],
|
||||
deployment_params: dict[str, str],
|
||||
deployment_url: str,
|
||||
) -> None:
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
monkeypatch.setattr(litellm, "model_fallbacks", ["openai/healthy-fallback"])
|
||||
deployment: Final = _mock_failing_deployment_with_a_healthy_openai_fallback(respx_mock, deployment_url)
|
||||
|
||||
result: Final = await ahealth_check({**deployment_params, "api_key": "sk-test"}, mode=mode)
|
||||
|
||||
assert "error" in result
|
||||
assert len(deployment.calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.timeout(30, method="thread")
|
||||
@pytest.mark.asyncio
|
||||
async def test_calls_after_a_health_check_still_use_the_global_model_fallbacks(
|
||||
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
monkeypatch.setattr(litellm, "model_fallbacks", ["openai/healthy-fallback"])
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "openai-env-key")
|
||||
deployment: Final = {"model": "anthropic/missing-model", "api_base": "https://deployment.example", "api_key": "sk-test"}
|
||||
_ = _mock_failing_deployment_with_a_healthy_openai_fallback(respx_mock, "https://deployment.example/v1/messages")
|
||||
|
||||
_ = await ahealth_check(deployment, mode="chat")
|
||||
response: Final = await litellm.acompletion(**deployment, messages=[{"role": "user", "content": "hi"}])
|
||||
|
||||
assert response.choices[0].message.content == "ok"
|
||||
|
|
|
|||
|
|
@ -18,6 +18,9 @@ import io
|
|||
import asyncio
|
||||
import logging
|
||||
|
||||
import yaml
|
||||
|
||||
import litellm
|
||||
from litellm.proxy.proxy_server import ProxyConfig
|
||||
|
||||
INVALID_FILES = ["config_with_missing_include.yaml"]
|
||||
|
|
@ -355,3 +358,23 @@ async def test_load_config_rejects_unknown_mcp_disabled_capability(tmp_path: Pat
|
|||
|
||||
with pytest.raises(ValidationError):
|
||||
await ProxyConfig().load_config(router=None, config_file_path=str(config_path))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_proxy_config_model_fallbacks_never_becomes_the_sdk_global_fallback_list(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(litellm, "model_fallbacks", None)
|
||||
config_file: Final = tmp_path / "config.yaml"
|
||||
_ = config_file.write_text(
|
||||
yaml.safe_dump(
|
||||
{
|
||||
"model_list": [{"model_name": "gpt-5.4-nano", "litellm_params": {"model": "openai/gpt-5.4-nano"}}],
|
||||
"litellm_settings": {"model_fallbacks": ["anthropic/claude-haiku-5-5"]},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
_ = await ProxyConfig().load_config(router=None, config_file_path=str(config_file))
|
||||
|
||||
assert litellm.model_fallbacks is None
|
||||
|
|
|
|||
|
|
@ -398,7 +398,7 @@ async def test_route_request_no_model_required_with_router_settings_and_no_route
|
|||
}
|
||||
|
||||
with patch.object(litellm, "acompletion", return_value="fake_response") as mock_completion:
|
||||
await route_request(data, None, "gpt-3.5-turbo", "acompletion")
|
||||
assert await (await route_request(data, None, "gpt-3.5-turbo", "acompletion")) == "fake_response"
|
||||
|
||||
mock_completion.assert_called_once_with(**data)
|
||||
|
||||
|
|
@ -1480,6 +1480,40 @@ async def test_route_request_keeps_key_fallbacks_out_of_pass_through_provider_bo
|
|||
assert provider_bodies and all("gpt-4" not in body for body in provider_bodies), provider_bodies
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_route_request_pass_through_never_falls_back_to_the_global_model_fallbacks(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
monkeypatch.setattr(litellm, "model_fallbacks", ["openai/gpt-not-granted"])
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "sk-pass-through")
|
||||
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
|
||||
monkeypatch.delenv("OPENAI_API_BASE", raising=False)
|
||||
router: Final = litellm.Router(
|
||||
model_list=[
|
||||
{
|
||||
"model_name": "gpt-4",
|
||||
"litellm_params": {"model": "openai/gpt-4", "api_key": "sk-proxy", "api_base": _PROXY_DEPLOYMENT_BASE},
|
||||
}
|
||||
],
|
||||
router_general_settings=RouterGeneralSettings(pass_through_all_models=True),
|
||||
)
|
||||
data: Final = {"model": "openai/gpt-pass-through", "messages": list(_HELLO_MESSAGES)}
|
||||
|
||||
with respx.mock(assert_all_called=False) as respx_mock:
|
||||
requested: Final = respx_mock.post(
|
||||
"https://api.openai.com/v1/chat/completions", json__model="gpt-pass-through"
|
||||
).mock(return_value=httpx.Response(404, json={"error": {"message": "no such model", "code": "model_not_found"}}))
|
||||
not_granted: Final = respx_mock.post(
|
||||
"https://api.openai.com/v1/chat/completions", json__model="gpt-not-granted"
|
||||
).mock(return_value=_chat_completion_response("gpt-not-granted"))
|
||||
with pytest.raises(litellm.NotFoundError):
|
||||
await (await route_request(data, router, None, "acompletion"))
|
||||
|
||||
assert requested.called
|
||||
assert not not_granted.called
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"route_type, param, route",
|
||||
[
|
||||
|
|
|
|||
|
|
@ -11107,6 +11107,36 @@ def test_check_valid_key_returns_false_for_http_unauthorized() -> None:
|
|||
assert upstream.calls.last.request.headers["Authorization"] == "Bearer invalid-test-key"
|
||||
|
||||
|
||||
def test_check_valid_key_never_counts_a_global_fallback_answer_as_a_valid_key(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(litellm, "model_fallbacks", ["anthropic/claude-haiku-5-5"])
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "anthropic-env-key")
|
||||
upstream: Final[respx.MockRouter]
|
||||
|
||||
with respx.mock(assert_all_called=False) as upstream:
|
||||
_ = upstream.post("https://api.openai.com/v1/chat/completions").respond(
|
||||
401, json={"error": {"message": "Invalid API key", "type": "invalid_request_error", "code": "invalid_api_key"}}
|
||||
)
|
||||
fallback_route: Final = upstream.post("https://api.anthropic.com/v1/messages").respond(
|
||||
200,
|
||||
json={
|
||||
"id": "msg_test",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": "claude-haiku-5-5",
|
||||
"content": [{"type": "text", "text": "ok"}],
|
||||
"stop_reason": "end_turn",
|
||||
"usage": {"input_tokens": 1, "output_tokens": 1},
|
||||
},
|
||||
)
|
||||
|
||||
valid_key: Final = check_valid_key(model="gpt-5-mini", api_key="invalid-test-key")
|
||||
|
||||
assert valid_key is False
|
||||
assert not fallback_route.called
|
||||
|
||||
|
||||
def test_check_valid_key_returns_true_for_successful_completion() -> None:
|
||||
response_body: Final = {
|
||||
"id": "chatcmpl-test",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue