fix(otel): read only the caller's requester_metadata snapshot in the v2 pre-call hook

The pre-call hook passed the proxy's whole per-request metadata dict into the
request identity, so proxy-owned siblings such as requester_ip_address were
promoted alongside the caller's keys. Only the requester_metadata mapping is
read now, keyed under its wrapper, which keeps the default allowlist behaviour
unchanged

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
yassin 2026-09-16 19:11:25 +00:00
parent 5e2d9e1d5c
commit 30f02aa6da
2 changed files with 27 additions and 16 deletions

View file

@ -49,7 +49,8 @@ if TYPE_CHECKING:
from litellm.types.utils import StandardLoggingPayload
LANGFUSE_TRACE_NAME_HEADER: Final = "langfuse_trace_name"
REQUESTER_METADATA_PATH: Final = "requester_metadata."
REQUESTER_METADATA_KEY: Final = "requester_metadata"
REQUESTER_METADATA_PATH: Final = f"{REQUESTER_METADATA_KEY}."
@dataclass(frozen=True)
@ -106,8 +107,9 @@ class RequestIdentity:
guardrail, or service span is created — so the whole request's spans
inherit identity, not just the LLM-call span. Metadata sub-keys use the
``user_api_key_*`` names that ``baggage.DEFAULT_BAGGAGE_METADATA_KEYS``
promotes; ``request_metadata`` (the proxy's per-request metadata dict) is
flattened to dotted keys so ``requester_metadata.<key>`` resolves too.
promotes; ``request_metadata`` (the caller's ``requester_metadata``
snapshot) is flattened to dotted keys so ``requester_metadata.<key>``
resolves too.
"""
get: Final = lambda name: getattr(auth, name, None) # noqa: E731
auth_meta: Final = tuple(
@ -358,21 +360,21 @@ def model_from_request_data(data: object) -> str | None:
def metadata_from_request_data(data: object) -> Mapping[str, object] | None:
"""The proxy's per-request metadata dict from a pre-call ``data`` dict.
"""The caller's ``requester_metadata`` snapshot from a pre-call ``data`` dict, keyed under its wrapper.
The proxy writes it under ``metadata`` or ``litellm_metadata`` depending on
the route; the one carrying the ``requester_metadata`` snapshot wins.
The proxy stores it under ``metadata`` or ``litellm_metadata`` depending on the route;
the proxy-owned siblings (``user_api_key_*``, ``requester_ip_address``) are not read.
"""
top: Final = _as_str_mapping(data)
if top is None:
return None
candidates: Final = tuple(
nested for name in ("metadata", "litellm_metadata") if (nested := _as_str_mapping(top.get(name))) is not None
)
return next(
(c for c in candidates if isinstance(c.get("requester_metadata"), Mapping)),
candidates[0] if candidates else None,
snapshots: Final = tuple(
snapshot
for name in ("metadata", "litellm_metadata")
if (nested := _as_str_mapping(top.get(name))) is not None
and (snapshot := _as_str_mapping(nested.get(REQUESTER_METADATA_KEY))) is not None
)
return MappingProxyType({REQUESTER_METADATA_KEY: snapshots[0]}) if snapshots else None
def flatten_metadata(raw: Mapping[str, object]) -> Iterator[tuple[str, str]]:

View file

@ -1623,17 +1623,21 @@ def test_provider_model_and_team_metadata_on_real_boundary_flow():
def test_pre_call_hook_seeds_baggage_onto_server_and_child_spans():
"""The pre-call hook seeds identity Baggage in the request context so the
server span (stamped directly) AND later child spans (service here, via the
Baggage processor) carry identity — not just the LLM-call span."""
Baggage processor) carry identity — not just the LLM-call span. Only the
caller's ``requester_metadata`` is read from the request dict: the proxy's
own ``requester_ip_address`` stays unpromoted under the default allowlist."""
logger, exporter = _logger()
server = logger._emitter.start_span(
SpanRole.PROXY_REQUEST, LITELLM_PROXY_REQUEST_SPAN_NAME
)
data = {
"model": "gpt-4o",
"metadata": {"requester_ip_address": "127.0.0.1", "requester_metadata": {"trace_id": "abc"}},
}
async def _flow():
# pre-call seeds baggage + stamps the active server span
await logger.async_pre_call_hook(
_Auth(), None, {"model": "gpt-4o"}, "completion"
)
await logger.async_pre_call_hook(_Auth(), None, data, "completion")
# a later service call (same task) must inherit the identity
await logger.async_service_success_hook(
payload=_ServicePayload("redis", "set"), parent_otel_span=server
@ -1653,6 +1657,11 @@ def test_pre_call_hook_seeds_baggage_onto_server_and_child_spans():
srv.attributes[LiteLLM.TEAM_ID] == "t1"
) # stamped directly on the server span
assert srv.attributes[f"{LiteLLM.METADATA_PREFIX}user_api_key_user_id"] == "u1"
assert not any(
k in (f"{LiteLLM.METADATA_PREFIX}requester_ip_address", f"{LiteLLM.METADATA_PREFIX}trace_id")
for s in (redis, srv)
for k in s.attributes
)
def test_pre_call_hook_promotes_nested_request_metadata_key():