From cde463b92d1b11cf417337810ca54f88d55e2a07 Mon Sep 17 00:00:00 2001 From: Darien Kindlund Date: Fri, 17 Apr 2026 10:28:50 -0400 Subject: [PATCH] fix(proxy): honor client-supplied x-litellm-call-id in spend log request_id When a client sends x-litellm-call-id explicitly, it expects to use that id to look up the resulting spend log record. Today, get_spend_logs_id prefers response_obj.id (the providers own response id) over litellm_call_id, so the client-supplied id is silently discarded. The spend record exists, but with an unrelated provider id as request_id, making /spend/logs/v2?request_id=my-uuid return empty. Track whether the call_id came from the client header (vs. an auto-generated UUID), and when it did, prefer it over response.id in the spend log id resolution. Auto-generated call_ids continue to fall back to response.id, preserving existing behavior for callers who do not supply the header. Co-Authored-By: Claude Opus 4.7 (1M context) --- litellm/proxy/common_request_processing.py | 9 ++- litellm/proxy/google_endpoints/endpoints.py | 18 +++-- .../spend_tracking/spend_tracking_utils.py | 6 ++ .../test_spend_tracking_utils.py | 79 +++++++++++++++++++ 4 files changed, 103 insertions(+), 9 deletions(-) diff --git a/litellm/proxy/common_request_processing.py b/litellm/proxy/common_request_processing.py index c4717ad9cf3..96fb34c11b8 100644 --- a/litellm/proxy/common_request_processing.py +++ b/litellm/proxy/common_request_processing.py @@ -783,9 +783,12 @@ class ProxyBaseLLMRequestProcessing: ): self.data["model"] = user_api_key_dict.aliases[self.data["model"]] - self.data["litellm_call_id"] = request.headers.get( - "x-litellm-call-id", str(uuid.uuid4()) - ) + client_supplied_call_id = request.headers.get("x-litellm-call-id") + if client_supplied_call_id: + self.data["litellm_call_id"] = client_supplied_call_id + self.data["litellm_call_id_from_client"] = True + else: + self.data["litellm_call_id"] = str(uuid.uuid4()) DDSpanTagger.tag_call_id(self.data.get("litellm_call_id")) DDSpanTagger.tag_request( user_api_key_dict=user_api_key_dict, diff --git a/litellm/proxy/google_endpoints/endpoints.py b/litellm/proxy/google_endpoints/endpoints.py index 9768d93e922..bf74fcacbf7 100644 --- a/litellm/proxy/google_endpoints/endpoints.py +++ b/litellm/proxy/google_endpoints/endpoints.py @@ -58,9 +58,12 @@ async def google_generate_content( ) # Create logging object with full request metadata so callbacks (e.g. S3) get user/trace_id - data["litellm_call_id"] = request.headers.get( - "x-litellm-call-id", str(uuid.uuid4()) - ) + client_supplied_call_id = request.headers.get("x-litellm-call-id") + if client_supplied_call_id: + data["litellm_call_id"] = client_supplied_call_id + data["litellm_call_id_from_client"] = True + else: + data["litellm_call_id"] = str(uuid.uuid4()) logging_obj, data = litellm.utils.function_setup( original_function="agenerate_content", rules_obj=litellm.utils.Rules(), @@ -121,9 +124,12 @@ async def google_stream_generate_content( ) # Create logging object with full request metadata so streaming END callbacks (e.g. S3) get user/trace_id - data["litellm_call_id"] = request.headers.get( - "x-litellm-call-id", str(uuid.uuid4()) - ) + client_supplied_call_id = request.headers.get("x-litellm-call-id") + if client_supplied_call_id: + data["litellm_call_id"] = client_supplied_call_id + data["litellm_call_id_from_client"] = True + else: + data["litellm_call_id"] = str(uuid.uuid4()) logging_obj, data = litellm.utils.function_setup( original_function="agenerate_content_stream", rules_obj=litellm.utils.Rules(), diff --git a/litellm/proxy/spend_tracking/spend_tracking_utils.py b/litellm/proxy/spend_tracking/spend_tracking_utils.py index 8a963ec0134..011f72f2bb3 100644 --- a/litellm/proxy/spend_tracking/spend_tracking_utils.py +++ b/litellm/proxy/spend_tracking/spend_tracking_utils.py @@ -169,6 +169,12 @@ def get_spend_logs_id( if call_type == "aretrieve_batch" or call_type == "acreate_file": # Generate a hash from the response object id: Optional[str] = generate_hash_from_response(response_obj) + elif kwargs.get("litellm_call_id_from_client"): + # Client explicitly supplied x-litellm-call-id; honor it so callers + # can correlate spend log records with their own request id. + id = cast(Optional[str], kwargs.get("litellm_call_id")) or cast( + Optional[str], response_obj.get("id") + ) else: id = cast(Optional[str], response_obj.get("id")) or cast( Optional[str], kwargs.get("litellm_call_id") 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 30b952cd421..c099d54ca60 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 @@ -1469,3 +1469,82 @@ class TestIsMasterKey: master = "sk-master-key-123" hashed = hash_token(master) assert _is_master_key(api_key=hashed, _master_key=master) is True + + +class TestGetSpendLogsId: + """Tests for get_spend_logs_id precedence between response.id and litellm_call_id.""" + + def setup_method(self): + from litellm.proxy.spend_tracking.spend_tracking_utils import ( + get_spend_logs_id, + ) + + self.fn = get_spend_logs_id + + def test_default_prefers_response_id(self): + """Without the client-supplied flag, response.id wins (existing behavior).""" + result = self.fn( + call_type="acompletion", + response_obj={"id": "chatcmpl-provider-id"}, + kwargs={"litellm_call_id": "auto-generated-uuid"}, + ) + assert result == "chatcmpl-provider-id" + + def test_default_falls_back_to_litellm_call_id(self): + """Without the client-supplied flag, falls back to litellm_call_id when response.id missing.""" + result = self.fn( + call_type="acompletion", + response_obj={}, + kwargs={"litellm_call_id": "auto-generated-uuid"}, + ) + assert result == "auto-generated-uuid" + + def test_client_supplied_flag_prefers_litellm_call_id(self): + """With client-supplied flag, litellm_call_id wins over response.id.""" + result = self.fn( + call_type="acompletion", + response_obj={"id": "chatcmpl-provider-id"}, + kwargs={ + "litellm_call_id": "client-correlation-uuid", + "litellm_call_id_from_client": True, + }, + ) + assert result == "client-correlation-uuid" + + def test_client_supplied_flag_falls_back_to_response_id(self): + """With client-supplied flag but missing litellm_call_id, falls back to response.id.""" + result = self.fn( + call_type="acompletion", + response_obj={"id": "chatcmpl-provider-id"}, + kwargs={ + "litellm_call_id": None, + "litellm_call_id_from_client": True, + }, + ) + assert result == "chatcmpl-provider-id" + + def test_client_supplied_flag_false_uses_default_precedence(self): + """Explicit False on flag uses default (response.id wins).""" + result = self.fn( + call_type="acompletion", + response_obj={"id": "chatcmpl-provider-id"}, + kwargs={ + "litellm_call_id": "auto-uuid", + "litellm_call_id_from_client": False, + }, + ) + assert result == "chatcmpl-provider-id" + + def test_aretrieve_batch_unchanged(self): + """The batch hash path is independent of the flag.""" + result = self.fn( + call_type="aretrieve_batch", + response_obj={"id": "batch-id", "data": "x"}, + kwargs={ + "litellm_call_id": "client-uuid", + "litellm_call_id_from_client": True, + }, + ) + # Hash-based id, not the client uuid + assert result is not None + assert result != "client-uuid"