mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
perf: eliminate per-request callback scanning on proxy hot path (#27858)
- Introduce `_CallbackCapabilities` dataclass and `ProxyLogging._callback_capabilities()` static method that inspects `litellm.callbacks` once and caches capability flags keyed on (list length, member ids); invalidates automatically when the callback list mutates without per-request iteration overhead - Replace O(n) `litellm.callbacks` walks in `async_pre_call_hook`, `during_call_hook`, `async_post_call_streaming_iterator_hook`, `async_post_call_streaming_hook`, and `post_call_response_headers_hook` with fast-path exits when no relevant callbacks are registered - Add `needs_iterator_wrap()` and `needs_per_chunk_streaming_hook()` instance methods to decouple iterator-level wrapping from per-chunk hook execution; avoids `get_response_string` materialization per chunk when no guardrail or chunk-hook callback is active - Introduce `_fast_serialize_simple_model_response_stream()` using `orjson` for common single-choice text streaming chunks, bypassing the full Pydantic serializer; falls back to `model_dump_json` for tool calls, logprobs, usage, and provider-specific fields - Add early-return in `_restamp_streaming_chunk_model` when downstream model already matches the requested model, avoiding unnecessary string comparisons on every chunk - Fix stale zero-cost cache bug in `_is_model_cost_zero`: move the per-router `_zero_cost_cache` dict onto the `Router` instance and clear it in `_invalidate_model_group_info_cache` so in-place pricing updates via `upsert_deployment` immediately resume budget enforcement - Add `scripts/benchmark_chat_completions_perf.py`: standalone async benchmarking tool with a mock OpenAI provider, LiteLLM proxy process management, non-streaming RPS, streaming TTFT, and full-stream latency measurements with repeat/median run support - Add comprehensive unit tests covering capability detection, cache invalidation, fast-path correctness, zero-cost cache regression, and the no-callback streaming fast path Co-authored-by: Yassin Kortam <yassinkortam@g.ucla.edu>
This commit is contained in:
parent
65d6ad82ef
commit
a6494e6fe3
9 changed files with 1676 additions and 84 deletions
|
|
@ -120,6 +120,22 @@ def _log_budget_lookup_failure(entity: str, error: Exception) -> None:
|
|||
)
|
||||
|
||||
|
||||
def _get_router_zero_cost_cache(llm_router: Router) -> Optional[Dict[str, bool]]:
|
||||
"""
|
||||
Return the router's per-instance zero-cost cache, or ``None`` for objects
|
||||
that don't expose one (e.g. ``MagicMock`` stand-ins in unit tests).
|
||||
|
||||
The cache lives on the ``Router`` instance so it:
|
||||
* is invalidated by ``Router._invalidate_model_group_info_cache`` on
|
||||
any model add/remove/upsert (including in-place pricing changes via
|
||||
``/model/update``, which go through ``upsert_deployment``);
|
||||
* dies with the router itself — no risk of CPython reusing the
|
||||
previous router's ``id()`` and serving its cached entries.
|
||||
"""
|
||||
cache = getattr(llm_router, "_zero_cost_cache", None)
|
||||
return cache if isinstance(cache, dict) else None
|
||||
|
||||
|
||||
def _is_model_cost_zero(
|
||||
model: Optional[Union[str, List[str]]], llm_router: Optional[Router]
|
||||
) -> bool:
|
||||
|
|
@ -141,7 +157,15 @@ def _is_model_cost_zero(
|
|||
# Handle list of models
|
||||
model_list = [model] if isinstance(model, str) else model
|
||||
|
||||
zero_cost_cache = _get_router_zero_cost_cache(llm_router)
|
||||
|
||||
for model_name in model_list:
|
||||
if zero_cost_cache is not None:
|
||||
cached = zero_cost_cache.get(model_name)
|
||||
if cached is not None:
|
||||
if cached is False:
|
||||
return False
|
||||
continue
|
||||
try:
|
||||
# Use router's get_model_group_info method directly for better reliability
|
||||
model_group_info = llm_router.get_model_group_info(model_group=model_name)
|
||||
|
|
@ -152,6 +176,8 @@ def _is_model_cost_zero(
|
|||
verbose_proxy_logger.debug(
|
||||
f"No model group info found for {model_name}, assuming it has cost"
|
||||
)
|
||||
if zero_cost_cache is not None:
|
||||
zero_cost_cache[model_name] = False
|
||||
return False
|
||||
|
||||
# Check costs for this model
|
||||
|
|
@ -164,6 +190,8 @@ def _is_model_cost_zero(
|
|||
verbose_proxy_logger.debug(
|
||||
f"Model {model_name} has undefined cost (input: {input_cost}, output: {output_cost}), assuming it has cost"
|
||||
)
|
||||
if zero_cost_cache is not None:
|
||||
zero_cost_cache[model_name] = False
|
||||
return False
|
||||
|
||||
# If either cost is non-zero, return False
|
||||
|
|
@ -171,6 +199,8 @@ def _is_model_cost_zero(
|
|||
verbose_proxy_logger.debug(
|
||||
f"Model {model_name} has non-zero cost (input: {input_cost}, output: {output_cost})"
|
||||
)
|
||||
if zero_cost_cache is not None:
|
||||
zero_cost_cache[model_name] = False
|
||||
return False
|
||||
|
||||
# Costs are 0 — verify this is from explicit configuration,
|
||||
|
|
@ -184,6 +214,8 @@ def _is_model_cost_zero(
|
|||
"cost (enforce budget)",
|
||||
safe_name,
|
||||
)
|
||||
if zero_cost_cache is not None:
|
||||
zero_cost_cache[model_name] = False
|
||||
return False
|
||||
|
||||
verbose_proxy_logger.debug(
|
||||
|
|
@ -192,6 +224,8 @@ def _is_model_cost_zero(
|
|||
input_cost,
|
||||
output_cost,
|
||||
)
|
||||
if zero_cost_cache is not None:
|
||||
zero_cost_cache[model_name] = True
|
||||
|
||||
except Exception as e:
|
||||
# If we can't determine the cost, assume it has cost (conservative approach)
|
||||
|
|
|
|||
|
|
@ -6691,6 +6691,9 @@ def _restamp_streaming_chunk_model(
|
|||
downstream_model = (
|
||||
chunk.get("model") if isinstance(chunk, dict) else getattr(chunk, "model", None)
|
||||
)
|
||||
if downstream_model == requested_model_from_client:
|
||||
return chunk, model_mismatch_logged
|
||||
|
||||
if not model_mismatch_logged and downstream_model != requested_model_from_client:
|
||||
verbose_proxy_logger.debug(
|
||||
"litellm_call_id=%s: streaming chunk model mismatch - requested=%r downstream=%r. Overriding model to requested.",
|
||||
|
|
@ -6719,7 +6722,125 @@ def _restamp_streaming_chunk_model(
|
|||
return chunk, model_mismatch_logged
|
||||
|
||||
|
||||
async def async_data_generator(
|
||||
def _fast_serialize_simple_model_response_stream(
|
||||
chunk: ModelResponseStream,
|
||||
) -> Optional[bytes]:
|
||||
"""
|
||||
Serialize the common OpenAI text streaming chunk without the full Pydantic
|
||||
serializer. Fall back for richer chunks so tool calls, logprobs, usage, and
|
||||
provider-specific fields keep the canonical model_dump_json behavior.
|
||||
"""
|
||||
if (
|
||||
getattr(chunk, "provider_specific_fields", None) is not None
|
||||
or getattr(chunk, "system_fingerprint", None) is not None
|
||||
or getattr(chunk, "usage", None) is not None
|
||||
):
|
||||
return None
|
||||
|
||||
choices = getattr(chunk, "choices", None)
|
||||
if not isinstance(choices, list) or len(choices) != 1:
|
||||
return None
|
||||
|
||||
choice = choices[0]
|
||||
if (
|
||||
getattr(choice, "logprobs", None) is not None
|
||||
or getattr(choice, "enhancements", None) is not None
|
||||
):
|
||||
return None
|
||||
|
||||
delta = getattr(choice, "delta", None)
|
||||
if delta is None:
|
||||
return None
|
||||
|
||||
unsupported_delta_fields = (
|
||||
"function_call",
|
||||
"tool_calls",
|
||||
"audio",
|
||||
"images",
|
||||
"annotations",
|
||||
"reasoning_content",
|
||||
"thinking_blocks",
|
||||
"provider_specific_fields",
|
||||
"refusal",
|
||||
)
|
||||
if any(
|
||||
getattr(delta, field, None) is not None for field in unsupported_delta_fields
|
||||
):
|
||||
return None
|
||||
|
||||
delta_dict: dict = {}
|
||||
role = getattr(delta, "role", None)
|
||||
content = getattr(delta, "content", None)
|
||||
if role is not None:
|
||||
delta_dict["role"] = role
|
||||
if content is not None:
|
||||
delta_dict["content"] = content
|
||||
|
||||
choice_dict = {"index": getattr(choice, "index", 0), "delta": delta_dict}
|
||||
finish_reason = getattr(choice, "finish_reason", None)
|
||||
if finish_reason is not None:
|
||||
choice_dict["finish_reason"] = finish_reason
|
||||
|
||||
# Match the canonical ``model_dump_json(exclude_none=True)`` shape — if a
|
||||
# field is None, omit it entirely rather than emitting ``"key": null``.
|
||||
# Strict OpenAI-compatible clients reject ``null`` for optional fields like
|
||||
# ``model``, so diverging here would surface as a client-side regression
|
||||
# only on the fast path. Fall back to the slow path if a required-looking
|
||||
# top-level identifier is missing.
|
||||
model = getattr(chunk, "model", None)
|
||||
if model is None:
|
||||
return None
|
||||
|
||||
payload: dict = {
|
||||
"id": getattr(chunk, "id", None),
|
||||
"object": getattr(chunk, "object", None),
|
||||
"created": getattr(chunk, "created", None),
|
||||
"model": model,
|
||||
"choices": [choice_dict],
|
||||
}
|
||||
for top_level_key in ("id", "object", "created"):
|
||||
if payload[top_level_key] is None:
|
||||
payload.pop(top_level_key)
|
||||
return orjson.dumps(payload)
|
||||
|
||||
|
||||
def _serialize_streaming_chunk(chunk: BaseModel) -> Union[str, bytes]:
|
||||
if isinstance(chunk, ModelResponseStream):
|
||||
serialized_chunk = _fast_serialize_simple_model_response_stream(chunk)
|
||||
if serialized_chunk is not None:
|
||||
return serialized_chunk
|
||||
|
||||
return chunk.model_dump_json(exclude_none=True, exclude_unset=True)
|
||||
|
||||
|
||||
async def _apply_streaming_chunk_hooks(
|
||||
*,
|
||||
chunk: Any,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
request_data: dict,
|
||||
str_so_far: str,
|
||||
) -> Tuple[Any, str]:
|
||||
chunk = await proxy_logging_obj.async_post_call_streaming_hook(
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
response=chunk,
|
||||
data=request_data,
|
||||
str_so_far=str_so_far if str_so_far else None,
|
||||
)
|
||||
|
||||
if isinstance(chunk, (ModelResponse, ModelResponseStream)):
|
||||
response_str = litellm.get_response_string(response_obj=chunk)
|
||||
str_so_far += response_str
|
||||
|
||||
return chunk, str_so_far
|
||||
|
||||
|
||||
def _format_streaming_sse_chunk(chunk: Union[str, bytes]) -> Union[str, bytes]:
|
||||
if isinstance(chunk, bytes):
|
||||
return b"data: " + chunk + b"\n\n"
|
||||
return f"data: {chunk}\n\n"
|
||||
|
||||
|
||||
async def async_data_generator( # noqa: PLR0915
|
||||
response, user_api_key_dict: UserAPIKeyAuth, request_data: dict
|
||||
):
|
||||
verbose_proxy_logger.debug("inside generator")
|
||||
|
|
@ -6733,22 +6854,36 @@ async def async_data_generator(
|
|||
# Previously "".join(str_so_far_parts) was called every chunk, re-joining
|
||||
# the entire accumulated response. String += is O(n) amortized total.
|
||||
_str_so_far: str = ""
|
||||
async for chunk in proxy_logging_obj.async_post_call_streaming_iterator_hook(
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
response=response,
|
||||
request_data=request_data,
|
||||
):
|
||||
### CALL HOOKS ### - modify outgoing data
|
||||
chunk = await proxy_logging_obj.async_post_call_streaming_hook(
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
response=chunk,
|
||||
data=request_data,
|
||||
str_so_far=_str_so_far if _str_so_far else None,
|
||||
)
|
||||
# Separate iterator-level vs per-chunk hook decisions. The iterator
|
||||
# wrap is needed when any callback overrides
|
||||
# ``async_post_call_streaming_iterator_hook`` or has
|
||||
# ``apply_guardrail``; the per-chunk hook (which builds ``str_so_far``
|
||||
# and calls ``async_post_call_streaming_hook``) is only needed when
|
||||
# there is an active CustomGuardrail or a class that overrides the
|
||||
# per-chunk hook. Coalescing them into a single flag forced wasted
|
||||
# ``get_response_string`` work per chunk on every deployment that
|
||||
# happened to ship a streaming-iterator override (the default).
|
||||
needs_iterator_wrap = proxy_logging_obj.needs_iterator_wrap()
|
||||
needs_per_chunk_hook = proxy_logging_obj.needs_per_chunk_streaming_hook()
|
||||
|
||||
if isinstance(chunk, (ModelResponse, ModelResponseStream)):
|
||||
response_str = litellm.get_response_string(response_obj=chunk)
|
||||
_str_so_far += response_str
|
||||
if needs_iterator_wrap:
|
||||
stream_iterator = proxy_logging_obj.async_post_call_streaming_iterator_hook(
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
response=response,
|
||||
request_data=request_data,
|
||||
)
|
||||
else:
|
||||
stream_iterator = response
|
||||
|
||||
async for chunk in stream_iterator:
|
||||
if needs_per_chunk_hook:
|
||||
### CALL HOOKS ### - modify outgoing data
|
||||
chunk, _str_so_far = await _apply_streaming_chunk_hooks(
|
||||
chunk=chunk,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
request_data=request_data,
|
||||
str_so_far=_str_so_far,
|
||||
)
|
||||
|
||||
chunk, model_mismatch_logged = _restamp_streaming_chunk_model(
|
||||
chunk=chunk,
|
||||
|
|
@ -6758,16 +6893,22 @@ async def async_data_generator(
|
|||
)
|
||||
|
||||
if isinstance(chunk, BaseModel):
|
||||
chunk = chunk.model_dump_json(exclude_none=True, exclude_unset=True)
|
||||
chunk = _serialize_streaming_chunk(chunk)
|
||||
elif isinstance(chunk, str) and chunk.startswith("data: "):
|
||||
error_message = chunk
|
||||
break
|
||||
|
||||
try:
|
||||
yield f"data: {chunk}\n\n"
|
||||
yield _format_streaming_sse_chunk(chunk=chunk)
|
||||
except Exception as e:
|
||||
yield f"data: {str(e)}\n\n"
|
||||
|
||||
if not needs_iterator_wrap:
|
||||
# The iterator-wrap path fires deferred logging itself; fire it
|
||||
# here for the no-wrap fast path so non-callback deployments
|
||||
# still flush their post-stream logging.
|
||||
ProxyLogging._fire_deferred_stream_logging(request_data)
|
||||
|
||||
# Streaming is done, yield the [DONE] chunk
|
||||
if error_message is not None:
|
||||
yield error_message
|
||||
|
|
|
|||
|
|
@ -12,15 +12,18 @@ import traceback
|
|||
from datetime import date, datetime, timedelta, timezone
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from dataclasses import dataclass, field
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
AsyncGenerator,
|
||||
Awaitable,
|
||||
ClassVar,
|
||||
Dict,
|
||||
List,
|
||||
Literal,
|
||||
Optional,
|
||||
Tuple,
|
||||
Union,
|
||||
cast,
|
||||
overload,
|
||||
|
|
@ -335,6 +338,30 @@ def _enrich_http_exception_with_guardrail_context(
|
|||
detail.setdefault("guardrail_mode", event_hook)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _CallbackCapabilities:
|
||||
"""Cached per-hook capability flags derived from ``litellm.callbacks``.
|
||||
|
||||
Recomputing this per request walked the callback list and resolved every
|
||||
string entry via ``get_custom_logger_compatible_class`` — a measurable
|
||||
chunk of overhead on streaming and non-streaming chat completions.
|
||||
"""
|
||||
|
||||
has_post_call_response_headers: bool = False
|
||||
has_iterator_override: bool = False
|
||||
has_streaming_chunk_override: bool = False
|
||||
has_guardrail: bool = False
|
||||
has_pre_call_override: bool = False
|
||||
# Tuple[(resolved_callback, "override" | "apply_guardrail"), ...]
|
||||
# Ordered the same as ``litellm.callbacks``; used to build the streaming
|
||||
# iterator chain without re-scanning per request.
|
||||
iterator_overrides: Tuple[Tuple[Any, str], ...] = field(default_factory=tuple)
|
||||
# Resolved CustomLogger callbacks in original order. Pre-resolving once
|
||||
# avoids the per-request ``get_custom_logger_compatible_class`` walk for
|
||||
# every string entry in ``litellm.callbacks``.
|
||||
resolved_callbacks: Tuple[Any, ...] = field(default_factory=tuple)
|
||||
|
||||
|
||||
class ProxyLogging:
|
||||
"""
|
||||
Logging/Custom Handlers for proxy.
|
||||
|
|
@ -1397,20 +1424,20 @@ class ProxyLogging:
|
|||
metadata = data.get("metadata", data.get("litellm_metadata", {})) or {}
|
||||
pipeline_managed: set = metadata.get("_pipeline_managed_guardrails", set())
|
||||
|
||||
for callback in litellm.callbacks:
|
||||
caps = ProxyLogging._callback_capabilities()
|
||||
# Skip the per-request callback walk entirely when nothing in
|
||||
# ``litellm.callbacks`` overrides ``async_pre_call_hook`` and no
|
||||
# CustomGuardrail is configured. Saves the loop overhead +
|
||||
# ``time.time()`` x2 per registered callback for the common
|
||||
# "callbacks=[]" case on small / dev deployments.
|
||||
if not caps.has_guardrail and not caps.has_pre_call_override:
|
||||
if data is not None:
|
||||
self._process_guardrail_metadata(data)
|
||||
return data
|
||||
|
||||
for _callback in caps.resolved_callbacks:
|
||||
start_time = time.time()
|
||||
_callback = None
|
||||
if isinstance(callback, str):
|
||||
_callback = litellm.litellm_core_utils.litellm_logging.get_custom_logger_compatible_class(
|
||||
cast(_custom_logger_compatible_callbacks_literal, callback)
|
||||
)
|
||||
else:
|
||||
_callback = callback # type: ignore
|
||||
if (
|
||||
_callback is not None
|
||||
and isinstance(_callback, CustomGuardrail)
|
||||
and data is not None
|
||||
):
|
||||
if isinstance(_callback, CustomGuardrail) and data is not None:
|
||||
# Skip guardrails managed by a pipeline
|
||||
if (
|
||||
_callback.guardrail_name
|
||||
|
|
@ -1505,6 +1532,131 @@ class ProxyLogging:
|
|||
_enrich_http_exception_with_guardrail_context(e, callback)
|
||||
raise
|
||||
|
||||
# Cache for callback-capability detection. Keyed on a signature of
|
||||
# litellm.callbacks (length + each item's id) so we recompute when the
|
||||
# callback list mutates (add/remove) without iterating every request.
|
||||
_callback_capabilities_cache: ClassVar[
|
||||
Dict[Tuple[int, Tuple[int, ...]], "_CallbackCapabilities"]
|
||||
] = {}
|
||||
|
||||
@staticmethod
|
||||
def _callback_capabilities() -> "_CallbackCapabilities":
|
||||
"""
|
||||
Inspect ``litellm.callbacks`` once and answer the per-hook capability
|
||||
questions used to short-circuit no-op work on the chat-completions hot
|
||||
path. Per-request callers iterated ``litellm.callbacks`` and called
|
||||
``get_custom_logger_compatible_class`` for every string entry — that
|
||||
scanning cost dominated the proxy overhead on low-config deployments.
|
||||
|
||||
Cache invalidates whenever the list length or member identities change.
|
||||
"""
|
||||
callbacks = litellm.callbacks
|
||||
sig = (len(callbacks), tuple(id(c) for c in callbacks))
|
||||
cache = ProxyLogging._callback_capabilities_cache
|
||||
cached = cache.get(sig)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
has_post_call_response_headers = False
|
||||
has_iterator_override = False
|
||||
has_streaming_chunk_override = False
|
||||
has_guardrail = False
|
||||
has_pre_call_override = False
|
||||
iterator_overrides: List[Tuple[Any, str]] = [] # (callback, kind)
|
||||
resolved_callbacks: List[Any] = []
|
||||
|
||||
for callback in callbacks:
|
||||
if isinstance(callback, str):
|
||||
resolved: Any = (
|
||||
litellm.litellm_core_utils.litellm_logging.get_custom_logger_compatible_class(
|
||||
cast(_custom_logger_compatible_callbacks_literal, callback)
|
||||
)
|
||||
)
|
||||
else:
|
||||
resolved = callback
|
||||
if resolved is None or not isinstance(resolved, CustomLogger):
|
||||
continue
|
||||
resolved_callbacks.append(resolved)
|
||||
cls = type(resolved)
|
||||
if cls is CustomLogger:
|
||||
continue
|
||||
if isinstance(resolved, CustomGuardrail):
|
||||
has_guardrail = True
|
||||
# Use the same leaf-class ``__dict__`` check as the other hook
|
||||
# capabilities: only callbacks that actually override the hook
|
||||
# contribute to the flag. Setting this for every ``CustomLogger``
|
||||
# instance (the prior behaviour) forced the full
|
||||
# ``post_call_response_headers_hook`` body to run on every request
|
||||
# even when no registered callback customized response headers.
|
||||
cls_attrs = cls.__dict__
|
||||
if "async_post_call_response_headers_hook" in cls_attrs:
|
||||
has_post_call_response_headers = True
|
||||
if "async_post_call_streaming_iterator_hook" in cls_attrs:
|
||||
has_iterator_override = True
|
||||
iterator_overrides.append((resolved, "override"))
|
||||
elif "apply_guardrail" in cls_attrs:
|
||||
iterator_overrides.append((resolved, "apply_guardrail"))
|
||||
if "async_post_call_streaming_hook" in cls_attrs:
|
||||
has_streaming_chunk_override = True
|
||||
if "async_pre_call_hook" in cls_attrs:
|
||||
has_pre_call_override = True
|
||||
|
||||
caps = _CallbackCapabilities(
|
||||
has_post_call_response_headers=has_post_call_response_headers,
|
||||
has_iterator_override=has_iterator_override
|
||||
or any(kind == "apply_guardrail" for _, kind in iterator_overrides),
|
||||
has_streaming_chunk_override=has_streaming_chunk_override,
|
||||
has_guardrail=has_guardrail,
|
||||
has_pre_call_override=has_pre_call_override,
|
||||
iterator_overrides=tuple(iterator_overrides),
|
||||
resolved_callbacks=tuple(resolved_callbacks),
|
||||
)
|
||||
# Limit cache to handle test churn without leaking; production
|
||||
# callback lists are stable so this rarely grows past 1 entry.
|
||||
if len(cache) >= 32:
|
||||
cache.clear()
|
||||
cache[sig] = caps
|
||||
return caps
|
||||
|
||||
@staticmethod
|
||||
def has_post_call_response_headers_callbacks() -> bool:
|
||||
return ProxyLogging._callback_capabilities().has_post_call_response_headers
|
||||
|
||||
@staticmethod
|
||||
def has_streaming_callbacks() -> bool:
|
||||
caps = ProxyLogging._callback_capabilities()
|
||||
return (
|
||||
caps.has_iterator_override
|
||||
or caps.has_streaming_chunk_override
|
||||
or caps.has_guardrail
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def has_streaming_chunk_hook_overrides() -> bool:
|
||||
"""True iff any callback overrides ``async_post_call_streaming_hook``
|
||||
(the per-chunk hook, distinct from the iterator wrapper)."""
|
||||
caps = ProxyLogging._callback_capabilities()
|
||||
return caps.has_streaming_chunk_override or caps.has_guardrail
|
||||
|
||||
def needs_iterator_wrap(self) -> bool:
|
||||
"""Whether ``async_data_generator`` needs to wrap the upstream stream
|
||||
through ``async_post_call_streaming_iterator_hook``. Instance method
|
||||
so tests can override the gate via ``MagicMock(spec=ProxyLogging)``.
|
||||
"""
|
||||
return ProxyLogging._callback_capabilities().has_iterator_override
|
||||
|
||||
def needs_per_chunk_streaming_hook(self) -> bool:
|
||||
"""Whether ``async_data_generator`` needs to call the per-chunk
|
||||
``_apply_streaming_chunk_hooks`` for every emitted chunk. Instance
|
||||
method for the same reason as :py:meth:`needs_iterator_wrap`.
|
||||
"""
|
||||
caps = ProxyLogging._callback_capabilities()
|
||||
return caps.has_streaming_chunk_override or caps.has_guardrail
|
||||
|
||||
@staticmethod
|
||||
def has_during_call_guardrails() -> bool:
|
||||
return ProxyLogging._callback_capabilities().has_guardrail
|
||||
|
||||
async def during_call_hook(
|
||||
self,
|
||||
data: dict,
|
||||
|
|
@ -1514,6 +1666,12 @@ class ProxyLogging:
|
|||
"""
|
||||
Runs the CustomGuardrail's async_moderation_hook() in parallel
|
||||
"""
|
||||
# Fast path: skip the entire guardrail scan when no CustomGuardrail
|
||||
# callbacks are registered. Saves per-request iteration over
|
||||
# ``litellm.callbacks`` plus an ``asyncio.gather([])`` round trip on
|
||||
# deployments with no guardrails configured.
|
||||
if not ProxyLogging._callback_capabilities().has_guardrail:
|
||||
return data
|
||||
# Step 1: Collect all guardrail tasks to run in parallel
|
||||
guardrail_tasks = []
|
||||
|
||||
|
|
@ -2122,6 +2280,14 @@ class ProxyLogging:
|
|||
Dict[str, str]: Merged headers from all callbacks.
|
||||
"""
|
||||
merged_headers: Dict[str, str] = {}
|
||||
# Outer call sites in common_request_processing.py already gate this
|
||||
# call with ``has_post_call_response_headers_callbacks()``. The
|
||||
# cached detection makes the redundant interior guard cheap, but the
|
||||
# guard would still iterate every code path through this function so
|
||||
# keep it cheap and rely on the cached capability lookup.
|
||||
if not ProxyLogging._callback_capabilities().has_post_call_response_headers:
|
||||
return merged_headers
|
||||
|
||||
try:
|
||||
# Build litellm_call_info — normalized routing metadata for callbacks
|
||||
litellm_call_info = self._build_litellm_call_info(
|
||||
|
|
@ -2203,6 +2369,16 @@ class ProxyLogging:
|
|||
Covers:
|
||||
1. /chat/completions
|
||||
"""
|
||||
# Per-chunk fast path: skip the response-string materialization and
|
||||
# callback scan when no configured callback overrides
|
||||
# ``async_post_call_streaming_hook`` AND no CustomGuardrail is
|
||||
# active. ``get_response_string`` walks every choice/delta on the
|
||||
# chunk so paying it per chunk for no-op callbacks dominated stream
|
||||
# CPU time even after the iterator-chain fix.
|
||||
caps = ProxyLogging._callback_capabilities()
|
||||
if not caps.has_streaming_chunk_override and not caps.has_guardrail:
|
||||
return response
|
||||
|
||||
from litellm.proxy.proxy_server import llm_router
|
||||
|
||||
response_str: Optional[str] = None
|
||||
|
|
@ -2278,6 +2454,18 @@ class ProxyLogging:
|
|||
Covers:
|
||||
1. /chat/completions
|
||||
"""
|
||||
caps = ProxyLogging._callback_capabilities()
|
||||
# Fast path: no real overrides. Internal proxy CustomLogger callbacks
|
||||
# (e.g. _PROXY_MaxBudgetLimiter, ManagedFiles) inherit the default
|
||||
# ``async for chunk: yield chunk`` body, so wrapping the iterator
|
||||
# through each of them adds N pass-through trampolines per chunk for
|
||||
# zero behavior change. Skip the chain entirely and stream through.
|
||||
if not caps.iterator_overrides:
|
||||
async for chunk in response:
|
||||
yield chunk
|
||||
ProxyLogging._fire_deferred_stream_logging(request_data)
|
||||
return
|
||||
|
||||
from litellm.proxy.proxy_server import llm_router
|
||||
|
||||
# Merge model-level guardrails before checking which guardrails to run
|
||||
|
|
@ -2287,55 +2475,35 @@ class ProxyLogging:
|
|||
|
||||
current_response = response
|
||||
|
||||
for callback in litellm.callbacks:
|
||||
_callback: Optional[CustomLogger] = None
|
||||
if isinstance(callback, str):
|
||||
_callback = litellm.litellm_core_utils.litellm_logging.get_custom_logger_compatible_class(
|
||||
cast(_custom_logger_compatible_callbacks_literal, callback)
|
||||
for resolved_callback, kind in caps.iterator_overrides:
|
||||
if isinstance(resolved_callback, CustomGuardrail):
|
||||
if (
|
||||
resolved_callback.should_run_guardrail(
|
||||
data=request_data, event_type=GuardrailEventHooks.post_call
|
||||
)
|
||||
is not True
|
||||
):
|
||||
continue
|
||||
if kind == "override":
|
||||
current_response = self._wrap_streaming_iterator_with_enrichment(
|
||||
resolved_callback,
|
||||
resolved_callback.async_post_call_streaming_iterator_hook(
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
response=current_response,
|
||||
request_data=request_data,
|
||||
),
|
||||
)
|
||||
else:
|
||||
_callback = callback # type: ignore
|
||||
if _callback is not None and isinstance(_callback, CustomLogger):
|
||||
if not isinstance(
|
||||
_callback, CustomGuardrail
|
||||
) or _callback.should_run_guardrail(
|
||||
data=request_data, event_type=GuardrailEventHooks.post_call
|
||||
):
|
||||
if (
|
||||
"async_post_call_streaming_iterator_hook"
|
||||
in type(callback).__dict__
|
||||
):
|
||||
current_response = (
|
||||
self._wrap_streaming_iterator_with_enrichment(
|
||||
_callback,
|
||||
_callback.async_post_call_streaming_iterator_hook(
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
response=current_response,
|
||||
request_data=request_data,
|
||||
),
|
||||
)
|
||||
)
|
||||
elif "apply_guardrail" in type(callback).__dict__:
|
||||
request_data["guardrail_to_apply"] = callback
|
||||
current_response = self._wrap_streaming_iterator_with_enrichment(
|
||||
_callback,
|
||||
unified_guardrail.async_post_call_streaming_iterator_hook(
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
request_data=request_data,
|
||||
response=current_response,
|
||||
),
|
||||
)
|
||||
else:
|
||||
current_response = (
|
||||
self._wrap_streaming_iterator_with_enrichment(
|
||||
_callback,
|
||||
_callback.async_post_call_streaming_iterator_hook(
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
response=current_response,
|
||||
request_data=request_data,
|
||||
),
|
||||
)
|
||||
)
|
||||
# kind == "apply_guardrail": route through unified_guardrail
|
||||
request_data["guardrail_to_apply"] = resolved_callback
|
||||
current_response = self._wrap_streaming_iterator_with_enrichment(
|
||||
resolved_callback,
|
||||
unified_guardrail.async_post_call_streaming_iterator_hook(
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
request_data=request_data,
|
||||
response=current_response,
|
||||
),
|
||||
)
|
||||
|
||||
# Actually iterate through the chained async generator and yield chunks
|
||||
async for chunk in current_response:
|
||||
|
|
|
|||
|
|
@ -491,6 +491,17 @@ class Router:
|
|||
# Maps (team_id, team_public_model_name) -> list of indices in model_list
|
||||
self.team_model_to_deployment_indices: Dict[Tuple[str, str], List[int]] = {}
|
||||
|
||||
# Initialize cache attributes that ``_invalidate_model_group_info_cache``
|
||||
# touches *before* the first ``set_model_list`` below (which calls
|
||||
# that invalidation as part of building the model index).
|
||||
self._access_groups_cache: Optional[Dict[str, List[str]]] = None
|
||||
# Per-router cache for the proxy auth-layer "is this model explicitly
|
||||
# zero-cost?" check. Lives on the router so it is invalidated alongside
|
||||
# ``_cached_get_model_group_info`` and dies with the router (no
|
||||
# ``id()``-reuse risk after GC). See
|
||||
# ``litellm.proxy.auth.auth_checks._is_model_cost_zero``.
|
||||
self._zero_cost_cache: Dict[str, bool] = {}
|
||||
|
||||
if model_list is not None:
|
||||
# set_model_list will build indices automatically
|
||||
self.set_model_list(model_list)
|
||||
|
|
@ -503,8 +514,6 @@ class Router:
|
|||
[]
|
||||
) # initialize an empty list - to allow _add_deployment and delete_deployment to work
|
||||
|
||||
self._access_groups_cache: Optional[Dict[str, List[str]]] = None
|
||||
|
||||
if allowed_fails is not None:
|
||||
self.allowed_fails = allowed_fails
|
||||
else:
|
||||
|
|
@ -9228,8 +9237,13 @@ class Router:
|
|||
"""Invalidate the cached model group info.
|
||||
|
||||
Call this whenever self.model_list is modified to ensure the cache is rebuilt.
|
||||
Also clears the auth-layer zero-cost cache, which depends on the same
|
||||
``ModelGroupInfo`` data — without this, an in-place pricing update on
|
||||
an existing deployment (same model count) would keep a stale ``True``
|
||||
result and bypass budget enforcement.
|
||||
"""
|
||||
self._cached_get_model_group_info.cache_clear()
|
||||
self._zero_cost_cache.clear()
|
||||
|
||||
def _invalidate_access_groups_cache(self) -> None:
|
||||
"""Invalidate the cached access groups.
|
||||
|
|
|
|||
842
scripts/benchmark_chat_completions_perf.py
Normal file
842
scripts/benchmark_chat_completions_perf.py
Normal file
|
|
@ -0,0 +1,842 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Benchmark LiteLLM proxy /v1/chat/completions overhead and streaming TTFT.
|
||||
|
||||
The script can run a local OpenAI-compatible mock provider plus a LiteLLM proxy
|
||||
from any checkout. That makes it useful for comparing tags/commits without
|
||||
depending on real provider latency.
|
||||
|
||||
Example:
|
||||
uv run python scripts/benchmark_chat_completions_perf.py \
|
||||
--label current --requests 500 --concurrency 100
|
||||
|
||||
Compare another checkout:
|
||||
uv run python scripts/benchmark_chat_completions_perf.py \
|
||||
--label v1.83.14-stable --litellm-dir /tmp/litellm-v1.83.14-stable
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import signal
|
||||
import statistics
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import web
|
||||
|
||||
|
||||
DEFAULT_MODEL = "perf-test-model"
|
||||
DEFAULT_API_KEY = "sk-1234"
|
||||
|
||||
|
||||
@dataclass
|
||||
class RequestSample:
|
||||
success: bool
|
||||
latency_ms: float
|
||||
status_code: int
|
||||
overhead_header_ms: Optional[float] = None
|
||||
error: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class SummaryStats:
|
||||
requests: int
|
||||
failures: int
|
||||
rps: float
|
||||
mean_ms: float
|
||||
p50_ms: float
|
||||
p95_ms: float
|
||||
p99_ms: float
|
||||
overhead_header_mean_ms: Optional[float] = None
|
||||
overhead_header_p50_ms: Optional[float] = None
|
||||
overhead_header_p95_ms: Optional[float] = None
|
||||
|
||||
|
||||
class MockOpenAIProvider:
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
port: int,
|
||||
first_token_delay_ms: float,
|
||||
stream_content_chunks: int,
|
||||
) -> None:
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.first_token_delay_ms = first_token_delay_ms
|
||||
self.stream_content_chunks = stream_content_chunks
|
||||
self.runner: Optional[web.AppRunner] = None
|
||||
|
||||
@property
|
||||
def base_url(self) -> str:
|
||||
return f"http://{self.host}:{self.port}"
|
||||
|
||||
async def start(self) -> None:
|
||||
app = web.Application()
|
||||
app.router.add_post("/v1/chat/completions", self.handle_chat_completions)
|
||||
self.runner = web.AppRunner(app, access_log=None)
|
||||
await self.runner.setup()
|
||||
site = web.TCPSite(self.runner, self.host, self.port)
|
||||
await site.start()
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self.runner is not None:
|
||||
await self.runner.cleanup()
|
||||
|
||||
async def handle_chat_completions(self, request: web.Request) -> web.StreamResponse:
|
||||
body = await request.json()
|
||||
if body.get("stream"):
|
||||
return await self._streaming_response(request=request, body=body)
|
||||
return self._json_response(body)
|
||||
|
||||
def _json_response(self, body: dict[str, Any]) -> web.Response:
|
||||
now = int(time.time())
|
||||
payload = {
|
||||
"id": "chatcmpl-perf",
|
||||
"object": "chat.completion",
|
||||
"created": now,
|
||||
"model": body.get("model", DEFAULT_MODEL),
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": "hello"},
|
||||
"finish_reason": "stop",
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
"prompt_tokens": 1,
|
||||
"completion_tokens": 1,
|
||||
"total_tokens": 2,
|
||||
},
|
||||
}
|
||||
return web.json_response(payload)
|
||||
|
||||
async def _streaming_response(
|
||||
self, request: web.Request, body: dict[str, Any]
|
||||
) -> web.StreamResponse:
|
||||
response = web.StreamResponse(
|
||||
status=200,
|
||||
headers={
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
},
|
||||
)
|
||||
await response.prepare(request)
|
||||
if self.first_token_delay_ms > 0:
|
||||
await asyncio.sleep(self.first_token_delay_ms / 1000)
|
||||
|
||||
created = int(time.time())
|
||||
chunks = [{"role": "assistant"}]
|
||||
chunks.extend({"content": "hello"} for _ in range(self.stream_content_chunks))
|
||||
for delta in chunks:
|
||||
event = {
|
||||
"id": "chatcmpl-perf",
|
||||
"object": "chat.completion.chunk",
|
||||
"created": created,
|
||||
"model": body.get("model", DEFAULT_MODEL),
|
||||
"choices": [{"index": 0, "delta": delta, "finish_reason": None}],
|
||||
}
|
||||
await response.write(f"data: {json.dumps(event)}\n\n".encode())
|
||||
|
||||
done_event = {
|
||||
"id": "chatcmpl-perf",
|
||||
"object": "chat.completion.chunk",
|
||||
"created": created,
|
||||
"model": body.get("model", DEFAULT_MODEL),
|
||||
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
|
||||
}
|
||||
await response.write(f"data: {json.dumps(done_event)}\n\n".encode())
|
||||
await response.write(b"data: [DONE]\n\n")
|
||||
await response.write_eof()
|
||||
return response
|
||||
|
||||
|
||||
def percentile(values: list[float], pct: float) -> float:
|
||||
if not values:
|
||||
return 0.0
|
||||
sorted_values = sorted(values)
|
||||
index = min(int(len(sorted_values) * pct / 100), len(sorted_values) - 1)
|
||||
return sorted_values[index]
|
||||
|
||||
|
||||
def summarize(samples: list[RequestSample], wall_time_s: float) -> SummaryStats:
|
||||
latencies = [sample.latency_ms for sample in samples if sample.success]
|
||||
overhead_headers = [
|
||||
sample.overhead_header_ms
|
||||
for sample in samples
|
||||
if sample.success and sample.overhead_header_ms is not None
|
||||
]
|
||||
failures = len(samples) - len(latencies)
|
||||
return SummaryStats(
|
||||
requests=len(samples),
|
||||
failures=failures,
|
||||
rps=(len(latencies) / wall_time_s) if wall_time_s > 0 else 0.0,
|
||||
mean_ms=statistics.mean(latencies) if latencies else 0.0,
|
||||
p50_ms=percentile(latencies, 50),
|
||||
p95_ms=percentile(latencies, 95),
|
||||
p99_ms=percentile(latencies, 99),
|
||||
overhead_header_mean_ms=(
|
||||
statistics.mean(overhead_headers) if overhead_headers else None
|
||||
),
|
||||
overhead_header_p50_ms=(
|
||||
percentile(overhead_headers, 50) if overhead_headers else None
|
||||
),
|
||||
overhead_header_p95_ms=(
|
||||
percentile(overhead_headers, 95) if overhead_headers else None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def format_optional_ms(value: Optional[float]) -> str:
|
||||
return "n/a" if value is None else f"{value:.2f}"
|
||||
|
||||
|
||||
def get_git_revision(litellm_dir: Path) -> str:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--short", "HEAD"],
|
||||
cwd=litellm_dir,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
except Exception:
|
||||
return "unknown"
|
||||
|
||||
|
||||
def write_proxy_config(config_path: Path, provider_base_url: str, api_key: str) -> None:
|
||||
config_path.write_text(
|
||||
f"""model_list:
|
||||
- model_name: {DEFAULT_MODEL}
|
||||
litellm_params:
|
||||
model: openai/{DEFAULT_MODEL}
|
||||
api_key: fake-provider-key
|
||||
api_base: {provider_base_url}/v1
|
||||
|
||||
general_settings:
|
||||
master_key: {api_key}
|
||||
|
||||
litellm_settings:
|
||||
drop_params: true
|
||||
telemetry: false
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
async def wait_for_proxy(base_url: str, timeout_s: float) -> None:
|
||||
deadline = time.perf_counter() + timeout_s
|
||||
last_error = ""
|
||||
async with aiohttp.ClientSession() as session:
|
||||
while time.perf_counter() < deadline:
|
||||
try:
|
||||
async with session.get(f"{base_url}/health") as response:
|
||||
if response.status < 500:
|
||||
return
|
||||
last_error = f"HTTP {response.status}: {await response.text()}"
|
||||
except Exception as exc:
|
||||
last_error = str(exc)
|
||||
await asyncio.sleep(0.5)
|
||||
raise TimeoutError(f"Timed out waiting for proxy at {base_url}: {last_error}")
|
||||
|
||||
|
||||
def start_proxy_process(
|
||||
litellm_dir: Path,
|
||||
proxy_command: str,
|
||||
config_path: Path,
|
||||
port: int,
|
||||
log_path: Path,
|
||||
) -> subprocess.Popen:
|
||||
command = shlex.split(proxy_command) + [
|
||||
"--config",
|
||||
str(config_path),
|
||||
"--port",
|
||||
str(port),
|
||||
]
|
||||
env = {
|
||||
**os.environ,
|
||||
"LITELLM_TELEMETRY": "False",
|
||||
"PYTHONUNBUFFERED": "1",
|
||||
}
|
||||
log_file = log_path.open("w", encoding="utf-8")
|
||||
return subprocess.Popen(
|
||||
command,
|
||||
cwd=litellm_dir,
|
||||
env=env,
|
||||
stdout=log_file,
|
||||
stderr=subprocess.STDOUT,
|
||||
start_new_session=True,
|
||||
)
|
||||
|
||||
|
||||
def stop_proxy_process(process: subprocess.Popen) -> None:
|
||||
if process.poll() is not None:
|
||||
return
|
||||
try:
|
||||
os.killpg(process.pid, signal.SIGTERM)
|
||||
process.wait(timeout=10)
|
||||
except Exception:
|
||||
try:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def extract_overhead_header(headers: aiohttp.typedefs.LooseHeaders) -> Optional[float]:
|
||||
raw_value = headers.get("x-litellm-overhead-duration-ms") # type: ignore[union-attr]
|
||||
if raw_value is None:
|
||||
return None
|
||||
try:
|
||||
return float(raw_value)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
async def post_non_streaming(
|
||||
session: aiohttp.ClientSession,
|
||||
url: str,
|
||||
headers: dict[str, str],
|
||||
payload: dict[str, Any],
|
||||
semaphore: asyncio.Semaphore,
|
||||
) -> RequestSample:
|
||||
async with semaphore:
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
async with session.post(url, headers=headers, json=payload) as response:
|
||||
body = await response.read()
|
||||
latency_ms = (time.perf_counter() - start) * 1000
|
||||
if response.status != 200:
|
||||
return RequestSample(
|
||||
success=False,
|
||||
latency_ms=latency_ms,
|
||||
status_code=response.status,
|
||||
error=body.decode("utf-8", errors="ignore")[:200],
|
||||
)
|
||||
return RequestSample(
|
||||
success=True,
|
||||
latency_ms=latency_ms,
|
||||
status_code=response.status,
|
||||
overhead_header_ms=extract_overhead_header(response.headers),
|
||||
)
|
||||
except Exception as exc:
|
||||
return RequestSample(
|
||||
success=False,
|
||||
latency_ms=(time.perf_counter() - start) * 1000,
|
||||
status_code=0,
|
||||
error=str(exc)[:200],
|
||||
)
|
||||
|
||||
|
||||
async def run_non_streaming_benchmark(
|
||||
url: str,
|
||||
headers: dict[str, str],
|
||||
payload: dict[str, Any],
|
||||
requests: int,
|
||||
concurrency: int,
|
||||
warmup: int,
|
||||
timeout_s: float,
|
||||
) -> SummaryStats:
|
||||
timeout = aiohttp.ClientTimeout(total=timeout_s)
|
||||
connector = aiohttp.TCPConnector(
|
||||
limit=max(concurrency * 2, 10),
|
||||
limit_per_host=max(concurrency, 10),
|
||||
force_close=False,
|
||||
)
|
||||
semaphore = asyncio.Semaphore(concurrency)
|
||||
async with aiohttp.ClientSession(connector=connector, timeout=timeout) as session:
|
||||
if warmup > 0:
|
||||
await asyncio.gather(
|
||||
*[
|
||||
post_non_streaming(session, url, headers, payload, semaphore)
|
||||
for _ in range(warmup)
|
||||
]
|
||||
)
|
||||
wall_start = time.perf_counter()
|
||||
samples = await asyncio.gather(
|
||||
*[
|
||||
post_non_streaming(session, url, headers, payload, semaphore)
|
||||
for _ in range(requests)
|
||||
]
|
||||
)
|
||||
wall_time_s = time.perf_counter() - wall_start
|
||||
return summarize(samples, wall_time_s)
|
||||
|
||||
|
||||
async def measure_stream_ttft(
|
||||
session: aiohttp.ClientSession,
|
||||
url: str,
|
||||
headers: dict[str, str],
|
||||
payload: dict[str, Any],
|
||||
semaphore: asyncio.Semaphore,
|
||||
) -> RequestSample:
|
||||
async with semaphore:
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
async with session.post(url, headers=headers, json=payload) as response:
|
||||
if response.status != 200:
|
||||
body = await response.read()
|
||||
return RequestSample(
|
||||
success=False,
|
||||
latency_ms=(time.perf_counter() - start) * 1000,
|
||||
status_code=response.status,
|
||||
error=body.decode("utf-8", errors="ignore")[:200],
|
||||
)
|
||||
|
||||
while raw_line := await response.content.readline():
|
||||
line = raw_line.strip()
|
||||
if not line or not line.startswith(b"data:"):
|
||||
continue
|
||||
event_payload = line[5:].strip()
|
||||
if event_payload == b"[DONE]":
|
||||
break
|
||||
event = json.loads(event_payload)
|
||||
choice = (event.get("choices") or [{}])[0]
|
||||
delta = choice.get("delta") or {}
|
||||
content = delta.get("content") or choice.get("text")
|
||||
if content:
|
||||
return RequestSample(
|
||||
success=True,
|
||||
latency_ms=(time.perf_counter() - start) * 1000,
|
||||
status_code=response.status,
|
||||
overhead_header_ms=extract_overhead_header(
|
||||
response.headers
|
||||
),
|
||||
)
|
||||
return RequestSample(
|
||||
success=False,
|
||||
latency_ms=(time.perf_counter() - start) * 1000,
|
||||
status_code=response.status,
|
||||
error="stream ended before a content token",
|
||||
)
|
||||
except Exception as exc:
|
||||
return RequestSample(
|
||||
success=False,
|
||||
latency_ms=(time.perf_counter() - start) * 1000,
|
||||
status_code=0,
|
||||
error=str(exc)[:200],
|
||||
)
|
||||
|
||||
|
||||
async def run_streaming_ttft_benchmark(
|
||||
url: str,
|
||||
headers: dict[str, str],
|
||||
payload: dict[str, Any],
|
||||
requests: int,
|
||||
concurrency: int,
|
||||
warmup: int,
|
||||
timeout_s: float,
|
||||
) -> SummaryStats:
|
||||
timeout = aiohttp.ClientTimeout(total=timeout_s)
|
||||
connector = aiohttp.TCPConnector(
|
||||
limit=max(concurrency * 2, 10),
|
||||
limit_per_host=max(concurrency, 10),
|
||||
force_close=False,
|
||||
)
|
||||
semaphore = asyncio.Semaphore(concurrency)
|
||||
async with aiohttp.ClientSession(connector=connector, timeout=timeout) as session:
|
||||
if warmup > 0:
|
||||
await asyncio.gather(
|
||||
*[
|
||||
measure_stream_ttft(session, url, headers, payload, semaphore)
|
||||
for _ in range(warmup)
|
||||
]
|
||||
)
|
||||
wall_start = time.perf_counter()
|
||||
samples = await asyncio.gather(
|
||||
*[
|
||||
measure_stream_ttft(session, url, headers, payload, semaphore)
|
||||
for _ in range(requests)
|
||||
]
|
||||
)
|
||||
wall_time_s = time.perf_counter() - wall_start
|
||||
return summarize(samples, wall_time_s)
|
||||
|
||||
|
||||
async def measure_stream_full_response(
|
||||
session: aiohttp.ClientSession,
|
||||
url: str,
|
||||
headers: dict[str, str],
|
||||
payload: dict[str, Any],
|
||||
semaphore: asyncio.Semaphore,
|
||||
) -> RequestSample:
|
||||
async with semaphore:
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
async with session.post(url, headers=headers, json=payload) as response:
|
||||
if response.status != 200:
|
||||
body = await response.read()
|
||||
return RequestSample(
|
||||
success=False,
|
||||
latency_ms=(time.perf_counter() - start) * 1000,
|
||||
status_code=response.status,
|
||||
error=body.decode("utf-8", errors="ignore")[:200],
|
||||
)
|
||||
|
||||
saw_content = False
|
||||
while raw_line := await response.content.readline():
|
||||
line = raw_line.strip()
|
||||
if not line or not line.startswith(b"data:"):
|
||||
continue
|
||||
event_payload = line[5:].strip()
|
||||
if event_payload == b"[DONE]":
|
||||
return RequestSample(
|
||||
success=saw_content,
|
||||
latency_ms=(time.perf_counter() - start) * 1000,
|
||||
status_code=response.status,
|
||||
overhead_header_ms=extract_overhead_header(
|
||||
response.headers
|
||||
),
|
||||
error="" if saw_content else "stream ended without content",
|
||||
)
|
||||
if b'"content"' in event_payload or b'"text"' in event_payload:
|
||||
saw_content = True
|
||||
|
||||
return RequestSample(
|
||||
success=False,
|
||||
latency_ms=(time.perf_counter() - start) * 1000,
|
||||
status_code=response.status,
|
||||
error="stream ended before [DONE]",
|
||||
)
|
||||
except Exception as exc:
|
||||
return RequestSample(
|
||||
success=False,
|
||||
latency_ms=(time.perf_counter() - start) * 1000,
|
||||
status_code=0,
|
||||
error=str(exc)[:200],
|
||||
)
|
||||
|
||||
|
||||
async def run_streaming_full_benchmark(
|
||||
url: str,
|
||||
headers: dict[str, str],
|
||||
payload: dict[str, Any],
|
||||
requests: int,
|
||||
concurrency: int,
|
||||
warmup: int,
|
||||
timeout_s: float,
|
||||
) -> SummaryStats:
|
||||
timeout = aiohttp.ClientTimeout(total=timeout_s)
|
||||
connector = aiohttp.TCPConnector(
|
||||
limit=max(concurrency * 2, 10),
|
||||
limit_per_host=max(concurrency, 10),
|
||||
force_close=False,
|
||||
)
|
||||
semaphore = asyncio.Semaphore(concurrency)
|
||||
async with aiohttp.ClientSession(connector=connector, timeout=timeout) as session:
|
||||
if warmup > 0:
|
||||
await asyncio.gather(
|
||||
*[
|
||||
measure_stream_full_response(
|
||||
session, url, headers, payload, semaphore
|
||||
)
|
||||
for _ in range(warmup)
|
||||
]
|
||||
)
|
||||
wall_start = time.perf_counter()
|
||||
samples = await asyncio.gather(
|
||||
*[
|
||||
measure_stream_full_response(session, url, headers, payload, semaphore)
|
||||
for _ in range(requests)
|
||||
]
|
||||
)
|
||||
wall_time_s = time.perf_counter() - wall_start
|
||||
return summarize(samples, wall_time_s)
|
||||
|
||||
|
||||
def stats_to_dict(stats: SummaryStats) -> dict[str, Any]:
|
||||
return {
|
||||
"requests": stats.requests,
|
||||
"failures": stats.failures,
|
||||
"rps": stats.rps,
|
||||
"mean_ms": stats.mean_ms,
|
||||
"p50_ms": stats.p50_ms,
|
||||
"p95_ms": stats.p95_ms,
|
||||
"p99_ms": stats.p99_ms,
|
||||
"overhead_header_mean_ms": stats.overhead_header_mean_ms,
|
||||
"overhead_header_p50_ms": stats.overhead_header_p50_ms,
|
||||
"overhead_header_p95_ms": stats.overhead_header_p95_ms,
|
||||
}
|
||||
|
||||
|
||||
def _median_run(
|
||||
runs: list[tuple[SummaryStats, SummaryStats, SummaryStats, Optional[SummaryStats]]],
|
||||
) -> tuple[SummaryStats, SummaryStats, SummaryStats, Optional[SummaryStats]]:
|
||||
# Pick the run whose proxy non-stream p50 is the median across repeats.
|
||||
# Choosing a single representative run (rather than aggregating each metric
|
||||
# separately) keeps related metrics from the same execution context so
|
||||
# client-overhead deltas stay internally consistent.
|
||||
sorted_runs = sorted(runs, key=lambda r: r[1].p50_ms)
|
||||
return sorted_runs[len(sorted_runs) // 2]
|
||||
|
||||
|
||||
def print_summary(
|
||||
label: str,
|
||||
revision: str,
|
||||
direct: SummaryStats,
|
||||
proxy: SummaryStats,
|
||||
stream: SummaryStats,
|
||||
stream_full: Optional[SummaryStats],
|
||||
) -> None:
|
||||
client_overhead_p50 = proxy.p50_ms - direct.p50_ms
|
||||
client_overhead_p95 = proxy.p95_ms - direct.p95_ms
|
||||
print("\n=== Benchmark summary ===")
|
||||
print(f"Label: {label}")
|
||||
print(f"Revision: {revision}")
|
||||
print(f"Direct provider non-stream p50: {direct.p50_ms:.2f} ms")
|
||||
print(f"Proxy non-stream p50: {proxy.p50_ms:.2f} ms")
|
||||
print(f"Proxy non-stream p95: {proxy.p95_ms:.2f} ms")
|
||||
print(f"Proxy non-stream RPS: {proxy.rps:.2f}")
|
||||
print(f"Client-observed overhead p50: {client_overhead_p50:.2f} ms")
|
||||
print(f"Client-observed overhead p95: {client_overhead_p95:.2f} ms")
|
||||
print(
|
||||
"x-litellm-overhead-duration-ms p50: "
|
||||
f"{format_optional_ms(proxy.overhead_header_p50_ms)} ms"
|
||||
)
|
||||
print(f"Streaming TTFT p50: {stream.p50_ms:.2f} ms")
|
||||
print(f"Streaming TTFT p95: {stream.p95_ms:.2f} ms")
|
||||
print(f"Streaming TTFT RPS: {stream.rps:.2f}")
|
||||
if stream_full is not None:
|
||||
print(f"Streaming full response p50: {stream_full.p50_ms:.2f} ms")
|
||||
print(f"Streaming full response p95: {stream_full.p95_ms:.2f} ms")
|
||||
print(f"Streaming full response RPS: {stream_full.rps:.2f}")
|
||||
print("\nMarkdown row:")
|
||||
print(
|
||||
"| "
|
||||
+ " | ".join(
|
||||
[
|
||||
label,
|
||||
revision,
|
||||
f"{stream.p50_ms:.2f}",
|
||||
f"{stream.p95_ms:.2f}",
|
||||
f"{proxy.rps:.2f}",
|
||||
f"{client_overhead_p50:.2f}",
|
||||
f"{client_overhead_p95:.2f}",
|
||||
format_optional_ms(proxy.overhead_header_p50_ms),
|
||||
f"{stream_full.p50_ms:.2f}" if stream_full is not None else "n/a",
|
||||
f"{stream_full.rps:.2f}" if stream_full is not None else "n/a",
|
||||
]
|
||||
)
|
||||
+ " |"
|
||||
)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--label", default="current", help="Label for this run")
|
||||
parser.add_argument(
|
||||
"--litellm-dir",
|
||||
default=str(Path.cwd()),
|
||||
help="Checkout directory used to start the LiteLLM proxy",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--proxy-command",
|
||||
default="uv run litellm",
|
||||
help="Command used to start the proxy inside --litellm-dir",
|
||||
)
|
||||
parser.add_argument("--proxy-host", default="127.0.0.1")
|
||||
parser.add_argument("--proxy-port", type=int, default=4000)
|
||||
parser.add_argument("--provider-host", default="127.0.0.1")
|
||||
parser.add_argument("--provider-port", type=int, default=8099)
|
||||
parser.add_argument("--api-key", default=DEFAULT_API_KEY)
|
||||
parser.add_argument("--requests", type=int, default=500)
|
||||
parser.add_argument("--concurrency", type=int, default=100)
|
||||
parser.add_argument("--stream-requests", type=int, default=200)
|
||||
parser.add_argument("--stream-concurrency", type=int, default=20)
|
||||
parser.add_argument("--warmup", type=int, default=100)
|
||||
parser.add_argument("--stream-warmup", type=int, default=20)
|
||||
parser.add_argument("--timeout", type=float, default=30)
|
||||
parser.add_argument("--proxy-start-timeout", type=float, default=90)
|
||||
parser.add_argument("--provider-first-token-delay-ms", type=float, default=0)
|
||||
parser.add_argument(
|
||||
"--provider-stream-content-chunks",
|
||||
type=int,
|
||||
default=20,
|
||||
help="Streaming chunks the mock provider emits. Default 20 (realistic).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--measure-full-stream",
|
||||
action="store_true",
|
||||
default=True,
|
||||
help="Measure time to consume the complete streaming response (on by default).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-measure-full-stream",
|
||||
dest="measure_full_stream",
|
||||
action="store_false",
|
||||
help="Skip the full-stream RPS measurement.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--repeats",
|
||||
type=int,
|
||||
default=1,
|
||||
help="Run the entire suite N times against the same proxy and report the median run.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-start-proxy",
|
||||
action="store_true",
|
||||
help="Benchmark an already-running proxy at --proxy-host/--proxy-port",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--provider-url",
|
||||
help="Use an already-running provider instead of starting the mock provider",
|
||||
)
|
||||
parser.add_argument("--output-json", help="Write machine-readable results")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
async def async_main() -> None:
|
||||
args = parse_args()
|
||||
litellm_dir = Path(args.litellm_dir).resolve()
|
||||
revision = get_git_revision(litellm_dir)
|
||||
proxy_base_url = f"http://{args.proxy_host}:{args.proxy_port}"
|
||||
proxy_url = f"{proxy_base_url}/v1/chat/completions"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {args.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
provider_headers = {
|
||||
"Authorization": "Bearer fake-provider-key",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
non_stream_payload = {
|
||||
"model": DEFAULT_MODEL,
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
"max_tokens": 1,
|
||||
}
|
||||
stream_payload = {**non_stream_payload, "stream": True}
|
||||
|
||||
provider: Optional[MockOpenAIProvider] = None
|
||||
proxy_process: Optional[subprocess.Popen] = None
|
||||
with tempfile.TemporaryDirectory(prefix="litellm-perf-") as tmp_dir_name:
|
||||
tmp_dir = Path(tmp_dir_name)
|
||||
proxy_log_path = tmp_dir / "proxy.log"
|
||||
if args.provider_url:
|
||||
provider_base_url = args.provider_url.rstrip("/")
|
||||
else:
|
||||
provider = MockOpenAIProvider(
|
||||
host=args.provider_host,
|
||||
port=args.provider_port,
|
||||
first_token_delay_ms=args.provider_first_token_delay_ms,
|
||||
stream_content_chunks=args.provider_stream_content_chunks,
|
||||
)
|
||||
await provider.start()
|
||||
provider_base_url = provider.base_url
|
||||
|
||||
config_path = tmp_dir / "config.yaml"
|
||||
write_proxy_config(config_path, provider_base_url, args.api_key)
|
||||
|
||||
try:
|
||||
if not args.no_start_proxy:
|
||||
proxy_process = start_proxy_process(
|
||||
litellm_dir=litellm_dir,
|
||||
proxy_command=args.proxy_command,
|
||||
config_path=config_path,
|
||||
port=args.proxy_port,
|
||||
log_path=proxy_log_path,
|
||||
)
|
||||
await wait_for_proxy(proxy_base_url, args.proxy_start_timeout)
|
||||
|
||||
runs: list[
|
||||
tuple[
|
||||
SummaryStats,
|
||||
SummaryStats,
|
||||
SummaryStats,
|
||||
Optional[SummaryStats],
|
||||
]
|
||||
] = []
|
||||
for run_idx in range(max(1, args.repeats)):
|
||||
if args.repeats > 1:
|
||||
print(f"\n--- Run {run_idx + 1}/{args.repeats} ---")
|
||||
_direct = await run_non_streaming_benchmark(
|
||||
url=f"{provider_base_url}/v1/chat/completions",
|
||||
headers=provider_headers,
|
||||
payload=non_stream_payload,
|
||||
requests=args.requests,
|
||||
concurrency=args.concurrency,
|
||||
warmup=args.warmup,
|
||||
timeout_s=args.timeout,
|
||||
)
|
||||
_proxy = await run_non_streaming_benchmark(
|
||||
url=proxy_url,
|
||||
headers=headers,
|
||||
payload=non_stream_payload,
|
||||
requests=args.requests,
|
||||
concurrency=args.concurrency,
|
||||
warmup=args.warmup,
|
||||
timeout_s=args.timeout,
|
||||
)
|
||||
_stream = await run_streaming_ttft_benchmark(
|
||||
url=proxy_url,
|
||||
headers=headers,
|
||||
payload=stream_payload,
|
||||
requests=args.stream_requests,
|
||||
concurrency=args.stream_concurrency,
|
||||
warmup=args.stream_warmup,
|
||||
timeout_s=args.timeout,
|
||||
)
|
||||
_stream_full = (
|
||||
await run_streaming_full_benchmark(
|
||||
url=proxy_url,
|
||||
headers=headers,
|
||||
payload=stream_payload,
|
||||
requests=args.stream_requests,
|
||||
concurrency=args.stream_concurrency,
|
||||
warmup=args.stream_warmup,
|
||||
timeout_s=args.timeout,
|
||||
)
|
||||
if args.measure_full_stream
|
||||
else None
|
||||
)
|
||||
runs.append((_direct, _proxy, _stream, _stream_full))
|
||||
if args.repeats > 1:
|
||||
print(
|
||||
f" run {run_idx + 1}: non-stream p50={_proxy.p50_ms:.2f}ms "
|
||||
f"rps={_proxy.rps:.2f} | TTFT p50={_stream.p50_ms:.2f}ms "
|
||||
f"full RPS="
|
||||
+ (f"{_stream_full.rps:.2f}" if _stream_full else "n/a")
|
||||
)
|
||||
|
||||
direct, proxy, stream, stream_full = _median_run(runs)
|
||||
finally:
|
||||
if proxy_process is not None:
|
||||
stop_proxy_process(proxy_process)
|
||||
if provider is not None:
|
||||
await provider.stop()
|
||||
|
||||
print_summary(args.label, revision, direct, proxy, stream, stream_full)
|
||||
|
||||
if args.output_json:
|
||||
output = {
|
||||
"label": args.label,
|
||||
"revision": revision,
|
||||
"direct_non_streaming": stats_to_dict(direct),
|
||||
"proxy_non_streaming": stats_to_dict(proxy),
|
||||
"proxy_streaming_ttft": stats_to_dict(stream),
|
||||
"proxy_streaming_full": (
|
||||
stats_to_dict(stream_full) if stream_full is not None else None
|
||||
),
|
||||
"client_observed_overhead_p50_ms": proxy.p50_ms - direct.p50_ms,
|
||||
"client_observed_overhead_p95_ms": proxy.p95_ms - direct.p95_ms,
|
||||
"proxy_log_path": str(proxy_log_path),
|
||||
}
|
||||
Path(args.output_json).write_text(
|
||||
json.dumps(output, indent=2, sort_keys=True), encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
asyncio.run(async_main())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -104,3 +104,82 @@ class TestUnmappedModelBudgetEnforcement:
|
|||
assert (
|
||||
result is True
|
||||
), "Model with explicit cost=0 in litellm_params should bypass budget"
|
||||
|
||||
def test_cache_invalidates_on_in_place_pricing_update(self):
|
||||
"""
|
||||
Regression test for the stale-cache bug surfaced in PR review:
|
||||
upgrading an explicitly free deployment to paid via ``upsert_deployment``
|
||||
(same deployment count, same router instance) must invalidate the
|
||||
cached ``_is_model_cost_zero=True`` answer so budget checks resume
|
||||
immediately — not after the next proxy restart.
|
||||
"""
|
||||
from litellm.types.router import Deployment, LiteLLM_Params, ModelInfo
|
||||
|
||||
router = Router(
|
||||
model_list=[
|
||||
{
|
||||
"model_name": "ramping-model",
|
||||
"litellm_params": {
|
||||
"model": "openai/ramping-deploy",
|
||||
"api_key": "sk-fake",
|
||||
"input_cost_per_token": 0.0,
|
||||
"output_cost_per_token": 0.0,
|
||||
},
|
||||
"model_info": {
|
||||
"id": "ramping-deploy-id",
|
||||
"input_cost_per_token": 0.0,
|
||||
"output_cost_per_token": 0.0,
|
||||
},
|
||||
},
|
||||
]
|
||||
)
|
||||
# Warm the cache as zero-cost.
|
||||
assert _is_model_cost_zero(model="ramping-model", llm_router=router) is True
|
||||
assert router._zero_cost_cache.get("ramping-model") is True
|
||||
|
||||
# In-place pricing update: same deployment count, same router id,
|
||||
# same model name. The pre-fix cache key was
|
||||
# ``(id(router), len(model_list), model_name)`` and would not change.
|
||||
router.upsert_deployment(
|
||||
deployment=Deployment(
|
||||
model_name="ramping-model",
|
||||
litellm_params=LiteLLM_Params(
|
||||
model="openai/ramping-deploy",
|
||||
api_key="sk-fake",
|
||||
input_cost_per_token=0.000002,
|
||||
output_cost_per_token=0.000008,
|
||||
),
|
||||
model_info=ModelInfo(
|
||||
id="ramping-deploy-id",
|
||||
input_cost_per_token=0.000002,
|
||||
output_cost_per_token=0.000008,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
# Cache must have been cleared by ``_invalidate_model_group_info_cache``.
|
||||
assert router._zero_cost_cache == {}
|
||||
# Subsequent call sees the new pricing and enforces budget.
|
||||
assert _is_model_cost_zero(model="ramping-model", llm_router=router) is False
|
||||
|
||||
def test_handles_router_without_zero_cost_cache_attribute(self):
|
||||
"""Tolerate router-like objects (e.g. ``MagicMock`` stand-ins) that
|
||||
do not expose ``_zero_cost_cache`` — the auth check must still
|
||||
compute a correct answer, just without caching."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from litellm.types.router import ModelGroupInfo
|
||||
|
||||
mock_router = MagicMock(spec=Router)
|
||||
mock_router.model_list = []
|
||||
mock_router.get_model_group_info.return_value = ModelGroupInfo(
|
||||
model_group="paid-model",
|
||||
providers=["openai"],
|
||||
input_cost_per_token=0.001,
|
||||
output_cost_per_token=0.002,
|
||||
)
|
||||
# Strip the attribute so the helper falls back to the no-cache path.
|
||||
del mock_router._zero_cost_cache
|
||||
|
||||
result = _is_model_cost_zero(model="paid-model", llm_router=mock_router)
|
||||
assert result is False
|
||||
|
|
|
|||
128
tests/test_litellm/proxy/test_proxy_logging_hook_detection.py
Normal file
128
tests/test_litellm/proxy/test_proxy_logging_hook_detection.py
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import pytest
|
||||
|
||||
import litellm
|
||||
from litellm.integrations.custom_guardrail import CustomGuardrail
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.proxy.utils import ProxyLogging
|
||||
|
||||
|
||||
def test_has_post_call_response_headers_callbacks_ignores_empty_callbacks(
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setattr(litellm, "callbacks", [])
|
||||
|
||||
assert ProxyLogging.has_post_call_response_headers_callbacks() is False
|
||||
|
||||
|
||||
def test_has_post_call_response_headers_callbacks_requires_override(
|
||||
monkeypatch,
|
||||
):
|
||||
"""A vanilla ``CustomLogger`` inherits the no-op response-headers hook;
|
||||
the capability flag must stay False so the proxy can skip the headers
|
||||
loop entirely. Only callbacks that *override* the hook should flip it."""
|
||||
monkeypatch.setattr(litellm, "callbacks", [CustomLogger()])
|
||||
assert ProxyLogging.has_post_call_response_headers_callbacks() is False
|
||||
|
||||
class _AddsHeaders(CustomLogger):
|
||||
async def async_post_call_response_headers_hook(self, **kwargs):
|
||||
return {"x-custom": "1"}
|
||||
|
||||
monkeypatch.setattr(litellm, "callbacks", [_AddsHeaders()])
|
||||
assert ProxyLogging.has_post_call_response_headers_callbacks() is True
|
||||
|
||||
|
||||
def test_has_streaming_callbacks_uses_custom_logger_detection(monkeypatch):
|
||||
monkeypatch.setattr(litellm, "callbacks", [])
|
||||
assert ProxyLogging.has_streaming_callbacks() is False
|
||||
|
||||
monkeypatch.setattr(litellm, "callbacks", [CustomLogger()])
|
||||
assert ProxyLogging.has_streaming_callbacks() is False
|
||||
|
||||
class StreamingLogger(CustomLogger):
|
||||
async def async_post_call_streaming_hook(self, **kwargs):
|
||||
return kwargs.get("response")
|
||||
|
||||
monkeypatch.setattr(litellm, "callbacks", [StreamingLogger()])
|
||||
assert ProxyLogging.has_streaming_callbacks() is True
|
||||
|
||||
|
||||
def test_has_streaming_callbacks_detects_guardrails(monkeypatch):
|
||||
monkeypatch.setattr(litellm, "callbacks", [CustomGuardrail()])
|
||||
assert ProxyLogging.has_streaming_callbacks() is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_post_call_response_headers_hook_returns_early_without_callbacks(
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setattr(litellm, "callbacks", [])
|
||||
proxy_logging_obj = ProxyLogging(user_api_key_cache={}) # type: ignore[arg-type]
|
||||
|
||||
result = await proxy_logging_obj.post_call_response_headers_hook(
|
||||
data={},
|
||||
user_api_key_dict=None, # type: ignore[arg-type]
|
||||
response=None,
|
||||
request_headers={},
|
||||
)
|
||||
|
||||
assert result == {}
|
||||
|
||||
|
||||
def test_callback_capabilities_skips_default_custom_logger(monkeypatch):
|
||||
"""
|
||||
Internal proxy hooks (e.g. _PROXY_MaxBudgetLimiter, ManagedFiles) inherit
|
||||
the default ``async_post_call_streaming_iterator_hook`` body. The
|
||||
capability scanner must NOT report them as iterator overrides — wrapping
|
||||
the chunk stream through every no-op layer was responsible for ~10x
|
||||
streaming overhead on default deployments.
|
||||
"""
|
||||
|
||||
class _InternalNoopHook(CustomLogger):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(litellm, "callbacks", [_InternalNoopHook()])
|
||||
|
||||
caps = ProxyLogging._callback_capabilities()
|
||||
# Subclass inherits the base no-op for every hook — every capability flag
|
||||
# must stay False so the proxy short-circuits the corresponding loops.
|
||||
assert caps.has_post_call_response_headers is False
|
||||
assert caps.iterator_overrides == ()
|
||||
assert caps.has_iterator_override is False
|
||||
assert caps.has_streaming_chunk_override is False
|
||||
assert caps.has_guardrail is False
|
||||
|
||||
|
||||
def test_callback_capabilities_captures_iterator_override(monkeypatch):
|
||||
class _OverridesIterator(CustomLogger):
|
||||
async def async_post_call_streaming_iterator_hook( # type: ignore[override]
|
||||
self, user_api_key_dict, response, request_data
|
||||
):
|
||||
async for item in response:
|
||||
yield item
|
||||
|
||||
override = _OverridesIterator()
|
||||
monkeypatch.setattr(litellm, "callbacks", [override])
|
||||
|
||||
caps = ProxyLogging._callback_capabilities()
|
||||
assert caps.has_iterator_override is True
|
||||
assert len(caps.iterator_overrides) == 1
|
||||
resolved, kind = caps.iterator_overrides[0]
|
||||
assert resolved is override
|
||||
assert kind == "override"
|
||||
|
||||
|
||||
def test_callback_capabilities_cache_invalidates_on_list_change(monkeypatch):
|
||||
"""The cache key includes (length, id-of-each-callback). Mutating the
|
||||
callback list must produce a fresh capability snapshot."""
|
||||
monkeypatch.setattr(litellm, "callbacks", [])
|
||||
assert ProxyLogging._callback_capabilities().resolved_callbacks == ()
|
||||
|
||||
class _OverridesPreCall(CustomLogger):
|
||||
async def async_pre_call_hook(self, *args, **kwargs):
|
||||
return kwargs.get("data")
|
||||
|
||||
pre = _OverridesPreCall()
|
||||
monkeypatch.setattr(litellm, "callbacks", [pre])
|
||||
caps = ProxyLogging._callback_capabilities()
|
||||
assert caps.has_pre_call_override is True
|
||||
assert pre in caps.resolved_callbacks
|
||||
|
|
@ -4515,6 +4515,69 @@ async def test_async_data_generator_cleanup_on_early_exit():
|
|||
mock_response.aclose.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_data_generator_uses_direct_stream_fast_path_without_callbacks():
|
||||
"""
|
||||
When there are no streaming callbacks, async_data_generator should avoid
|
||||
per-chunk hook machinery and iterate the provider stream directly.
|
||||
"""
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.proxy.proxy_server import async_data_generator
|
||||
from litellm.proxy.utils import ProxyLogging
|
||||
|
||||
mock_user_api_key_dict = MagicMock(spec=UserAPIKeyAuth)
|
||||
mock_request_data = {
|
||||
"model": "gpt-3.5-turbo",
|
||||
"messages": [{"role": "user", "content": "test"}],
|
||||
}
|
||||
mock_chunks = [
|
||||
{"choices": [{"delta": {"content": "Hello"}}]},
|
||||
{"choices": [{"delta": {"content": " world"}}]},
|
||||
]
|
||||
|
||||
class MockStream:
|
||||
def __aiter__(self):
|
||||
return self._stream()
|
||||
|
||||
async def _stream(self):
|
||||
for chunk in mock_chunks:
|
||||
yield chunk
|
||||
|
||||
async def aclose(self):
|
||||
pass
|
||||
|
||||
mock_response = MockStream()
|
||||
mock_response.aclose = AsyncMock()
|
||||
mock_proxy_logging_obj = MagicMock(spec=ProxyLogging)
|
||||
mock_proxy_logging_obj.has_streaming_callbacks.return_value = False
|
||||
mock_proxy_logging_obj.needs_iterator_wrap.return_value = False
|
||||
mock_proxy_logging_obj.needs_per_chunk_streaming_hook.return_value = False
|
||||
mock_proxy_logging_obj.async_post_call_streaming_iterator_hook = MagicMock()
|
||||
mock_proxy_logging_obj.async_post_call_streaming_hook = AsyncMock()
|
||||
mock_proxy_logging_obj.post_call_failure_hook = AsyncMock()
|
||||
|
||||
with patch("litellm.proxy.proxy_server.proxy_logging_obj", mock_proxy_logging_obj):
|
||||
with patch.object(
|
||||
ProxyLogging, "_fire_deferred_stream_logging"
|
||||
) as mock_deferred_logging:
|
||||
yielded_data = []
|
||||
async for data in async_data_generator(
|
||||
mock_response, mock_user_api_key_dict, mock_request_data
|
||||
):
|
||||
yielded_data.append(data)
|
||||
|
||||
yielded_text = [
|
||||
chunk.decode("utf-8") if isinstance(chunk, bytes) else chunk
|
||||
for chunk in yielded_data
|
||||
]
|
||||
assert len([chunk for chunk in yielded_text if chunk.startswith("data: {")]) == 2
|
||||
assert yielded_text[-1] == "data: [DONE]\n\n"
|
||||
mock_proxy_logging_obj.async_post_call_streaming_iterator_hook.assert_not_called()
|
||||
mock_proxy_logging_obj.async_post_call_streaming_hook.assert_not_awaited()
|
||||
mock_deferred_logging.assert_called_once_with(mock_request_data)
|
||||
mock_response.aclose.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_data_generator_cleanup_on_normal_completion():
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -66,6 +66,69 @@ def _make_model_response_stream_chunk(model: str) -> litellm.ModelResponseStream
|
|||
return litellm.ModelResponseStream(**chunk_dict)
|
||||
|
||||
|
||||
def _decode_sse_chunk(chunk) -> str:
|
||||
return chunk.decode("utf-8") if isinstance(chunk, bytes) else chunk
|
||||
|
||||
|
||||
def test_restamp_streaming_chunk_skips_matching_model():
|
||||
from litellm.proxy.proxy_server import _restamp_streaming_chunk_model
|
||||
|
||||
chunk = _make_model_response_stream_chunk("client-model")
|
||||
|
||||
result, model_mismatch_logged = _restamp_streaming_chunk_model(
|
||||
chunk=chunk,
|
||||
requested_model_from_client="client-model",
|
||||
request_data={"litellm_call_id": "test-call-id"},
|
||||
model_mismatch_logged=False,
|
||||
)
|
||||
|
||||
assert result is chunk
|
||||
assert result.model == "client-model"
|
||||
assert model_mismatch_logged is False
|
||||
|
||||
|
||||
def test_fast_serialize_simple_streaming_chunk_matches_model_dump_json():
|
||||
from litellm.proxy.proxy_server import _serialize_streaming_chunk
|
||||
|
||||
chunk = _make_model_response_stream_chunk("client-model")
|
||||
|
||||
assert json.loads(_serialize_streaming_chunk(chunk)) == json.loads(
|
||||
chunk.model_dump_json(exclude_none=True, exclude_unset=True)
|
||||
)
|
||||
|
||||
|
||||
def test_fast_serialize_returns_none_when_model_field_is_missing():
|
||||
"""
|
||||
The fast path must mirror ``model_dump_json(exclude_none=True)``: when
|
||||
``chunk.model`` is ``None`` the slow path omits the field entirely.
|
||||
Emitting ``"model": null`` would diverge and trip strict OpenAI-
|
||||
compatible clients that reject ``null`` for optional string fields.
|
||||
Falling back to ``None`` lets the canonical serializer handle the edge.
|
||||
"""
|
||||
from litellm.proxy.proxy_server import (
|
||||
_fast_serialize_simple_model_response_stream,
|
||||
_serialize_streaming_chunk,
|
||||
)
|
||||
|
||||
chunk = _make_model_response_stream_chunk("client-model")
|
||||
chunk.model = None # type: ignore[assignment]
|
||||
|
||||
assert _fast_serialize_simple_model_response_stream(chunk) is None
|
||||
|
||||
# Going through the public ``_serialize_streaming_chunk`` should still
|
||||
# produce a serialized result via the slow-path fallback, and it must
|
||||
# not contain ``"model": null``.
|
||||
serialized = _serialize_streaming_chunk(chunk)
|
||||
payload_str = (
|
||||
serialized.decode("utf-8") if isinstance(serialized, bytes) else serialized
|
||||
)
|
||||
assert '"model": null' not in payload_str
|
||||
assert '"model":null' not in payload_str
|
||||
assert json.loads(payload_str) == json.loads(
|
||||
chunk.model_dump_json(exclude_none=True, exclude_unset=True)
|
||||
)
|
||||
|
||||
|
||||
def test_proxy_chat_completion_does_not_return_provider_prefixed_model(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
|
|
@ -164,6 +227,21 @@ async def test_proxy_streaming_chunks_do_not_return_provider_prefixed_model(
|
|||
"async_post_call_streaming_hook",
|
||||
AsyncMock(side_effect=lambda **kwargs: kwargs["response"]),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
proxy_server.proxy_logging_obj,
|
||||
"has_streaming_callbacks",
|
||||
MagicMock(return_value=True),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
proxy_server.proxy_logging_obj,
|
||||
"needs_iterator_wrap",
|
||||
MagicMock(return_value=True),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
proxy_server.proxy_logging_obj,
|
||||
"needs_per_chunk_streaming_hook",
|
||||
MagicMock(return_value=True),
|
||||
)
|
||||
|
||||
user_api_key_dict = UserAPIKeyAuth(api_key="sk-1234")
|
||||
|
||||
|
|
@ -179,7 +257,7 @@ async def test_proxy_streaming_chunks_do_not_return_provider_prefixed_model(
|
|||
|
||||
# First chunk is expected to be JSON, last chunk is [DONE]
|
||||
assert len(chunks) >= 2
|
||||
first = chunks[0]
|
||||
first = _decode_sse_chunk(chunks[0])
|
||||
assert first.startswith("data: ")
|
||||
|
||||
payload = json.loads(first[len("data: ") :].strip())
|
||||
|
|
@ -222,6 +300,21 @@ async def test_proxy_streaming_chunks_use_client_requested_model_before_alias_ma
|
|||
"async_post_call_streaming_hook",
|
||||
AsyncMock(side_effect=lambda **kwargs: kwargs["response"]),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
proxy_server.proxy_logging_obj,
|
||||
"has_streaming_callbacks",
|
||||
MagicMock(return_value=True),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
proxy_server.proxy_logging_obj,
|
||||
"needs_iterator_wrap",
|
||||
MagicMock(return_value=True),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
proxy_server.proxy_logging_obj,
|
||||
"needs_per_chunk_streaming_hook",
|
||||
MagicMock(return_value=True),
|
||||
)
|
||||
|
||||
user_api_key_dict = UserAPIKeyAuth(api_key="sk-1234")
|
||||
|
||||
|
|
@ -239,7 +332,7 @@ async def test_proxy_streaming_chunks_use_client_requested_model_before_alias_ma
|
|||
chunks.append(item)
|
||||
|
||||
assert len(chunks) >= 2
|
||||
first = chunks[0]
|
||||
first = _decode_sse_chunk(chunks[0])
|
||||
assert first.startswith("data: ")
|
||||
|
||||
payload = json.loads(first[len("data: ") :].strip())
|
||||
|
|
@ -279,6 +372,21 @@ async def test_proxy_streaming_azure_model_router_preserves_actual_model(monkeyp
|
|||
"async_post_call_streaming_hook",
|
||||
AsyncMock(side_effect=lambda **kwargs: kwargs["response"]),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
proxy_server.proxy_logging_obj,
|
||||
"has_streaming_callbacks",
|
||||
MagicMock(return_value=True),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
proxy_server.proxy_logging_obj,
|
||||
"needs_iterator_wrap",
|
||||
MagicMock(return_value=True),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
proxy_server.proxy_logging_obj,
|
||||
"needs_per_chunk_streaming_hook",
|
||||
MagicMock(return_value=True),
|
||||
)
|
||||
|
||||
user_api_key_dict = UserAPIKeyAuth(api_key="sk-1234")
|
||||
|
||||
|
|
@ -296,7 +404,7 @@ async def test_proxy_streaming_azure_model_router_preserves_actual_model(monkeyp
|
|||
chunks.append(item)
|
||||
|
||||
assert len(chunks) >= 2
|
||||
first = chunks[0]
|
||||
first = _decode_sse_chunk(chunks[0])
|
||||
assert first.startswith("data: ")
|
||||
|
||||
payload = json.loads(first[len("data: ") :].strip())
|
||||
|
|
@ -337,6 +445,21 @@ async def test_proxy_streaming_fastest_response_preserves_winning_model(monkeypa
|
|||
"async_post_call_streaming_hook",
|
||||
AsyncMock(side_effect=lambda **kwargs: kwargs["response"]),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
proxy_server.proxy_logging_obj,
|
||||
"has_streaming_callbacks",
|
||||
MagicMock(return_value=True),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
proxy_server.proxy_logging_obj,
|
||||
"needs_iterator_wrap",
|
||||
MagicMock(return_value=True),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
proxy_server.proxy_logging_obj,
|
||||
"needs_per_chunk_streaming_hook",
|
||||
MagicMock(return_value=True),
|
||||
)
|
||||
|
||||
user_api_key_dict = UserAPIKeyAuth(api_key="sk-1234")
|
||||
|
||||
|
|
@ -355,7 +478,7 @@ async def test_proxy_streaming_fastest_response_preserves_winning_model(monkeypa
|
|||
chunks.append(item)
|
||||
|
||||
assert len(chunks) >= 2
|
||||
first = chunks[0]
|
||||
first = _decode_sse_chunk(chunks[0])
|
||||
assert first.startswith("data: ")
|
||||
|
||||
payload = json.loads(first[len("data: ") :].strip())
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue