From c6b62515af78f0fd5ca5259341576f39969ce82e Mon Sep 17 00:00:00 2001 From: Yucheng He Date: Tue, 15 Sep 2026 15:27:48 -0700 Subject: [PATCH] fix(spend_tracking): carry the request path in collector events that omit bodies The collector event dropped proxy_server_request whenever spend logs do not store bodies, so the sidecar could not tell a file or response read from a minted response and re-keyed a second read into its own row. The event now always carries the request's method and path, and nothing else of the request: no host, query, headers or body. --- litellm/proxy/spend_tracking/spend_event.py | 22 +++++++++++++++++-- .../proxy/spend_tracking/test_spend_event.py | 11 ++++++++-- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/litellm/proxy/spend_tracking/spend_event.py b/litellm/proxy/spend_tracking/spend_event.py index 53f26346f85..177258532ab 100644 --- a/litellm/proxy/spend_tracking/spend_event.py +++ b/litellm/proxy/spend_tracking/spend_event.py @@ -6,7 +6,7 @@ callback's ``kwargs`` into the projection ``_PROXY_track_cost_callback`` and ``DBSpendUpdateWriter.update_database`` actually read: identities and metadata, timings, usage, the standard logging payload without its prompt/response bodies, and the tool names. The request messages, the raw ``proxy_server_request`` body and the full response travel only when spend logs -are configured to store prompts and responses. The cache key is the preset key the caching layer +are configured to store prompts and responses; the request's method and path always travel. The cache key is the preset key the caching layer already computed, never a fresh hash over the request body. ``spend_event_callback_args`` rebuilds the ``(kwargs, response_obj, start_time, end_time)`` tuple @@ -18,6 +18,7 @@ from dataclasses import dataclass from datetime import datetime from types import MappingProxyType from typing import Final, Literal, TypeAlias +from urllib.parse import urlsplit from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError from typing_extensions import NotRequired, ReadOnly, TypedDict @@ -247,6 +248,19 @@ def _metadata_for_event( return MappingProxyType({**kept, "user_api_key_budget_reservation": budget_reservation}) +def _request_route_for_event(proxy_server_request: object) -> ObjectMapping | None: + """The request's method and path alone: what the spend row needs to tell a read of a stored + object (its id in the path) from a response minted for this call, with no host, query, + headers or body.""" + request: Final = _mapping_or_none(proxy_server_request) + if request is None: + return None + url: Final = request.get("url") + if not isinstance(url, str): + return None + return MappingProxyType({"url": urlsplit(url).path, "method": request.get("method")}) + + def _litellm_params_for_event( litellm_params: _LitellmParams, cache_key: str | None, store_bodies: bool ) -> _LitellmParams: @@ -267,7 +281,11 @@ def _litellm_params_for_event( "user_api_key_end_user_id": litellm_params.get("user_api_key_end_user_id"), "metadata": _metadata_for_event(metadata, budget_reservation), "litellm_metadata": _metadata_for_event(litellm_metadata, budget_reservation), - "proxy_server_request": litellm_params.get("proxy_server_request") if store_bodies else None, + "proxy_server_request": ( + litellm_params.get("proxy_server_request") + if store_bodies + else _request_route_for_event(litellm_params.get("proxy_server_request")) + ), "preset_cache_key": cache_key, } return projected diff --git a/tests/test_litellm/proxy/spend_tracking/test_spend_event.py b/tests/test_litellm/proxy/spend_tracking/test_spend_event.py index ff449235582..b7e38ad9854 100644 --- a/tests/test_litellm/proxy/spend_tracking/test_spend_event.py +++ b/tests/test_litellm/proxy/spend_tracking/test_spend_event.py @@ -63,7 +63,12 @@ def _success_kwargs(preset_cache_key: str | None = "preset-key") -> dict: "litellm_params": { "api_base": "https://api.openai.com", "preset_cache_key": preset_cache_key, - "proxy_server_request": {"body": {"messages": [{"role": "user", "content": _BIG_PROMPT}]}}, + "proxy_server_request": { + "url": "http://litellm:4000/v1/chat/completions?api-version=2026-01-01", + "method": "POST", + "headers": {"authorization": "Bearer sk-secret"}, + "body": {"messages": [{"role": "user", "content": _BIG_PROMPT}]}, + }, "metadata": { "user_api_key": "hash-1", "user_api_key_user_id": "user-1", @@ -110,7 +115,9 @@ def test_event_is_compact_and_omits_bodies_by_default(): decoded: Final = json.loads(line) assert "messages" not in decoded["standard_logging_object"] assert "response" not in decoded["standard_logging_object"] - assert decoded["litellm_params"]["proxy_server_request"] is None + assert decoded["litellm_params"]["proxy_server_request"] == {"url": "/v1/chat/completions", "method": "POST"} + assert b"sk-secret" not in line + assert b"api-version" not in line def test_event_carries_bodies_when_spend_logs_store_them():