fix(pass-through): propagate x-litellm-spend-logs-metadata header

OpenAI-compatible endpoints parse `x-litellm-spend-logs-metadata` in
`LiteLLMProxyRequestSetup._get_spend_logs_metadata_from_request_headers`
and place it at `data["metadata"]["spend_logs_metadata"]`. Pass-through
endpoints (/anthropic/v1/messages, /openai/v1/*, /v2/chat, /vertex_ai/*,
/gemini/*, Cursor) bypass that pipeline, so
`StandardLoggingPayload.metadata.spend_logs_metadata` was always None
for `call_type=pass_through_endpoint`, hiding per-call metadata
(e.g. task / issue) from `/spend/logs`.

This change extracts the header at both `PassthroughStandardLoggingPayload`
construction sites (HTTP and WebSocket), adds an optional
`spend_logs_metadata` field to the TypedDict, and propagates it into
`kwargs["litellm_params"]["metadata"]` at every provider-handler
payload builder (base, Anthropic, OpenAI, Cohere, Gemini, Vertex) — both
the non-streaming and streaming (collected-chunks) paths. Two small
shared helpers on `BasePassthroughLoggingHandler` keep the propagation
consistent across handlers:

- `_apply_spend_logs_metadata(kwargs, payload)` — writes to the same
  key path OpenAI-compatible flows already use, so
  `get_standard_logging_object_payload` surfaces the value identically.
- `_seed_streaming_kwargs_from_logging_obj(logging_obj)` — seeds the
  fresh kwargs used by streaming logging paths with the payload that
  `success_handler.pass_through_async_success_handler` stashes on
  `model_call_details` before any provider handler runs.

Tests in `tests/test_litellm/proxy/pass_through_endpoints/test_spend_logs_metadata_propagation.py`
cover: header extraction (valid / missing / malformed JSON), helper
writes to the matching key path, preservation of existing metadata
keys, no-op when payload or metadata is absent, streaming-kwargs seed
behavior.
This commit is contained in:
Dawid Łaziński 2026-04-20 18:54:46 +02:00 committed by bot-lazinscy
parent b8f7d61400
commit c6f77aa964
No known key found for this signature in database
10 changed files with 248 additions and 4 deletions

View file

@ -59,6 +59,7 @@ sequenceDiagram
|-----------|-------------|
| `x-pass-*` headers | Strip prefix and forward (e.g., `x-pass-anthropic-beta``anthropic-beta`) |
| `x-litellm-tags` header | Extract tags and add to request metadata for logging |
| `x-litellm-spend-logs-metadata` header | Parse as JSON and propagate into `StandardLoggingPayload.metadata.spend_logs_metadata` (matches OpenAI-compatible endpoints) |
| Streaming chunk collection | Collect chunks async for logging after stream completes |
| Multipart form handling | Reconstruct multipart/form-data requests for file uploads |
| Guardrails (opt-in) | Run content filtering when explicitly configured |

View file

@ -19,6 +19,8 @@ from litellm.types.passthrough_endpoints.pass_through_endpoints import (
)
from litellm.types.utils import LiteLLMBatch, ModelResponse, TextCompletionResponse
from .base_passthrough_logging_handler import BasePassthroughLoggingHandler
if TYPE_CHECKING:
from litellm.types.passthrough_endpoints.pass_through_endpoints import EndpointType
@ -157,6 +159,10 @@ class AnthropicPassthroughLoggingHandler:
{"proxy_server_request": {"body": {"user": user}}}
)
BasePassthroughLoggingHandler._apply_spend_logs_metadata(
kwargs, passthrough_logging_payload
)
# pretty print standard logging object
verbose_proxy_logger.debug(
"kwargs= %s",
@ -224,7 +230,9 @@ class AnthropicPassthroughLoggingHandler:
kwargs = AnthropicPassthroughLoggingHandler._create_anthropic_response_logging_payload(
litellm_model_response=complete_streaming_response,
model=model,
kwargs={},
kwargs=BasePassthroughLoggingHandler._seed_streaming_kwargs_from_logging_obj(
litellm_logging_obj
),
start_time=start_time,
end_time=end_time,
logging_obj=litellm_logging_obj,

View file

@ -93,6 +93,49 @@ class BasePassthroughLoggingHandler(ABC):
return get_end_user_id_from_request_body(request_body)
return None
@staticmethod
def _apply_spend_logs_metadata(
kwargs: dict,
passthrough_logging_payload: Optional["PassthroughStandardLoggingPayload"],
) -> None:
"""
Propagate `spend_logs_metadata` from a pass-through logging payload into
`kwargs["litellm_params"]["metadata"]`.
OpenAI-compatible endpoints write the value at
`data["metadata"]["spend_logs_metadata"]` before the call; this helper
matches that key path at logging time so downstream
`get_standard_logging_object_payload` surfaces the same field for
pass-through traffic.
"""
if not passthrough_logging_payload:
return
metadata = passthrough_logging_payload.get("spend_logs_metadata")
if not metadata:
return
kwargs.setdefault("litellm_params", {})
kwargs["litellm_params"].setdefault("metadata", {})
kwargs["litellm_params"]["metadata"]["spend_logs_metadata"] = metadata
@staticmethod
def _seed_streaming_kwargs_from_logging_obj(
litellm_logging_obj: LiteLLMLoggingObj,
) -> dict:
"""
Build the initial `kwargs` dict for streaming logging paths so the
pass-through payload (populated by `success_handler.pass_through_async_success_handler`
before any provider handler runs) survives into the response logging
builder. Without this seed, streaming paths would lose the
`spend_logs_metadata` that non-streaming traffic carries via kwargs.
"""
initial: dict = {}
passthrough_payload = litellm_logging_obj.model_call_details.get(
"passthrough_logging_payload"
)
if passthrough_payload is not None:
initial["passthrough_logging_payload"] = passthrough_payload
return initial
def _create_response_logging_payload(
self,
litellm_model_response: Union[ModelResponse, TextCompletionResponse],
@ -129,6 +172,8 @@ class BasePassthroughLoggingHandler(ABC):
{"proxy_server_request": {"body": {"user": user}}}
)
self._apply_spend_logs_metadata(kwargs, passthrough_logging_payload)
# Make standard logging object for Anthropic
standard_logging_object = get_standard_logging_object_payload(
kwargs=kwargs,
@ -209,7 +254,7 @@ class BasePassthroughLoggingHandler(ABC):
kwargs = self._create_response_logging_payload(
litellm_model_response=complete_streaming_response,
model=model,
kwargs={},
kwargs=self._seed_streaming_kwargs_from_logging_obj(litellm_logging_obj),
start_time=start_time,
end_time=end_time,
logging_obj=litellm_logging_obj,

View file

@ -142,6 +142,10 @@ class CoherePassthroughLoggingHandler(BasePassthroughLoggingHandler):
{"proxy_server_request": {"body": {"user": user}}}
)
BasePassthroughLoggingHandler._apply_spend_logs_metadata(
kwargs, passthrough_logging_payload
)
# Create standard logging object
if litellm_model_response is not None:
get_standard_logging_object_payload(

View file

@ -17,6 +17,8 @@ from litellm.types.utils import (
TextCompletionResponse,
)
from .base_passthrough_logging_handler import BasePassthroughLoggingHandler
if TYPE_CHECKING:
from litellm.types.passthrough_endpoints.pass_through_endpoints import EndpointType
@ -139,7 +141,11 @@ class GeminiPassthroughLoggingHandler:
- Creates standard logging object
- Logs in litellm callbacks
"""
kwargs: Dict[str, Any] = {}
kwargs: Dict[str, Any] = (
BasePassthroughLoggingHandler._seed_streaming_kwargs_from_logging_obj(
litellm_logging_obj
)
)
model = model or GeminiPassthroughLoggingHandler.extract_model_from_url(
url_route
)
@ -242,6 +248,10 @@ class GeminiPassthroughLoggingHandler:
kwargs["model"] = model
kwargs["custom_llm_provider"] = custom_llm_provider
BasePassthroughLoggingHandler._apply_spend_logs_metadata(
kwargs, kwargs.get("passthrough_logging_payload")
)
# pretty print standard logging object
verbose_proxy_logger.debug("kwargs= %s", kwargs)

View file

@ -379,6 +379,10 @@ class OpenAIPassthroughLoggingHandler(BasePassthroughLoggingHandler):
"proxy_server_request", {}
).setdefault("body", {})["user"] = user
BasePassthroughLoggingHandler._apply_spend_logs_metadata(
kwargs, passthrough_logging_payload
)
# Create standard logging object
if litellm_model_response is not None:
get_standard_logging_object_payload(
@ -572,6 +576,10 @@ class OpenAIPassthroughLoggingHandler(BasePassthroughLoggingHandler):
"proxy_server_request", {}
).setdefault("body", {})["user"] = user
BasePassthroughLoggingHandler._apply_spend_logs_metadata(
kwargs, passthrough_logging_payload
)
# Create standard logging object
get_standard_logging_object_payload(
kwargs=kwargs,

View file

@ -16,6 +16,9 @@ from litellm.llms.vertex_ai.vector_stores.search_api.transformation import (
)
from litellm.llms.vertex_ai.videos.transformation import VertexAIVideoConfig
from litellm.proxy._types import PassThroughEndpointLoggingTypedDict
from litellm.proxy.pass_through_endpoints.llm_provider_handlers.base_passthrough_logging_handler import (
BasePassthroughLoggingHandler,
)
from litellm.types.utils import (
Choices,
EmbeddingResponse,
@ -341,7 +344,11 @@ class VertexPassthroughLoggingHandler:
- Creates standard logging object
- Logs in litellm callbacks
"""
kwargs: Dict[str, Any] = {}
kwargs: Dict[str, Any] = (
BasePassthroughLoggingHandler._seed_streaming_kwargs_from_logging_obj(
litellm_logging_obj
)
)
model = model or VertexPassthroughLoggingHandler.extract_model_from_url(
url_route
)
@ -547,6 +554,10 @@ class VertexPassthroughLoggingHandler:
kwargs["response_cost"] = response_cost
kwargs["model"] = model
BasePassthroughLoggingHandler._apply_spend_logs_metadata(
kwargs, kwargs.get("passthrough_logging_payload")
)
# pretty print standard logging object
verbose_proxy_logger.debug("kwargs= %s", kwargs)

View file

@ -752,11 +752,17 @@ async def pass_through_request( # noqa: PLR0915
params={"timeout": 600},
)
async_client = async_client_obj.client
spend_logs_metadata = (
LiteLLMProxyRequestSetup._get_spend_logs_metadata_from_request_headers(
_safe_get_request_headers(request)
)
)
passthrough_logging_payload = PassthroughStandardLoggingPayload(
url=str(url),
request_body=_parsed_body,
request_method=getattr(request, "method", None),
cost_per_request=cost_per_request,
spend_logs_metadata=spend_logs_metadata,
)
kwargs = HttpPassThroughEndpointHelpers._init_kwargs_for_pass_through_endpoint(
user_api_key_dict=user_api_key_dict,
@ -1386,11 +1392,18 @@ async def websocket_passthrough_request( # noqa: PLR0915
)
# Create passthrough logging payload
websocket_headers = dict(websocket.headers) if hasattr(websocket, "headers") else {}
spend_logs_metadata = (
LiteLLMProxyRequestSetup._get_spend_logs_metadata_from_request_headers(
websocket_headers
)
)
passthrough_logging_payload = PassthroughStandardLoggingPayload(
url=target,
request_body={}, # WebSocket doesn't have a traditional request body
request_method="WEBSOCKET",
cost_per_request=cost_per_request,
spend_logs_metadata=spend_logs_metadata,
)
# Create a dummy request object for WebSocket connections to maintain compatibility

View file

@ -46,3 +46,12 @@ class PassthroughStandardLoggingPayload(TypedDict, total=False):
Optional field, we use this for cost tracking only if it's set.
"""
spend_logs_metadata: Optional[dict]
"""
Parsed `x-litellm-spend-logs-metadata` request header, if present.
Propagated into `StandardLoggingPayload.metadata.spend_logs_metadata` so
pass-through traffic surfaces the same per-call metadata (e.g. `task`,
`issue`) that OpenAI-compatible endpoints already emit.
"""

View file

@ -0,0 +1,135 @@
"""Tests for `x-litellm-spend-logs-metadata` propagation through pass-through endpoints.
OpenAI-compatible endpoints parse this header in
`LiteLLMProxyRequestSetup._get_spend_logs_metadata_from_request_headers` and
stuff it into `data["metadata"]["spend_logs_metadata"]`. Pass-through endpoints
used to skip this extraction, so `/spend/logs` entries for
`call_type=pass_through_endpoint` always had
`metadata.spend_logs_metadata = None`.
These tests pin the fix:
1. Header extraction handles present/missing/malformed input.
2. `BasePassthroughLoggingHandler._apply_spend_logs_metadata` writes the
metadata to the same key path the OpenAI-compatible flow uses.
3. `_seed_streaming_kwargs_from_logging_obj` threads the pass-through payload
from `logging_obj.model_call_details` into fresh streaming kwargs, so
streaming responses retain the metadata.
"""
import json
from unittest.mock import MagicMock
import pytest
@pytest.mark.parametrize(
"headers,expected",
[
({"x-litellm-spend-logs-metadata": json.dumps({"task": "t1"})}, {"task": "t1"}),
({}, None),
({"x-litellm-spend-logs-metadata": "not-json"}, None),
],
)
def test_header_extraction(headers, expected):
"""Helper handles valid JSON, missing header, and malformed JSON."""
from litellm.proxy.litellm_pre_call_utils import LiteLLMProxyRequestSetup
assert (
LiteLLMProxyRequestSetup._get_spend_logs_metadata_from_request_headers(headers)
== expected
)
def test_apply_spend_logs_metadata_writes_matching_key_path():
"""The helper must write to `kwargs["litellm_params"]["metadata"]["spend_logs_metadata"]`
the same key path OpenAI-compatible flows use, so
`get_standard_logging_object_payload` picks it up identically.
"""
from litellm.proxy.pass_through_endpoints.llm_provider_handlers.base_passthrough_logging_handler import (
BasePassthroughLoggingHandler,
)
kwargs: dict = {}
metadata = {"task": "cost-report", "issue": "#42"}
BasePassthroughLoggingHandler._apply_spend_logs_metadata(
kwargs,
{"url": "x", "spend_logs_metadata": metadata},
)
assert kwargs["litellm_params"]["metadata"]["spend_logs_metadata"] == metadata
def test_apply_spend_logs_metadata_preserves_existing_metadata():
"""Existing metadata keys must not be clobbered when attaching
`spend_logs_metadata`.
"""
from litellm.proxy.pass_through_endpoints.llm_provider_handlers.base_passthrough_logging_handler import (
BasePassthroughLoggingHandler,
)
kwargs: dict = {"litellm_params": {"metadata": {"tags": ["prod"]}}}
BasePassthroughLoggingHandler._apply_spend_logs_metadata(
kwargs,
{"url": "x", "spend_logs_metadata": {"task": "t1"}},
)
assert kwargs["litellm_params"]["metadata"] == {
"tags": ["prod"],
"spend_logs_metadata": {"task": "t1"},
}
@pytest.mark.parametrize(
"payload",
[None, {}, {"url": "x"}, {"url": "x", "spend_logs_metadata": None}],
)
def test_apply_spend_logs_metadata_is_noop_when_missing(payload):
"""When the payload is missing or carries no metadata, the helper must
leave kwargs untouched this is the path for existing callers that don't
send the header, and it must remain backward-compatible.
"""
from litellm.proxy.pass_through_endpoints.llm_provider_handlers.base_passthrough_logging_handler import (
BasePassthroughLoggingHandler,
)
kwargs: dict = {}
BasePassthroughLoggingHandler._apply_spend_logs_metadata(kwargs, payload)
assert kwargs == {}
def test_seed_streaming_kwargs_copies_payload_from_logging_obj():
"""Streaming logging paths build fresh kwargs; the seed helper must lift
the pass-through payload stashed by `success_handler` onto that fresh dict
so `_create_*_response_logging_payload` can still see it.
"""
from litellm.proxy.pass_through_endpoints.llm_provider_handlers.base_passthrough_logging_handler import (
BasePassthroughLoggingHandler,
)
payload = {"url": "x", "spend_logs_metadata": {"task": "t1"}}
logging_obj = MagicMock(model_call_details={"passthrough_logging_payload": payload})
seeded = BasePassthroughLoggingHandler._seed_streaming_kwargs_from_logging_obj(
logging_obj
)
assert seeded == {"passthrough_logging_payload": payload}
def test_seed_streaming_kwargs_empty_when_no_payload():
"""No pass-through payload on `logging_obj` → empty dict (prior behavior)."""
from litellm.proxy.pass_through_endpoints.llm_provider_handlers.base_passthrough_logging_handler import (
BasePassthroughLoggingHandler,
)
logging_obj = MagicMock(model_call_details={})
assert (
BasePassthroughLoggingHandler._seed_streaming_kwargs_from_logging_obj(
logging_obj
)
== {}
)