fix(router): average every latency sample and drop the cost handler's dead division

_average_latency skipped integer samples in the sum while counting them in the denominator, which contradicted its own
Sequence[float | int] signature; it now averages every sample. Both success loggers in cost-based routing computed
response_ms / completion_tokens and threw the result away, so a chat response with zero completion tokens raised
ZeroDivisionError inside the handler. The proxy swallows and logs it, but the handler then skips that request's tpm and
rpm update, so cost-based routing undercounts the deployment's usage. The QA run for the latency fix hit it on real
gpt-5.5 traffic through /v1/chat/completions and /v1/messages
This commit is contained in:
mateo-berri 2026-09-05 16:20:20 -07:00
parent c55248d113
commit be9a2ea7c9
4 changed files with 62 additions and 14 deletions

View file

@ -1,6 +1,6 @@
#### What this does ####
# picks based on response time (for streaming, this is time to first token)
from datetime import datetime, timedelta
from datetime import datetime
from typing import Final
import litellm
@ -52,16 +52,12 @@ class LowestCostLoggingHandler(CustomLogger):
precise_minute: Final = f"{current_date}-{current_hour}-{current_minute}"
cost_key: Final = f"{model_group}_map"
response_ms: Final[timedelta] = end_time - start_time
total_tokens = 0
if isinstance(response_obj, ModelResponse):
_usage: Final = getattr(response_obj, "usage", None)
if _usage is not None and isinstance(_usage, litellm.Usage):
completion_tokens: Final = _usage.completion_tokens
total_tokens = _usage.total_tokens
float(response_ms.total_seconds() / completion_tokens)
# ------------
# Update usage
@ -131,18 +127,13 @@ class LowestCostLoggingHandler(CustomLogger):
current_minute: Final = datetime.now().strftime("%M")
precise_minute: Final = f"{current_date}-{current_hour}-{current_minute}"
response_ms: Final[timedelta] = end_time - start_time
total_tokens = 0
if isinstance(response_obj, ModelResponse):
_usage: Final = getattr(response_obj, "usage", None)
if _usage is not None and isinstance(_usage, litellm.Usage):
completion_tokens: Final = _usage.completion_tokens
total_tokens = _usage.total_tokens
float(response_ms.total_seconds() / completion_tokens)
# ------------
# Update usage
# ------------

View file

@ -26,10 +26,10 @@ class RoutingArgs(LiteLLMPydanticObjectBase):
max_latency_list_size: int = 10
def _average_latency(samples: Sequence[float | int]) -> float:
def _average_latency(samples: Sequence[float]) -> float:
if not samples:
return 0.0
return sum(sample for sample in samples if isinstance(sample, float)) / len(samples)
return sum(samples) / len(samples)
class LowestLatencyLoggingHandler(CustomLogger):

View file

@ -0,0 +1,59 @@
from datetime import datetime
import pytest
import litellm
from litellm.caching.caching import DualCache
from litellm.router_strategy.lowest_cost import LowestCostLoggingHandler
DEPLOYMENT_ID = "9876"
KWARGS = {
"litellm_params": {
"metadata": {"model_group": "gpt-5.5-pool"},
"model_info": {"id": DEPLOYMENT_ID},
}
}
def _chat_response_with_no_completion_tokens() -> litellm.ModelResponse:
return litellm.ModelResponse(
model="gpt-5.5",
choices=[{"index": 0, "message": {"role": "assistant", "content": ""}, "finish_reason": "length"}],
usage=litellm.Usage(prompt_tokens=12, completion_tokens=0, total_tokens=12),
)
def _recorded_minute_counters(cache: DualCache) -> dict[str, int]:
cached = cache.get_cache(key="gpt-5.5-pool_map") or {}
minute_buckets = cached.get(DEPLOYMENT_ID, {})
assert len(minute_buckets) == 1, f"expected one minute bucket, got {minute_buckets}"
return next(iter(minute_buckets.values()))
def test_log_success_event_counts_a_response_with_no_completion_tokens():
cache = DualCache()
handler = LowestCostLoggingHandler(router_cache=cache)
handler.log_success_event(
kwargs=KWARGS,
response_obj=_chat_response_with_no_completion_tokens(),
start_time=datetime(2026, 1, 1, 12, 0, 0),
end_time=datetime(2026, 1, 1, 12, 0, 2),
)
assert _recorded_minute_counters(cache) == {"tpm": 12, "rpm": 1}
@pytest.mark.asyncio
async def test_async_log_success_event_counts_a_response_with_no_completion_tokens():
cache = DualCache()
handler = LowestCostLoggingHandler(router_cache=cache)
await handler.async_log_success_event(
kwargs=KWARGS,
response_obj=_chat_response_with_no_completion_tokens(),
start_time=datetime(2026, 1, 1, 12, 0, 0),
end_time=datetime(2026, 1, 1, 12, 0, 2),
)
assert _recorded_minute_counters(cache) == {"tpm": 12, "rpm": 1}

View file

@ -172,8 +172,6 @@ def test_sync_chat_zero_completion_tokens_falls_back_to_seconds():
ids=["empty_latency_list", "minute_bucket_only_as_cost_based_routing_writes"],
)
async def test_async_get_available_deployments_treats_missing_samples_as_zero_latency(cached_entry):
"""A cached entry with no latency samples (cost-based routing shares the group's map key and writes
minute buckets only) must count as 0 latency, like an unseen deployment, instead of dividing by zero."""
cache = DualCache()
handler = LowestLatencyLoggingHandler(router_cache=cache)
cache.set_cache(