fix: guardrail usage logs action-filter pagination and partial-stream empty-choices chunks

- usage_endpoints.py: when action filter is set, fetch all rows and filter in
  Python before slicing to page — action is derived from SpendLog JSON metadata,
  not an index column, so DB-level skip/take cannot be used when filtering by it.
  Unfiltered path retains efficient DB pagination unchanged.
- common_request_processing.py: strip trailing empty-choices chunks (e.g. OpenAI
  final SSE usage chunk) before stream_chunk_builder so it does not IndexError on
  an empty choices list when reading finish_reason from the last chunk.
This commit is contained in:
Sameer Kankute 2026-06-29 12:24:09 +05:30
parent 7fc09d6b7e
commit 4d51bebf96
No known key found for this signature in database
2 changed files with 44 additions and 4 deletions

View file

@ -2465,6 +2465,17 @@ class ProxyBaseLLMRequestProcessing:
return
elif not isinstance(first_chunk, str) and not hasattr(first_chunk, "choices"):
return
# Strip trailing usage-only / empty-choices chunks (e.g. OpenAI's final SSE
# `data: {"choices":[],"usage":{...}}`) so stream_chunk_builder can safely
# inspect the last chunk's finish_reason without an IndexError.
def _has_empty_choices(c: Any) -> bool:
if isinstance(c, dict):
return c.get("choices") == []
return bool(hasattr(c, "choices") and getattr(c, "choices") == [])
chunks = [c for c in chunks if not _has_empty_choices(c)]
if not chunks:
return
# Optimization, not a correctness guard: dispatch_success_handlers is the
# authoritative de-dup via has_dispatched_final_stream_success. This just
# skips the stream_chunk_builder assembly when completion already logged.

View file

@ -608,6 +608,35 @@ async def guardrails_usage_logs(
effective_guardrail_ids.append(logical_name)
where = _build_usage_logs_where(effective_guardrail_ids or None, policy_id, start_date, end_date)
if action:
# action is derived from SpendLog JSON metadata, not an index column, so we
# cannot push the filter to the DB. Fetch all matching index rows, join with
# SpendLogs, filter in Python, then slice to the requested page.
all_index_rows = await SpendLogGuardrailIndexRepository(prisma_client).table.find_many(
where=where,
order={"start_time": "desc"},
)
if not all_index_rows:
return UsageLogsResponse(logs=[], total=0, page=page, page_size=page_size)
all_request_ids = [r.request_id for r in all_index_rows]
spend_logs = await SpendLogsRepository(prisma_client).table.find_many(
where={"request_id": {"in": all_request_ids}}
)
log_by_id = {s.request_id: s for s in spend_logs}
all_entries: List[UsageLogEntry] = []
for r in all_index_rows:
sl = log_by_id.get(r.request_id)
if not sl:
continue
entry = _usage_log_entry_from_row(r, sl, action)
if entry is not None:
all_entries.append(entry)
total = len(all_entries)
start_idx = (page - 1) * page_size
logs_out = all_entries[start_idx : start_idx + page_size]
return UsageLogsResponse(logs=logs_out, total=total, page=page, page_size=page_size)
index_rows = await SpendLogGuardrailIndexRepository(prisma_client).table.find_many(
where=where,
order={"start_time": "desc"},
@ -620,15 +649,15 @@ async def guardrails_usage_logs(
return UsageLogsResponse(logs=[], total=total, page=page, page_size=page_size)
spend_logs = await SpendLogsRepository(prisma_client).table.find_many(where={"request_id": {"in": request_ids}})
log_by_id = {s.request_id: s for s in spend_logs}
logs_out: List[UsageLogEntry] = []
logs_out_unfiltered: List[UsageLogEntry] = []
for r in index_rows[:page_size]:
sl = log_by_id.get(r.request_id)
if not sl:
continue
entry = _usage_log_entry_from_row(r, sl, action)
entry = _usage_log_entry_from_row(r, sl, None)
if entry is not None:
logs_out.append(entry)
return UsageLogsResponse(logs=logs_out, total=total, page=page, page_size=page_size)
logs_out_unfiltered.append(entry)
return UsageLogsResponse(logs=logs_out_unfiltered, total=total, page=page, page_size=page_size)
except Exception as e:
from litellm.proxy.utils import handle_exception_on_proxy