From 54d1bfc0af00fade592dd9f4bf59a7d285edcc27 Mon Sep 17 00:00:00 2001
From: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
Date: Thu, 28 May 2026 06:27:42 +0000
Subject: [PATCH] fix(compact_20260112): attribute summary subcall spend to
parent key/team
The compact_20260112 polyfill summary subrequest propagated metadata via
the Anthropic-shape `metadata` parameter, which only carries `user_id`.
The proxy auth fields used for spend attribution (`user_api_key`,
`user_api_key_team_id`, `litellm_call_id`, ...) live in
`data["litellm_metadata"]`. As a result, summary subcalls landed on the
router with an empty propagated metadata and the resulting tokens were
not attributed to the caller's key/team budget.
Rename the polyfill chain's spend-propagation parameter to
`litellm_metadata` and pull it from `kwargs["litellm_metadata"]` in
both the async and sync handlers, so the post-call hooks see the parent
key/team and bill the summary tokens accordingly. Add an
`_extract_proxy_litellm_metadata` helper and refactor
`_extract_user_api_key_auth` to use it.
---
.../adapters/handler.py | 56 +++++++++++------
.../context_management/dispatcher.py | 4 +-
.../context_management/editors/compact.py | 22 +++++--
.../context_management/test_compact.py | 61 +++++++++++++++++--
4 files changed, 111 insertions(+), 32 deletions(-)
diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py b/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py
index da286c9482d..6adea3e4ce2 100644
--- a/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py
+++ b/litellm/llms/anthropic/experimental_pass_through/adapters/handler.py
@@ -52,21 +52,30 @@ def _messages_have_compaction_block(messages: List[Dict]) -> bool:
return False
-def _extract_user_api_key_auth(kwargs: Dict[str, Any]) -> Any:
- """Pull the parent request's ``UserAPIKeyAuth`` out of ``litellm_metadata``.
+def _extract_proxy_litellm_metadata(kwargs: Dict[str, Any]) -> Optional[Dict[str, Any]]:
+ """Return ``kwargs["litellm_metadata"]`` when it's a dict; ``None`` otherwise.
- The proxy attaches the full auth object under
- ``data["litellm_metadata"]["user_api_key_auth"]`` (see
- ``LiteLLMProxyRequestSetup.add_user_api_key_auth_to_request_metadata``).
- The context-management polyfill uses it to gate the summary subrequest on
- the parent key/team's model allowlist; without it, the summary call would
- bypass the proxy auth checks. Returns ``None`` for SDK callers that bypass
- the proxy entirely.
+ The proxy attaches its auth/spend-attribution fields (``user_api_key``,
+ ``user_api_key_team_id``, ``litellm_call_id``, the full ``UserAPIKeyAuth``
+ object under ``user_api_key_auth``, ...) to ``data["litellm_metadata"]``
+ for ``/v1/messages`` (see
+ ``LiteLLMProxyRequestSetup.add_user_api_key_auth_to_request_metadata`` and
+ ``LITELLM_METADATA_ROUTES``). The Anthropic-shape ``metadata`` arg only
+ carries ``user_id`` and must not be conflated. Returns ``None`` for SDK
+ callers that bypass the proxy entirely.
"""
litellm_metadata = kwargs.get("litellm_metadata")
if not isinstance(litellm_metadata, dict):
return None
- return litellm_metadata.get("user_api_key_auth")
+ return litellm_metadata
+
+
+def _extract_user_api_key_auth(kwargs: Dict[str, Any]) -> Any:
+ """Pull the parent request's ``UserAPIKeyAuth`` out of ``litellm_metadata``."""
+ proxy_metadata = _extract_proxy_litellm_metadata(kwargs)
+ if proxy_metadata is None:
+ return None
+ return proxy_metadata.get("user_api_key_auth")
async def _prepare_context_managed_request(
@@ -76,7 +85,7 @@ async def _prepare_context_managed_request(
tools: Optional[List[Dict]],
system: Optional[Any],
context_management_spec: Any,
- metadata: Optional[Dict],
+ litellm_metadata: Optional[Dict],
drop_params: Optional[bool],
llm_router: Any,
user_api_key_auth: Any = None,
@@ -117,7 +126,7 @@ async def _prepare_context_managed_request(
tools=tools,
system=working_system,
context_management_spec=context_management_spec,
- metadata=metadata,
+ litellm_metadata=litellm_metadata,
drop_params=drop_params,
llm_router=llm_router,
user_api_key_auth=user_api_key_auth,
@@ -237,7 +246,7 @@ async def _run_polyfill_if_enabled(
tools: Optional[List[Dict]],
system: Optional[Any],
context_management_spec: Any,
- metadata: Optional[Dict],
+ litellm_metadata: Optional[Dict],
drop_params: Optional[bool],
llm_router: Any,
user_api_key_auth: Any = None,
@@ -265,7 +274,7 @@ async def _run_polyfill_if_enabled(
tools=tools,
system=system,
context_management_spec=context_management_spec,
- metadata=metadata,
+ litellm_metadata=litellm_metadata,
llm_router=llm_router,
user_api_key_auth=user_api_key_auth,
)
@@ -578,7 +587,12 @@ class LiteLLMMessagesToCompletionTransformationHandler:
except Exception:
pass
- user_api_key_auth = _extract_user_api_key_auth(kwargs)
+ proxy_litellm_metadata = _extract_proxy_litellm_metadata(kwargs)
+ user_api_key_auth = (
+ proxy_litellm_metadata.get("user_api_key_auth")
+ if proxy_litellm_metadata is not None
+ else None
+ )
polyfill_result = await _prepare_context_managed_request(
model=model,
@@ -586,7 +600,7 @@ class LiteLLMMessagesToCompletionTransformationHandler:
tools=tools,
system=system,
context_management_spec=context_management,
- metadata=metadata,
+ litellm_metadata=proxy_litellm_metadata,
drop_params=drop_params,
llm_router=litellm_router,
user_api_key_auth=user_api_key_auth,
@@ -722,6 +736,12 @@ class LiteLLMMessagesToCompletionTransformationHandler:
if context_management is None and not _messages_have_compaction_block(messages):
polyfill_result: Optional[PolyfillResult] = None
else:
+ proxy_litellm_metadata = _extract_proxy_litellm_metadata(kwargs)
+ user_api_key_auth = (
+ proxy_litellm_metadata.get("user_api_key_auth")
+ if proxy_litellm_metadata is not None
+ else None
+ )
polyfill_result = run_async_function(
_prepare_context_managed_request,
model=model,
@@ -729,10 +749,10 @@ class LiteLLMMessagesToCompletionTransformationHandler:
tools=tools,
system=system,
context_management_spec=context_management,
- metadata=metadata,
+ litellm_metadata=proxy_litellm_metadata,
drop_params=drop_params,
llm_router=litellm_router,
- user_api_key_auth=_extract_user_api_key_auth(kwargs),
+ user_api_key_auth=user_api_key_auth,
)
effective_messages = (
diff --git a/litellm/llms/anthropic/experimental_pass_through/context_management/dispatcher.py b/litellm/llms/anthropic/experimental_pass_through/context_management/dispatcher.py
index 5b8f1c267f4..f7af09ee62a 100644
--- a/litellm/llms/anthropic/experimental_pass_through/context_management/dispatcher.py
+++ b/litellm/llms/anthropic/experimental_pass_through/context_management/dispatcher.py
@@ -60,7 +60,7 @@ async def apply_context_management(
tools: Optional[List[Dict[str, Any]]],
system: Any,
context_management_spec: Union[Dict[str, Any], List[Dict[str, Any]], None],
- metadata: Optional[Dict[str, Any]] = None,
+ litellm_metadata: Optional[Dict[str, Any]] = None,
llm_router: Any = None,
user_api_key_auth: Any = None,
) -> PolyfillResult:
@@ -101,7 +101,7 @@ async def apply_context_management(
# Only async editors accept these — passing them to sync v0 editors
# would break their signature.
if inspect.iscoroutinefunction(editor):
- kwargs["metadata"] = metadata
+ kwargs["litellm_metadata"] = litellm_metadata
kwargs["llm_router"] = llm_router
kwargs["user_api_key_auth"] = user_api_key_auth
raw_result = await cast(Callable[..., Awaitable[Any]], editor)(**kwargs)
diff --git a/litellm/llms/anthropic/experimental_pass_through/context_management/editors/compact.py b/litellm/llms/anthropic/experimental_pass_through/context_management/editors/compact.py
index 51f841da603..c4b0315eac8 100644
--- a/litellm/llms/anthropic/experimental_pass_through/context_management/editors/compact.py
+++ b/litellm/llms/anthropic/experimental_pass_through/context_management/editors/compact.py
@@ -269,13 +269,23 @@ def _build_summary_prompt(
return prompt
-def _propagate_metadata(parent_metadata: Optional[Dict[str, Any]]) -> Dict[str, Any]:
- if not parent_metadata:
+def _propagate_metadata(
+ parent_litellm_metadata: Optional[Dict[str, Any]],
+) -> Dict[str, Any]:
+ """Extract the parent request's auth/spend-attribution fields for the summary subcall.
+
+ The proxy attaches ``user_api_key``, ``user_api_key_team_id`` etc. to
+ ``data["litellm_metadata"]`` (see
+ ``LiteLLMProxyRequestSetup.add_user_api_key_auth_to_request_metadata``).
+ Without these on the summary subrequest, the router's post-call hooks
+ cannot attribute summary tokens to the caller's key/team budget.
+ """
+ if not parent_litellm_metadata:
return {}
propagated: Dict[str, Any] = {}
for key in _PROPAGATED_METADATA_KEYS:
- if key in parent_metadata:
- propagated[key] = parent_metadata[key]
+ if key in parent_litellm_metadata:
+ propagated[key] = parent_litellm_metadata[key]
return propagated
@@ -615,7 +625,7 @@ async def apply_compact_20260112( # noqa: PLR0915
tools: Optional[List[Dict[str, Any]]],
system: Optional[Union[str, List[Dict[str, Any]]]],
edit_spec: Dict[str, Any],
- metadata: Optional[Dict[str, Any]] = None,
+ litellm_metadata: Optional[Dict[str, Any]] = None,
llm_router: Any = None,
user_api_key_auth: Any = None,
) -> PolyfillResult:
@@ -738,7 +748,7 @@ async def apply_compact_20260112( # noqa: PLR0915
summary_messages = _build_summary_messages(
effective_messages, prompt, system=augmented_system
)
- propagated_metadata = _propagate_metadata(metadata)
+ propagated_metadata = _propagate_metadata(litellm_metadata)
try:
response = await _call_summary_model(
diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/context_management/test_compact.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/context_management/test_compact.py
index a54e2146fb0..ef44c54d379 100644
--- a/tests/test_litellm/llms/anthropic/experimental_pass_through/context_management/test_compact.py
+++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/context_management/test_compact.py
@@ -537,11 +537,11 @@ async def test_full_summary_path_uses_router_when_available():
assert result.compaction_block["content"] == "Router summary"
-async def test_metadata_propagated_to_summary_call():
- """Auth metadata from the parent request is forwarded to the summary call."""
+async def test_litellm_metadata_propagated_to_summary_call():
+ """Auth fields from the proxy ``litellm_metadata`` are forwarded to the summary call."""
messages = _simple_messages()
mock_response = _make_mock_response("Summary")
- parent_metadata = {
+ parent_litellm_metadata = {
"user_api_key": "sk-test",
"user_api_key_team_id": "team-123",
"user_api_key_user_id": "user-456",
@@ -567,7 +567,7 @@ async def test_metadata_propagated_to_summary_call():
tools=None,
system=None,
edit_spec=_EDIT_SPEC_DEFAULT,
- metadata=parent_metadata,
+ litellm_metadata=parent_litellm_metadata,
)
call_kwargs = mock_call.call_args.kwargs
@@ -1255,7 +1255,7 @@ async def test_run_polyfill_skipped_when_drop_params_true():
tools=None,
system=None,
context_management_spec={"edits": [{"type": "compact_20260112"}]},
- metadata={},
+ litellm_metadata={},
drop_params=True,
llm_router=None,
)
@@ -1274,13 +1274,62 @@ async def test_run_polyfill_skipped_when_spec_empty():
tools=None,
system=None,
context_management_spec=None,
- metadata={},
+ litellm_metadata={},
drop_params=False,
llm_router=None,
)
assert result is None
+async def test_prepare_context_managed_request_forwards_proxy_litellm_metadata():
+ """The handler must hand the polyfill the proxy ``litellm_metadata`` (which
+ carries ``user_api_key`` / ``user_api_key_team_id`` / ...), not the
+ Anthropic-shape ``metadata`` arg (which only carries ``user_id``). Otherwise
+ the summary subcall lands on the router with no parent attribution, and
+ those tokens go unbilled to the caller's key/team."""
+ from litellm.llms.anthropic.experimental_pass_through.adapters.handler import (
+ _prepare_context_managed_request,
+ )
+
+ captured_summary_metadata: Dict[str, Any] = {}
+
+ class _RouterStub:
+ async def acompletion(self, **kwargs):
+ captured_summary_metadata.update(kwargs.get("metadata", {}))
+ return _make_mock_response("s")
+
+ with (
+ patch(
+ "litellm.llms.anthropic.experimental_pass_through.context_management.editors.compact._read_summary_model_setting",
+ return_value="claude-haiku-4-5",
+ ),
+ patch("litellm.token_counter", return_value=200_000),
+ ):
+ result = await _prepare_context_managed_request(
+ model=MODEL,
+ messages=_simple_messages(),
+ tools=None,
+ system=None,
+ context_management_spec={"edits": [_EDIT_SPEC_DEFAULT]},
+ litellm_metadata={
+ "user_api_key": "sk-parent",
+ "user_api_key_team_id": "team-abc",
+ "user_api_key_user_id": "user-xyz",
+ "litellm_call_id": "call-1",
+ },
+ drop_params=False,
+ llm_router=_RouterStub(),
+ )
+
+ assert result is not None
+ assert captured_summary_metadata.get("user_api_key") == "sk-parent"
+ assert captured_summary_metadata.get("user_api_key_team_id") == "team-abc"
+ assert captured_summary_metadata.get("user_api_key_user_id") == "user-xyz"
+ assert captured_summary_metadata.get("litellm_call_id") == "call-1"
+ # Anthropic-shape ``metadata.user_id`` must not leak in as a propagated field.
+ assert "user_id" not in captured_summary_metadata
+
+
# ---------------------------------------------------------------------------
# Endpoint error format: AnthropicContextManagementError → Anthropic 400 body
# ---------------------------------------------------------------------------