From 4d51bebf962c4acda1b630f25d7587671b6b6f68 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Mon, 29 Jun 2026 12:24:09 +0530 Subject: [PATCH] fix: guardrail usage logs action-filter pagination and partial-stream empty-choices chunks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- litellm/proxy/common_request_processing.py | 11 ++++++ litellm/proxy/guardrails/usage_endpoints.py | 37 ++++++++++++++++++--- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/litellm/proxy/common_request_processing.py b/litellm/proxy/common_request_processing.py index ab16c0e9acc..fe75ad79872 100644 --- a/litellm/proxy/common_request_processing.py +++ b/litellm/proxy/common_request_processing.py @@ -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. diff --git a/litellm/proxy/guardrails/usage_endpoints.py b/litellm/proxy/guardrails/usage_endpoints.py index d93babce0b2..c8c1fb92e2d 100644 --- a/litellm/proxy/guardrails/usage_endpoints.py +++ b/litellm/proxy/guardrails/usage_endpoints.py @@ -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