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

* 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.

* fix: move BasePassthroughLoggingHandler imports to local scope to avoid cyclic import warnings

* fix(pass-through): use logging_obj fallback for passthrough_logging_payload in non-streaming paths

passthrough_logging_payload is a named parameter of pass_through_async_success_handler
and is therefore consumed from **kwargs before reaching provider handlers.
All non-streaming kwargs.get('passthrough_logging_payload') calls returned None,
making _apply_spend_logs_metadata a no-op on every non-streaming path.

Fix: add logging_obj.model_call_details.get('passthrough_logging_payload') fallback
in base, anthropic, cohere, openai (first path), gemini, and vertex handlers.
The streaming path already uses _seed_streaming_kwargs_from_logging_obj which
reads from the same logging_obj.model_call_details source.

---------

Co-authored-by: ishaan-berri <155045088+ishaan-berri@users.noreply.github.com>
This commit is contained in:
Dawid Łaziński 2026-04-21 06:02:55 +02:00 • committed by GitHub
parent b9bedc8153
commit eda0653e0e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 261 additions and 6 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,7 @@ from litellm.types.passthrough_endpoints.pass_through_endpoints import (
)
from litellm.types.utils import LiteLLMBatch, ModelResponse, TextCompletionResponse
if TYPE_CHECKING:
from litellm.types.passthrough_endpoints.pass_through_endpoints import EndpointType
@ -146,6 +147,7 @@ class AnthropicPassthroughLoggingHandler:
kwargs["model"] = model
passthrough_logging_payload: Optional[PassthroughStandardLoggingPayload] = ( # type: ignore
kwargs.get("passthrough_logging_payload")
or logging_obj.model_call_details.get("passthrough_logging_payload")
)
if passthrough_logging_payload:
user = AnthropicPassthroughLoggingHandler._get_user_from_metadata(
@ -157,6 +159,11 @@ class AnthropicPassthroughLoggingHandler:
{"proxy_server_request": {"body": {"user": user}}}
)
from .base_passthrough_logging_handler import BasePassthroughLoggingHandler # noqa: PLC0415
BasePassthroughLoggingHandler._apply_spend_logs_metadata(
kwargs, passthrough_logging_payload
)
# pretty print standard logging object
verbose_proxy_logger.debug(
"kwargs= %s",
@ -221,10 +228,11 @@ class AnthropicPassthroughLoggingHandler:
"result": None,
"kwargs": {},
}
from .base_passthrough_logging_handler import BasePassthroughLoggingHandler # noqa: PLC0415
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],
@ -118,6 +161,7 @@ class BasePassthroughLoggingHandler(ABC):
kwargs["model"] = model
passthrough_logging_payload: Optional[PassthroughStandardLoggingPayload] = ( # type: ignore
kwargs.get("passthrough_logging_payload")
or logging_obj.model_call_details.get("passthrough_logging_payload")
)
if passthrough_logging_payload:
user = self._get_user_from_metadata(
@ -129,6 +173,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 +255,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

@ -131,7 +131,10 @@ class CoherePassthroughLoggingHandler(BasePassthroughLoggingHandler):
# Extract user information for tracking
passthrough_logging_payload: Optional[
PassthroughStandardLoggingPayload
] = kwargs.get("passthrough_logging_payload")
] = (
kwargs.get("passthrough_logging_payload")
or logging_obj.model_call_details.get("passthrough_logging_payload")
)
if passthrough_logging_payload:
user = handler_instance._get_user_from_metadata(
passthrough_logging_payload=passthrough_logging_payload,
@ -142,6 +145,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,7 @@ from litellm.types.utils import (
TextCompletionResponse,
)
if TYPE_CHECKING:
from litellm.types.passthrough_endpoints.pass_through_endpoints import EndpointType
@ -139,7 +140,12 @@ class GeminiPassthroughLoggingHandler:
- Creates standard logging object
- Logs in litellm callbacks
"""
kwargs: Dict[str, Any] = {}
from .base_passthrough_logging_handler import BasePassthroughLoggingHandler # noqa: PLC0415
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,13 @@ class GeminiPassthroughLoggingHandler:
kwargs["model"] = model
kwargs["custom_llm_provider"] = custom_llm_provider
from .base_passthrough_logging_handler import BasePassthroughLoggingHandler # noqa: PLC0415
BasePassthroughLoggingHandler._apply_spend_logs_metadata(
kwargs,
kwargs.get("passthrough_logging_payload")
or logging_obj.model_call_details.get("passthrough_logging_payload"),
)
# pretty print standard logging object
verbose_proxy_logger.debug("kwargs= %s", kwargs)

View file

@ -367,8 +367,11 @@ class OpenAIPassthroughLoggingHandler(BasePassthroughLoggingHandler):
kwargs["custom_llm_provider"] = custom_llm_provider
# Extract user information for tracking
passthrough_logging_payload: Optional[PassthroughStandardLoggingPayload] = (
passthrough_logging_payload: Optional[
PassthroughStandardLoggingPayload
] = (
kwargs.get("passthrough_logging_payload")
or logging_obj.model_call_details.get("passthrough_logging_payload")
)
if passthrough_logging_payload:
user = handler_instance._get_user_from_metadata(
@ -379,6 +382,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(
@ -570,6 +577,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

@ -341,7 +341,12 @@ class VertexPassthroughLoggingHandler:
- Creates standard logging object
- Logs in litellm callbacks
"""
kwargs: Dict[str, Any] = {}
from litellm.proxy.pass_through_endpoints.llm_provider_handlers.base_passthrough_logging_handler import BasePassthroughLoggingHandler # noqa: PLC0415
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 +552,13 @@ class VertexPassthroughLoggingHandler:
kwargs["response_cost"] = response_cost
kwargs["model"] = model
from litellm.proxy.pass_through_endpoints.llm_provider_handlers.base_passthrough_logging_handler import BasePassthroughLoggingHandler # noqa: PLC0415
BasePassthroughLoggingHandler._apply_spend_logs_metadata(
kwargs,
kwargs.get("passthrough_logging_payload")
or logging_obj.model_call_details.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
)
== {}
)