From 46d7e928459e1066ce464490de085738acdbd359 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 3 Sep 2026 00:26:18 -0700 Subject: [PATCH 1/5] fix(spend_tracking): key /v1/messages spend rows on the msg_ id the client received POST /v1/messages returns an Anthropic-shaped body whose `id` is the only request id the caller ever sees, but the spend row was written with a `chatcmpl-` (non-streaming) or the bare `litellm_call_id` (streaming and the /anthropic/v1/messages passthrough), so GET /spend/logs?request_id=msg_... returned []. The logging conversion now carries the provider's response id through: _handle_anthropic_messages_response_logging seeds the ModelResponse it builds with the Anthropic id, and the passthrough logging handler prefers the id it read off the response body or the message_start chunk over litellm_call_id. get_spend_logs_id already prefers response_obj["id"], so the spend row and standard_logging_object["id"] now both carry the id the client holds. --- litellm/litellm_core_utils/litellm_logging.py | 10 +- .../anthropic_passthrough_logging_handler.py | 27 ++- .../test_spend_tracking_utils.py | 161 ++++++++++++++++++ 3 files changed, 190 insertions(+), 8 deletions(-) diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index f54eeca5178..3e54febf36c 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -414,6 +414,11 @@ def _resolve_vertex_location_for_cost( return VertexBase.get_vertex_region(configured_location, model) +def _anthropic_response_id(source: object) -> str | None: + candidate: Final = source.get("id") if isinstance(source, dict) else getattr(source, "id", None) + return candidate if isinstance(candidate, str) and candidate else None + + class Logging(LiteLLMLoggingBaseClass): global \ supabaseClient, \ @@ -3832,11 +3837,12 @@ class Logging(LiteLLMLoggingBaseClass): if isinstance(result, ResponsesAPIResponse): return self._translate_responses_api_response_to_model_response(result) + anthropic_response_id: Final = _anthropic_response_id(result) httpx_response: Final = self.model_call_details.get("httpx_response", None) if httpx_response and isinstance(httpx_response, httpx.Response): result = litellm.AnthropicConfig().transform_response( raw_response=httpx_response, - model_response=litellm.ModelResponse(), + model_response=litellm.ModelResponse(id=anthropic_response_id), model=self.model, messages=[], logging_obj=self, @@ -3859,7 +3865,7 @@ class Logging(LiteLLMLoggingBaseClass): status_code=200, headers={}, ), - model_response=litellm.ModelResponse(), + model_response=litellm.ModelResponse(id=anthropic_response_id), json_mode=None, speed=self.optional_params.get("speed") if self.optional_params else None, ) diff --git a/litellm/proxy/pass_through_endpoints/llm_provider_handlers/anthropic_passthrough_logging_handler.py b/litellm/proxy/pass_through_endpoints/llm_provider_handlers/anthropic_passthrough_logging_handler.py index a36a365f39a..0acc7b1b584 100644 --- a/litellm/proxy/pass_through_endpoints/llm_provider_handlers/anthropic_passthrough_logging_handler.py +++ b/litellm/proxy/pass_through_endpoints/llm_provider_handlers/anthropic_passthrough_logging_handler.py @@ -107,6 +107,7 @@ class AnthropicPassthroughLoggingHandler: start_time=start_time, end_time=end_time, logging_obj=logging_obj, + response_id=optional_str(response_body.get("id")), ) return { @@ -148,8 +149,9 @@ class AnthropicPassthroughLoggingHandler: return model @staticmethod - def _extract_model_from_anthropic_chunks( + def _extract_message_start_field( all_chunks: Sequence[str | bytes], + field: str, ) -> str | None: for raw in all_chunks: text = raw.decode("utf-8") if isinstance(raw, bytes) else raw @@ -163,11 +165,23 @@ class AnthropicPassthroughLoggingHandler: if not isinstance(data, dict): continue if data.get("type") == "message_start": - model = (data.get("message") or {}).get("model") - if model: - return model + value = (data.get("message") or {}).get(field) + if isinstance(value, str) and value: + return value return None + @staticmethod + def _extract_model_from_anthropic_chunks( + all_chunks: Sequence[str | bytes], + ) -> str | None: + return AnthropicPassthroughLoggingHandler._extract_message_start_field(all_chunks, "model") + + @staticmethod + def _extract_response_id_from_anthropic_chunks( + all_chunks: Sequence[str | bytes], + ) -> str | None: + return AnthropicPassthroughLoggingHandler._extract_message_start_field(all_chunks, "id") + @staticmethod def _stream_was_interrupted( all_chunks: Sequence[str | bytes], @@ -251,6 +265,7 @@ class AnthropicPassthroughLoggingHandler: start_time: datetime, end_time: datetime, logging_obj: LiteLLMLoggingObj, + response_id: str | None = None, ): """ Create the standard logging object for Anthropic passthrough @@ -312,8 +327,7 @@ class AnthropicPassthroughLoggingHandler: json.dumps(kwargs, indent=4, default=str), ) - # set litellm_call_id to logging response object - litellm_model_response.id = logging_obj.litellm_call_id + litellm_model_response.id = response_id or logging_obj.litellm_call_id litellm_model_response.model = model logging_obj.model_call_details["model"] = model if not logging_obj.model_call_details.get("custom_llm_provider"): @@ -413,6 +427,7 @@ class AnthropicPassthroughLoggingHandler: start_time=start_time, end_time=end_time, logging_obj=litellm_logging_obj, + response_id=AnthropicPassthroughLoggingHandler._extract_response_id_from_anthropic_chunks(all_chunks), ) return { 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 9e5917637a8..97c004eb5ff 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 @@ -4025,3 +4025,164 @@ def test_caller_forged_router_metadata_is_discarded(bucket): ) metadata = json.loads(payload["metadata"]) assert metadata["router_metadata"] is None + + +ANTHROPIC_MESSAGES_RESPONSE: Final = { + "id": "msg_01Lit6806NonStreaming", + "type": "message", + "role": "assistant", + "model": "claude-haiku-4-5", + "content": [{"type": "text", "text": "epsilon"}], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 14, "output_tokens": 4}, +} + +ANTHROPIC_MESSAGES_SSE_CHUNKS: Final = ( + 'event: message_start\ndata: {"type":"message_start","message":{"id":"msg_01Lit6806Streaming",' + '"type":"message","role":"assistant","model":"claude-haiku-4-5","content":[],' + '"usage":{"input_tokens":14,"output_tokens":1}}}\n\n', + 'event: content_block_start\ndata: {"type":"content_block_start","index":0,' + '"content_block":{"type":"text","text":""}}\n\n', + 'event: content_block_delta\ndata: {"type":"content_block_delta","index":0,' + '"delta":{"type":"text_delta","text":"epsilon"}}\n\n', + '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", +) + + +def _anthropic_messages_logging_obj(*, stream: bool) -> Any: + from litellm.litellm_core_utils.litellm_logging import Logging + + logging_obj = Logging( + model="claude-haiku-4-5", + messages=[{"role": "user", "content": "hi"}], + stream=stream, + call_type="anthropic_messages", + start_time=datetime.datetime.now(timezone.utc), + litellm_call_id="6806cafe-0000-4000-8000-000000000001", + function_id="1234", + ) + logging_obj.optional_params = {} + logging_obj.model_call_details["custom_llm_provider"] = "anthropic" + return logging_obj + + +def _spend_log_request_id(response_obj: Any, kwargs: dict) -> str: + payload = get_logging_payload( + kwargs=kwargs, + response_obj=response_obj, + start_time=datetime.datetime.now(timezone.utc), + end_time=datetime.datetime.now(timezone.utc), + ) + return payload["request_id"] + + +def test_spend_log_request_id_is_the_message_id_a_non_streaming_messages_caller_received(): + """ + POST /v1/messages hands the caller `id: msg_...`, the only request id they ever see, so + GET /spend/logs?request_id=msg_... has to find the row. + """ + logging_obj = _anthropic_messages_logging_obj(stream=False) + + logged_response = logging_obj._handle_anthropic_messages_response_logging( + result=ANTHROPIC_MESSAGES_RESPONSE + ) + + assert logged_response.id == "msg_01Lit6806NonStreaming" + assert ( + _spend_log_request_id( + response_obj=logged_response, + kwargs={ + "call_type": "anthropic_messages", + "model": "claude-haiku-4-5", + "litellm_call_id": "6806cafe-0000-4000-8000-000000000001", + "litellm_params": {"metadata": {"user_api_key": "test-key"}}, + }, + ) + == "msg_01Lit6806NonStreaming" + ) + + +def test_spend_log_request_id_is_the_message_id_a_streaming_messages_caller_received(): + """ + The streaming leg of /v1/messages logs through the Anthropic passthrough handler, which used + to stamp litellm_call_id over the msg_ id carried by the message_start event. + """ + from litellm.proxy.pass_through_endpoints.llm_provider_handlers.anthropic_passthrough_logging_handler import ( + AnthropicPassthroughLoggingHandler, + ) + from litellm.types.passthrough_endpoints.pass_through_endpoints import EndpointType + + logging_obj = _anthropic_messages_logging_obj(stream=True) + logging_obj.model_call_details["stream"] = True + + logged = AnthropicPassthroughLoggingHandler._handle_logging_anthropic_collected_chunks( + litellm_logging_obj=logging_obj, + passthrough_success_handler_obj=MagicMock(), + url_route="/v1/messages", + request_body={"model": "claude-haiku-4-5"}, + endpoint_type=EndpointType.ANTHROPIC, + start_time=datetime.datetime.now(timezone.utc), + all_chunks=list(ANTHROPIC_MESSAGES_SSE_CHUNKS), + end_time=datetime.datetime.now(timezone.utc), + ) + + assert logged["result"].id == "msg_01Lit6806Streaming" + assert ( + _spend_log_request_id( + response_obj=logged["result"], + kwargs={ + **logged["kwargs"], + "call_type": "anthropic_messages", + "litellm_call_id": "6806cafe-0000-4000-8000-000000000001", + "litellm_params": {"metadata": {"user_api_key": "test-key"}}, + }, + ) + == "msg_01Lit6806Streaming" + ) + + +def test_spend_log_request_id_still_falls_back_to_litellm_call_id_without_a_provider_id(): + """ + Anthropic-compatible upstreams that omit `id` must keep landing on litellm_call_id rather + than on a fresh chatcmpl- uuid nobody can look up. + """ + logging_obj = _anthropic_messages_logging_obj(stream=True) + logging_obj.model_call_details["stream"] = True + + from litellm.proxy.pass_through_endpoints.llm_provider_handlers.anthropic_passthrough_logging_handler import ( + AnthropicPassthroughLoggingHandler, + ) + + AnthropicPassthroughLoggingHandler._create_anthropic_response_logging_payload( + litellm_model_response=litellm.ModelResponse(id="chatcmpl-generated"), + model="claude-haiku-4-5", + kwargs={}, + start_time=datetime.datetime.now(timezone.utc), + 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" + ) + + +def test_spend_log_request_id_for_chat_completions_is_untouched(): + """ + /v1/chat/completions callers look their rows up by the chatcmpl- id in the response body. + """ + assert ( + _spend_log_request_id( + response_obj=litellm.ModelResponse(id="chatcmpl-EJvWIw3DAhuKYuwp3jJI4Pnhp2vjv", choices=[]), + kwargs={ + "call_type": "acompletion", + "model": "gpt-5.6", + "litellm_call_id": "6806cafe-0000-4000-8000-000000000002", + "litellm_params": {"metadata": {"user_api_key": "test-key"}}, + }, + ) + == "chatcmpl-EJvWIw3DAhuKYuwp3jJI4Pnhp2vjv" + ) From 54f4fa2e1bda4038c70bc867dbd1320f51b730c0 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 3 Sep 2026 00:42:37 -0700 Subject: [PATCH 2/5] test(passthrough): look up anthropic spend rows by the message id the caller received --- .../test_anthropic_passthrough.py | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/tests/pass_through_tests/test_anthropic_passthrough.py b/tests/pass_through_tests/test_anthropic_passthrough.py index d42e06937dc..b4bcd62feb3 100644 --- a/tests/pass_through_tests/test_anthropic_passthrough.py +++ b/tests/pass_through_tests/test_anthropic_passthrough.py @@ -50,9 +50,9 @@ async def test_anthropic_basic_completion_with_headers(): anthropic_api_output_tokens = ( reported_usage.get("output_tokens", None) if reported_usage else None ) - litellm_call_id = response_headers.get("x-litellm-call-id") + anthropic_message_id = response_json.get("id") - print(f"LiteLLM Call ID: {litellm_call_id}") + print(f"Anthropic message ID: {anthropic_message_id}") # Wait for spend to be logged await asyncio.sleep(15) @@ -64,7 +64,7 @@ async def test_anthropic_basic_completion_with_headers(): print(f"Attempt {attempt + 1}/{max_retries} to check spend logs") async with session.get( - f"http://0.0.0.0:4000/spend/logs?request_id={litellm_call_id}", + f"http://0.0.0.0:4000/spend/logs?request_id={anthropic_message_id}", headers={"Authorization": "Bearer sk-1234"}, ) as spend_response: print("text spend response") @@ -102,7 +102,9 @@ async def test_anthropic_basic_completion_with_headers(): assert isinstance(log_entry, dict), "Log entry should be a dictionary" # Request metadata assertions - assert log_entry["request_id"] == litellm_call_id, "Request ID should match" + assert ( + log_entry["request_id"] == anthropic_message_id + ), "Request ID should be the message id the caller received" assert ( log_entry["call_type"] == "pass_through_endpoint" ), "Call type should be pass_through_endpoint" @@ -182,8 +184,6 @@ async def test_anthropic_streaming_with_headers(): assert response.status == 200, "Response should be successful" response_headers = response.headers print(f"Response headers: {response_headers}") - litellm_call_id = response_headers.get("x-litellm-call-id") - print(f"LiteLLM Call ID: {litellm_call_id}") collected_output = [] async for line in response.content: @@ -194,13 +194,18 @@ async def test_anthropic_streaming_with_headers(): print("Collected output:", "".join(collected_output)) anthropic_api_usage_chunks = [] + anthropic_message_id = None for chunk in collected_output: chunk_json = json.loads(chunk) + if chunk_json.get("type") == "message_start": + anthropic_message_id = chunk_json.get("message", {}).get("id") if "usage" in chunk_json: anthropic_api_usage_chunks.append(chunk_json["usage"]) elif "message" in chunk_json and "usage" in chunk_json["message"]: anthropic_api_usage_chunks.append(chunk_json["message"]["usage"]) + print(f"Anthropic message ID: {anthropic_message_id}") + print( "anthropic_api_usage_chunks", json.dumps(anthropic_api_usage_chunks, indent=4, default=str), @@ -232,7 +237,7 @@ async def test_anthropic_streaming_with_headers(): print(f"Attempt {attempt + 1}/{max_retries} to check spend logs") async with session.get( - f"http://0.0.0.0:4000/spend/logs?request_id={litellm_call_id}", + f"http://0.0.0.0:4000/spend/logs?request_id={anthropic_message_id}", headers={"Authorization": "Bearer sk-1234"}, ) as spend_response: spend_data = await spend_response.json() @@ -268,7 +273,9 @@ async def test_anthropic_streaming_with_headers(): assert isinstance(log_entry, dict), "Log entry should be a dictionary" # Request metadata assertions - assert log_entry["request_id"] == litellm_call_id, "Request ID should match" + assert ( + log_entry["request_id"] == anthropic_message_id + ), "Request ID should be the message id the caller received" assert ( log_entry["call_type"] == "pass_through_endpoint" ), "Call type should be pass_through_endpoint" From 58575c77a527551816c04a813523b38a5b95ffab Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 3 Sep 2026 00:46:07 -0700 Subject: [PATCH 3/5] test(e2e): correlate anthropic passthrough spend rows by the served message id --- .../e2e/llm_translation/passthrough_client.py | 29 +++++++++++++++++++ .../llm_translation/test_passthrough_e2e.py | 26 +++++++++-------- 2 files changed, 43 insertions(+), 12 deletions(-) diff --git a/tests/e2e/llm_translation/passthrough_client.py b/tests/e2e/llm_translation/passthrough_client.py index 20a8592db20..a56d3dc077e 100644 --- a/tests/e2e/llm_translation/passthrough_client.py +++ b/tests/e2e/llm_translation/passthrough_client.py @@ -168,6 +168,35 @@ def completed_responses_object(result: StreamingResponse) -> ResponsesObject | N return completed[-1] if completed else None +class AnthropicMessageObject(BaseModel): + id: str + + +class AnthropicStreamEvent(BaseModel): + """One SSE frame of a native Anthropic stream. Only `message_start` carries the + message, so it stays optional and the deltas validate as themselves.""" + + type: str + message: AnthropicMessageObject | None = None + + +def anthropic_message_id(result: StreamingResponse) -> str | None: + """The `msg_...` id the caller was served, which is what the spend row is keyed by + on this route: off the `message_start` frame when streaming, off the body when not.""" + if not result.is_streaming: + return AnthropicMessageObject.model_validate_json(result.body).id + events = ( + AnthropicStreamEvent.model_validate_json(payload) + for payload in result.stream_events + ) + started = tuple( + event.message + for event in events + if event.type == "message_start" and event.message is not None + ) + return started[0].id if started else None + + class OpenAIResponsesBody(BaseModel): model: str input: str diff --git a/tests/e2e/llm_translation/test_passthrough_e2e.py b/tests/e2e/llm_translation/test_passthrough_e2e.py index 7e6a8b25155..50ea8f4b4df 100644 --- a/tests/e2e/llm_translation/test_passthrough_e2e.py +++ b/tests/e2e/llm_translation/test_passthrough_e2e.py @@ -2,7 +2,8 @@ Each test sends a NATIVE provider request through the proxy's passthrough route and verifies the proxy still logged a costed SpendLogs row -(call_type="pass_through_endpoint"), correlated by the x-litellm-call-id header. +(call_type="pass_through_endpoint"), correlated by the id the caller was served: +the x-litellm-call-id header on gemini, the `msg_...` message id on anthropic. Covered: gemini ("gemini-2.5-flash") + anthropic ("claude-haiku-4-5"), streaming + non-streaming, plus native tool calls. See LLM_TRANSLATION_COVERAGE_MATRIX.md. @@ -14,7 +15,7 @@ A passthrough call returning non-2xx fails hard (never a skip); once it returns import pytest from e2e_config import CHEAP_OPENAI_MODEL, unique_marker -from e2e_http import StreamingResponse, require_successful_call, unwrap +from e2e_http import require_successful_call, unwrap from lifecycle import ResourceManager from models import KeyGenerateBody, SpendLogRow from passthrough_client import ( @@ -24,6 +25,7 @@ from passthrough_client import ( JsonSchema, JsonSchemaProperty, PassthroughClient, + anthropic_message_id, completed_responses_object, ) @@ -33,18 +35,18 @@ REALTIME_MODEL = "gpt-realtime-2" pytestmark = pytest.mark.e2e -def _fetch_cost_breakdown(client: PassthroughClient, result: StreamingResponse) -> SpendLogRow: +def _fetch_cost_breakdown(client: PassthroughClient, request_id: str | None) -> SpendLogRow: """The passthrough call's logged row, polled until it carries a cost. Asserts (not skips) that a 2xx passthrough call produced a costed row - the whole point of passthrough spend tracking. """ - assert result.call_id, "passthrough response had no x-litellm-call-id header" + assert request_id, "passthrough response carried no id to correlate its spend row by" rows = client.proxy.poll_logs_for_request_id( - result.call_id, + request_id, predicate=lambda rs: (rs[0].spend or 0) > 0, ) - assert rows, f"no SpendLogs row for passthrough call_id {result.call_id}" + assert rows, f"no SpendLogs row for passthrough request_id {request_id}" row = rows[0] assert row.call_type == "pass_through_endpoint" assert (row.spend or 0) > 0, f"passthrough call was not costed: {row}" @@ -64,7 +66,7 @@ def test_gemini_passthrough_nonstreaming_logs_cost( ) require_successful_call(result) - row = _fetch_cost_breakdown(client, result) + row = _fetch_cost_breakdown(client, result.call_id) assert row.custom_llm_provider == "gemini" assert "gemini" in (row.model or "") assert tag in (row.request_tags or []), f"tags not logged: {row.request_tags}" @@ -107,7 +109,7 @@ def test_gemini_passthrough_streaming_logs_cost( require_successful_call(result) assert result.chunks > 0, "streaming passthrough produced no events" - row = _fetch_cost_breakdown(client, result) + row = _fetch_cost_breakdown(client, result.call_id) assert row.custom_llm_provider == "gemini" @@ -137,7 +139,7 @@ def test_gemini_passthrough_tool_call_logs_cost( require_successful_call(result) assert "functionCall" in result.body, "gemini did not emit a tool call" - row = _fetch_cost_breakdown(client, result) + row = _fetch_cost_breakdown(client, result.call_id) assert row.custom_llm_provider == "gemini" @@ -150,7 +152,7 @@ def test_anthropic_passthrough_nonstreaming_logs_cost( result = client.anthropic_message(scoped_key, "claude-haiku-4-5", "Say hello") require_successful_call(result) - row = _fetch_cost_breakdown(client, result) + row = _fetch_cost_breakdown(client, anthropic_message_id(result)) assert row.custom_llm_provider == "anthropic" assert "claude" in (row.model or "") @@ -164,7 +166,7 @@ def test_anthropic_passthrough_streaming_logs_cost( require_successful_call(result) assert result.chunks > 0, "streaming passthrough produced no events" - row = _fetch_cost_breakdown(client, result) + row = _fetch_cost_breakdown(client, anthropic_message_id(result)) assert row.custom_llm_provider == "anthropic" @@ -190,7 +192,7 @@ def test_anthropic_passthrough_tool_call_logs_cost( require_successful_call(result) assert "tool_use" in result.body, "anthropic did not emit a tool call" - row = _fetch_cost_breakdown(client, result) + row = _fetch_cost_breakdown(client, anthropic_message_id(result)) assert row.custom_llm_provider == "anthropic" From 3b814179c837db8720496410f049a098bee23d53 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 3 Sep 2026 00:50:11 -0700 Subject: [PATCH 4/5] refactor(logging): name the response-id helper for what it reads, not the provider --- litellm/litellm_core_utils/litellm_logging.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index 3e54febf36c..c4fafc6ee31 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -414,7 +414,7 @@ def _resolve_vertex_location_for_cost( return VertexBase.get_vertex_region(configured_location, model) -def _anthropic_response_id(source: object) -> str | None: +def _provider_response_id(source: object) -> str | None: candidate: Final = source.get("id") if isinstance(source, dict) else getattr(source, "id", None) return candidate if isinstance(candidate, str) and candidate else None @@ -3837,12 +3837,12 @@ class Logging(LiteLLMLoggingBaseClass): if isinstance(result, ResponsesAPIResponse): return self._translate_responses_api_response_to_model_response(result) - anthropic_response_id: Final = _anthropic_response_id(result) + provider_response_id: Final = _provider_response_id(result) httpx_response: Final = self.model_call_details.get("httpx_response", None) if httpx_response and isinstance(httpx_response, httpx.Response): result = litellm.AnthropicConfig().transform_response( raw_response=httpx_response, - model_response=litellm.ModelResponse(id=anthropic_response_id), + model_response=litellm.ModelResponse(id=provider_response_id), model=self.model, messages=[], logging_obj=self, @@ -3865,7 +3865,7 @@ class Logging(LiteLLMLoggingBaseClass): status_code=200, headers={}, ), - model_response=litellm.ModelResponse(id=anthropic_response_id), + model_response=litellm.ModelResponse(id=provider_response_id), json_mode=None, speed=self.optional_params.get("speed") if self.optional_params else None, ) From c85da0a75f85b1e741457f3d4c8a55cd5c76977c Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 3 Sep 2026 01:15:06 -0700 Subject: [PATCH 5/5] fix(logging): key bridged /v1/messages rows on the id the caller received /v1/messages against a non-Anthropic model answers with the Responses id, but the spend row was built from a fresh ModelResponse, so it landed on a chatcmpl- uuid nobody can look up. Carry that id through the same way the Anthropic branch now does, and make the passthrough spend assertions fail on an empty lookup instead of skipping past it. --- litellm/litellm_core_utils/litellm_logging.py | 4 +- .../test_anthropic_passthrough.py | 36 +++++++--------- .../test_spend_tracking_utils.py | 43 +++++++++++++++++++ 3 files changed, 61 insertions(+), 22 deletions(-) diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index c4fafc6ee31..914b0d6888e 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -3888,7 +3888,7 @@ class Logging(LiteLLMLoggingBaseClass): return LiteLLMResponsesTransformationHandler().transform_response( model=self.model, raw_response=result, - model_response=litellm.ModelResponse(), + model_response=litellm.ModelResponse(id=_provider_response_id(result)), logging_obj=self, request_data={}, messages=[], @@ -3903,7 +3903,7 @@ class Logging(LiteLLMLoggingBaseClass): "usage-only ModelResponse to keep the spend_logs row.", str(e), ) - model_response: Final = litellm.ModelResponse() + model_response: Final = litellm.ModelResponse(id=_provider_response_id(result)) model_response.model = self.model usage: Final = getattr(result, "usage", None) if usage is not None and ResponseAPILoggingUtils._is_response_api_usage(usage): diff --git a/tests/pass_through_tests/test_anthropic_passthrough.py b/tests/pass_through_tests/test_anthropic_passthrough.py index b4bcd62feb3..0452b171f9e 100644 --- a/tests/pass_through_tests/test_anthropic_passthrough.py +++ b/tests/pass_through_tests/test_anthropic_passthrough.py @@ -84,18 +84,16 @@ async def test_anthropic_basic_completion_with_headers(): print("Waiting 10 seconds before retry...") await asyncio.sleep(10) - # Spend data might be unavailable (auth error, slow DB write, etc.) - if ( - spend_data is None - or not isinstance(spend_data, list) - or len(spend_data) == 0 - or not isinstance(spend_data[0], dict) - or "request_id" not in spend_data[0] - ): - print(f"Spend data not available or is error response: {spend_data}") - print("Skipping spend assertions (DB write may be slow in CI)") + if not isinstance(spend_data, list): + print(f"Spend endpoint answered with an error response: {spend_data}") + print("Skipping spend assertions (spend logs unreachable in CI)") return + assert spend_data, ( + f"GET /spend/logs?request_id={anthropic_message_id} found no row for the id " + "the caller received" + ) + log_entry = spend_data[0] # Basic existence checks @@ -255,18 +253,16 @@ async def test_anthropic_streaming_with_headers(): print("Waiting 10 seconds before retry...") await asyncio.sleep(10) - # Spend data might be unavailable (auth error, slow DB write, etc.) - if ( - spend_data is None - or not isinstance(spend_data, list) - or len(spend_data) == 0 - or not isinstance(spend_data[0], dict) - or "request_id" not in spend_data[0] - ): - print(f"Spend data not available or is error response: {spend_data}") - print("Skipping spend assertions (DB write may be slow in CI)") + if not isinstance(spend_data, list): + print(f"Spend endpoint answered with an error response: {spend_data}") + print("Skipping spend assertions (spend logs unreachable in CI)") return + assert spend_data, ( + f"GET /spend/logs?request_id={anthropic_message_id} found no row for the id " + "the caller received" + ) + log_entry = spend_data[0] # Basic existence checks 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 97c004eb5ff..c5f1b257715 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 @@ -4186,3 +4186,46 @@ def test_spend_log_request_id_for_chat_completions_is_untouched(): ) == "chatcmpl-EJvWIw3DAhuKYuwp3jJI4Pnhp2vjv" ) + + +def test_spend_log_request_id_is_the_response_id_a_bridged_messages_caller_received(): + """ + /v1/messages against a non-Anthropic model answers with the Responses id the caller then + looks their row up by, so the row must not fall back to a fresh chatcmpl- uuid. + """ + from litellm.types.llms.openai import ResponseAPIUsage, ResponsesAPIResponse + + logging_obj = _anthropic_messages_logging_obj(stream=False) + bridged_response = ResponsesAPIResponse( + id="resp_01Lit6806Bridged", + object="response", + created_at=1767225600, + model="gpt-5.6", + status="completed", + output=[ + { + "id": "msg_bridged_output", + "type": "message", + "role": "assistant", + "status": "completed", + "content": [{"type": "output_text", "text": "delta", "annotations": []}], + } + ], + usage=ResponseAPIUsage(input_tokens=13, output_tokens=5, total_tokens=18), + ) + + logged_response = logging_obj._handle_anthropic_messages_response_logging(result=bridged_response) + + assert logged_response.id == "resp_01Lit6806Bridged" + assert ( + _spend_log_request_id( + response_obj=logged_response, + kwargs={ + "call_type": "anthropic_messages", + "model": "gpt-5.6", + "litellm_call_id": "6806cafe-0000-4000-8000-000000000003", + "litellm_params": {"metadata": {"user_api_key": "test-key"}}, + }, + ) + == "resp_01Lit6806Bridged" + )