From 8344a96381edb9213fa140d4c990c023868b595b Mon Sep 17 00:00:00 2001 From: Yucheng He Date: Tue, 15 Sep 2026 11:46:48 -0700 Subject: [PATCH] fix(spend_tracking): resolve metadata.response_id from the same sources as the row key A response without an id of its own is keyed on the id the standard logging payload resolved, so the provider id kept in metadata follows that source too, stopping short of the proxy's own call id, which is not a provider identity. --- .../spend_tracking/spend_tracking_utils.py | 22 +++++++- .../test_spend_tracking_utils.py | 56 ++++++++++++++----- 2 files changed, 62 insertions(+), 16 deletions(-) diff --git a/litellm/proxy/spend_tracking/spend_tracking_utils.py b/litellm/proxy/spend_tracking/spend_tracking_utils.py index 40039f640fc..6e5dcb0303d 100644 --- a/litellm/proxy/spend_tracking/spend_tracking_utils.py +++ b/litellm/proxy/spend_tracking/spend_tracking_utils.py @@ -230,6 +230,24 @@ def _get_spend_logs_metadata( BATCH_COST_REQUEST_ID_SUFFIX: Final = "_batch_cost" +def get_provider_response_id(response_obj: Mapping[str, object], kwargs: Mapping[str, object]) -> str | None: + """The id the provider minted for this response: the response's own, else the one the standard + logging payload resolved. Never the proxy's call id, which is not a provider identity.""" + standard_logging_payload: Final = kwargs.get("standard_logging_object") + candidate_ids: Final = ( + response_obj.get("id"), + standard_logging_payload.get("id") if isinstance(standard_logging_payload, dict) else None, + ) + return next( + ( + candidate + for candidate in candidate_ids + if isinstance(candidate, str) and candidate and candidate != kwargs.get("litellm_call_id") + ), + None, + ) + + def get_spend_logs_id(call_type: str, response_obj: dict, kwargs: dict) -> str | None: standard_logging_payload = kwargs.get("standard_logging_object") candidate_ids: Final = ( @@ -394,7 +412,7 @@ def get_logging_payload(kwargs, response_obj, start_time, end_time) -> SpendLogs usage = _combined_usage.model_dump() id = get_spend_logs_id(call_type or "acompletion", response_obj_dict, kwargs) - raw_response_id: Final = response_obj_dict.get("id") + provider_response_id: Final = get_provider_response_id(response_obj_dict, kwargs) standard_logging_payload: Final = cast(StandardLoggingPayload | None, kwargs.get("standard_logging_object", None)) end_user_id = get_end_user_id_for_cost_tracking(litellm_params) @@ -530,7 +548,7 @@ def get_logging_payload(kwargs, response_obj, start_time, end_time) -> SpendLogs standard_logging_payload.get("autorouter_savings", None) if standard_logging_payload is not None else None ), litellm_call_id=litellm_call_id, - response_id=raw_response_id if isinstance(raw_response_id, str) else None, + response_id=provider_response_id, router_metadata=_get_router_metadata_for_spend_log( metadata=metadata, requested_model=_model_group, diff --git a/tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py b/tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py index a0b6d4f6bc6..e3eb08792fe 100644 --- a/tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py +++ b/tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py @@ -79,10 +79,16 @@ def test_classifier_audit_spend_storage_obeys_privacy_and_truncation(monkeypatch "classifier_input": {"system": "rubric" * 1000, "messages": [{"role": "user", "content": "ask"}]}, "originating_request_masked": {"input": "source-only", "api_key": "REDACTED"}, } - stored: Final = json.loads(_get_proxy_server_request_for_spend_logs_payload( - metadata={}, litellm_params={"proxy_server_request": {"body": {"model": "classifier"}}}, - kwargs={"standard_logging_object": audit, "standard_callback_dynamic_params": {"turn_off_message_logging": redact}}, - )) + stored: Final = json.loads( + _get_proxy_server_request_for_spend_logs_payload( + metadata={}, + litellm_params={"proxy_server_request": {"body": {"model": "classifier"}}}, + kwargs={ + "standard_logging_object": audit, + "standard_callback_dynamic_params": {"turn_off_message_logging": redact}, + }, + ) + ) if not store_prompts or redact: assert "classifier_input" not in stored assert "originating_request_masked" not in stored @@ -208,9 +214,7 @@ def test_batch_lifecycle_rows_derive_the_same_session_from_the_batch_id(): from litellm.proxy.spend_tracking.spend_tracking_utils import _get_batch_trace_session_id create_session: Final = _get_batch_trace_session_id(call_type="acreate_batch", request_id="batch-uid-1") - cost_session: Final = _get_batch_trace_session_id( - call_type="aretrieve_batch", request_id="batch-uid-1_batch_cost" - ) + cost_session: Final = _get_batch_trace_session_id(call_type="aretrieve_batch", request_id="batch-uid-1_batch_cost") assert create_session == cost_session == "batch-uid-1" @@ -1393,6 +1397,34 @@ def test_get_logging_payload_leaves_metadata_response_id_empty_without_a_respons assert json.loads(payload["metadata"])["response_id"] is None +@pytest.mark.parametrize( + "standard_logging_id, expected", + [("provider-123", "provider-123"), ("call-1", None), (None, None)], +) +def test_get_logging_payload_falls_back_to_the_standard_logging_payload_response_id( + standard_logging_id: str | None, expected: str | None +): + """A response without an id of its own is keyed on the id the standard logging payload resolved, + so metadata.response_id follows the same source, except that the proxy's own call id, which + that payload falls back to, is not a provider identity.""" + standard_logging_payload = _make_standard_logging_payload_with_usage_object({}) + standard_logging_payload["id"] = standard_logging_id + payload = get_logging_payload( + kwargs={ + "model": "gpt-4o-mini", + "litellm_call_id": "call-1", + "litellm_params": {"metadata": {"user_api_key": "test-key"}}, + "standard_logging_object": standard_logging_payload, + }, + response_obj={"choices": []}, + start_time=datetime.datetime.now(timezone.utc), + end_time=datetime.datetime.now(timezone.utc), + ) + + assert json.loads(payload["metadata"])["response_id"] == expected + assert payload["request_id"] == (standard_logging_id or "call-1") + + @patch("litellm.proxy.proxy_server.master_key", None) @patch("litellm.proxy.proxy_server.general_settings", {}) def test_get_logging_payload_includes_overhead_in_spend_logs_metadata(): @@ -4497,7 +4529,7 @@ ANTHROPIC_MESSAGES_SSE_CHUNKS: Final = ( 'event: content_block_stop\ndata: {"type":"content_block_stop","index":0}\n\n', 'event: message_delta\ndata: {"type":"message_delta","delta":{"stop_reason":"end_turn"},' '"usage":{"output_tokens":4}}\n\n', - "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n", + 'event: message_stop\ndata: {"type":"message_stop"}\n\n', ) @@ -4535,9 +4567,7 @@ def test_spend_log_request_id_is_the_message_id_a_non_streaming_messages_caller_ """ logging_obj = _anthropic_messages_logging_obj(stream=False) - logged_response = logging_obj._handle_anthropic_messages_response_logging( - result=ANTHROPIC_MESSAGES_RESPONSE - ) + logged_response = logging_obj._handle_anthropic_messages_response_logging(result=ANTHROPIC_MESSAGES_RESPONSE) assert logged_response.id == "msg_01Lit6806NonStreaming" assert ( @@ -4613,9 +4643,7 @@ def test_spend_log_request_id_still_falls_back_to_litellm_call_id_without_a_prov end_time=datetime.datetime.now(timezone.utc), logging_obj=logging_obj, ) - assert logging_obj.model_call_details["complete_streaming_response"].id == ( - "6806cafe-0000-4000-8000-000000000001" - ) + assert logging_obj.model_call_details["complete_streaming_response"].id == ("6806cafe-0000-4000-8000-000000000001") def test_spend_log_request_id_for_chat_completions_is_untouched():