Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions litellm/integrations/custom_logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,7 @@ async def async_post_call_response_headers_hook(
user_api_key_dict: UserAPIKeyAuth,
response: Any,
request_headers: Optional[Dict[str, str]] = None,
litellm_call_info: Optional[Dict[str, Any]] = None,
) -> Optional[Dict[str, str]]:
"""
Called after an LLM API call (success or failure) to allow injecting custom HTTP response headers.
Expand All @@ -386,6 +387,11 @@ async def async_post_call_response_headers_hook(
- user_api_key_dict: UserAPIKeyAuth - The user API key dictionary.
- response: Any - The response object (None for failure cases).
- request_headers: Optional[Dict[str, str]] - The original request headers.
- litellm_call_info: Optional[Dict[str, Any]] - Normalized routing metadata:
- custom_llm_provider: str - The LLM provider (e.g. "openai", "azure")
- model_info: dict - The model_info from router config
- api_base: str - The API base URL used
- model_id: str - The deployment model ID

Returns:
- Optional[Dict[str, str]]: A dictionary of headers to inject into the HTTP response.
Expand Down
3 changes: 3 additions & 0 deletions litellm/proxy/common_request_processing.py
Original file line number Diff line number Diff line change
Expand Up @@ -920,6 +920,7 @@ async def base_process_llm_request(
data=self.data,
user_api_key_dict=user_api_key_dict,
response=response,
request_headers=dict(request.headers),
)
if callback_headers:
custom_headers.update(callback_headers)
Expand Down Expand Up @@ -1028,6 +1029,7 @@ async def base_process_llm_request(
data=self.data,
user_api_key_dict=user_api_key_dict,
response=response,
request_headers=dict(request.headers),
)
if callback_headers:
fastapi_response.headers.update(callback_headers)
Expand Down Expand Up @@ -1196,6 +1198,7 @@ async def _handle_llm_api_exception(
data=self.data,
user_api_key_dict=user_api_key_dict,
response=None,
request_headers=(self.data.get("proxy_server_request") or {}).get("headers", {}),
)
if callback_headers:
headers.update(callback_headers)
Expand Down
10 changes: 10 additions & 0 deletions litellm/proxy/proxy_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -7367,6 +7367,16 @@ async def audio_transcriptions(
)
)

# Call response headers hook (matches base_process_llm_request behavior)
callback_headers = await proxy_logging_obj.post_call_response_headers_hook(
data=data,
user_api_key_dict=user_api_key_dict,
response=response,
request_headers=dict(request.headers),
)
if callback_headers:
fastapi_response.headers.update(callback_headers)

return response
except Exception as e:
await proxy_logging_obj.post_call_failure_hook(
Expand Down
63 changes: 57 additions & 6 deletions litellm/proxy/utils.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import asyncio
import copy
import hashlib
import inspect
import json
import os
import smtplib
Expand Down Expand Up @@ -285,6 +286,19 @@ def get_cache(


### LOGGING ###

# Cache for inspect.signature checks — avoids repeated introspection per request
_CALLBACK_ACCEPTS_CALL_INFO: Dict[int, bool] = {}


def _accepts_litellm_call_info(cb: CustomLogger) -> bool:
key = id(type(cb))
if key not in _CALLBACK_ACCEPTS_CALL_INFO:
sig = inspect.signature(cb.async_post_call_response_headers_hook)
_CALLBACK_ACCEPTS_CALL_INFO[key] = "litellm_call_info" in sig.parameters
return _CALLBACK_ACCEPTS_CALL_INFO[key]
Comment on lines +291 to +299

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cache key uses id(cb) (instance memory address), which is vulnerable to Python's address reuse after garbage collection. In deployments with dynamic callback add/remove: a removed callback's address may be reused by a new callback that doesn't accept litellm_call_info, causing the stale cache entry to return the wrong signature status.

Since all instances of the same callback class share the same method signature, use id(type(cb)) instead. This is safer (classes are effectively singletons), has better cache hit rates, and avoids the GC reuse problem entirely.

Comment on lines +294 to +299

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VAR_KEYWORD callbacks silently excluded from litellm_call_info

"litellm_call_info" in sig.parameters only matches explicitly-named parameters. Callbacks that use variadic keyword arguments for forward-compatibility (e.g. async def async_post_call_response_headers_hook(self, data, user_api_key_dict, response, **kw)) will not match this check and will never receive litellm_call_info, even if they access it via kw.get("litellm_call_info").

The fix is to also accept callbacks whose signature contains a VAR_KEYWORD parameter:

sig = inspect.signature(cb.async_post_call_response_headers_hook)
has_explicit = "litellm_call_info" in sig.parameters
has_var_kw = any(
    p.kind == inspect.Parameter.VAR_KEYWORD
    for p in sig.parameters.values()
)
_CALLBACK_ACCEPTS_CALL_INFO[key] = has_explicit or has_var_kw



class ProxyLogging:
"""
Logging/Custom Handlers for proxy.
Expand Down Expand Up @@ -1975,6 +1989,9 @@ async def post_call_response_headers_hook(
"""
merged_headers: Dict[str, str] = {}
try:
# Build litellm_call_info — normalized routing metadata for callbacks
litellm_call_info = self._build_litellm_call_info(data=data, response=response)

for callback in litellm.callbacks:
_callback: Optional[CustomLogger] = None
if isinstance(callback, str):
Expand All @@ -1985,12 +2002,22 @@ async def post_call_response_headers_hook(
_callback = callback # type: ignore

if _callback is not None and isinstance(_callback, CustomLogger):
result = await _callback.async_post_call_response_headers_hook(
data=data,
user_api_key_dict=user_api_key_dict,
response=response,
request_headers=request_headers,
)
if _accepts_litellm_call_info(_callback):
result = await _callback.async_post_call_response_headers_hook(
data=data,
user_api_key_dict=user_api_key_dict,
response=response,
request_headers=request_headers,
litellm_call_info=litellm_call_info,
)
else:
# Backwards compat: callback doesn't accept litellm_call_info
result = await _callback.async_post_call_response_headers_hook(
data=data,
user_api_key_dict=user_api_key_dict,
response=response,
request_headers=request_headers,
)
if result is not None:
merged_headers.update(result)
except Exception as e:
Expand All @@ -1999,6 +2026,30 @@ async def post_call_response_headers_hook(
)
return merged_headers

@staticmethod
def _build_litellm_call_info(
data: dict, response: Any
) -> Dict[str, Any]:
"""
Build a normalized dict of routing metadata from response._hidden_params
and data, abstracting away the metadata vs litellm_metadata split.
"""
hidden_params = getattr(response, "_hidden_params", {}) or {}

# model_info: check both metadata keys (chat uses "metadata", responses uses "litellm_metadata")
model_info = (
(data.get("metadata") or {}).get("model_info")
or (data.get("litellm_metadata") or {}).get("model_info")
or {}
)
Comment on lines +2040 to +2044

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AttributeError when metadata key is explicitly None

data.get("metadata", {}) only returns the {} fallback when the key is absent. If the key exists with a None value (e.g. data = {"metadata": None}), Python returns None, and the subsequent .get("model_info") call raises AttributeError. This exception is caught by the outer try/except Exception in post_call_response_headers_hook, which means litellm_call_info is never built and no callbacks are invoked for that request.

The failure path in common_request_processing.py at line 1201 has the same pattern for proxy_server_request. The fix is to use (... or {}) to guard against None values:

Suggested change
model_info = (
data.get("metadata", {}).get("model_info")
or data.get("litellm_metadata", {}).get("model_info")
or {}
)
model_info = (
(data.get("metadata") or {}).get("model_info")
or (data.get("litellm_metadata") or {}).get("model_info")
or {}
)


return {
"custom_llm_provider": hidden_params.get("custom_llm_provider"),
"model_info": model_info,
"api_base": hidden_params.get("api_base"),
"model_id": hidden_params.get("model_id"),
}

def is_a2a_streaming_response(self, response: dict) -> bool:
expected_keys = ["jsonrpc", "id", "result"]
return all(key in response for key in expected_keys)
Expand Down
6 changes: 6 additions & 0 deletions litellm/responses/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,9 @@ async def aresponses(
litellm_metadata=kwargs.get("litellm_metadata", {}),
custom_llm_provider=custom_llm_provider,
)
# Stamp custom_llm_provider so callbacks can identify the provider
# (mirrors litellm/main.py:1371 for chat completions)
response._hidden_params["custom_llm_provider"] = custom_llm_provider

if response is None:
raise ValueError(
Expand Down Expand Up @@ -778,6 +781,9 @@ def responses(
litellm_metadata=kwargs.get("litellm_metadata", {}),
custom_llm_provider=custom_llm_provider,
)
# Stamp custom_llm_provider so callbacks can identify the provider
# (mirrors litellm/main.py:1371 for chat completions)
response._hidden_params["custom_llm_provider"] = custom_llm_provider

return response
except Exception as e:
Expand Down
1 change: 1 addition & 0 deletions litellm/responses/streaming_iterator.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ def __init__(
self._hidden_params = {
"model_id": _model_info.get("id", None),
"api_base": _api_base,
"custom_llm_provider": custom_llm_provider,
}
self._hidden_params["additional_headers"] = process_response_headers(
self.response.headers or {}
Expand Down
131 changes: 131 additions & 0 deletions tests/test_litellm/proxy/hooks/test_post_call_response_headers_hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,3 +195,134 @@ async def test_default_hook_returns_none():
response=None,
)
assert result is None


# --- Tests for litellm_call_info parameter ---


class CallInfoInspectorLogger(CustomLogger):
"""Logger that captures litellm_call_info for inspection."""

def __init__(self):
self.called = False
self.received_call_info = None

async def async_post_call_response_headers_hook(
self,
data: dict,
user_api_key_dict: UserAPIKeyAuth,
response: Any,
request_headers: Optional[Dict[str, str]] = None,
litellm_call_info: Optional[Dict[str, Any]] = None,
) -> Optional[Dict[str, str]]:
self.called = True
self.received_call_info = litellm_call_info
return None


@pytest.mark.asyncio
async def test_litellm_call_info_from_hidden_params():
"""Test that litellm_call_info is built from response._hidden_params."""
inspector = CallInfoInspectorLogger()

class MockResponse:
_hidden_params = {
"custom_llm_provider": "openai",
"api_base": "https://api.openai.com",
"model_id": "model-abc",
}

with patch("litellm.callbacks", [inspector]):
from litellm.proxy.utils import ProxyLogging
from litellm.caching.caching import DualCache

proxy_logging = ProxyLogging(user_api_key_cache=DualCache())

await proxy_logging.post_call_response_headers_hook(
data={"model": "gpt-4", "metadata": {"model_info": {"id": "model-abc", "provider": "HubSpot"}}},
user_api_key_dict=UserAPIKeyAuth(api_key="test-key"),
response=MockResponse(),
)

assert inspector.called is True
assert inspector.received_call_info is not None
assert inspector.received_call_info["custom_llm_provider"] == "openai"
assert inspector.received_call_info["api_base"] == "https://api.openai.com"
assert inspector.received_call_info["model_id"] == "model-abc"
assert inspector.received_call_info["model_info"]["provider"] == "HubSpot"


@pytest.mark.asyncio
async def test_litellm_call_info_from_litellm_metadata():
"""Test that litellm_call_info finds model_info under litellm_metadata (responses API path)."""
inspector = CallInfoInspectorLogger()

class MockResponse:
_hidden_params = {
"custom_llm_provider": "azure",
"api_base": "https://east.openai.azure.com",
"model_id": "deploy-xyz",
}

with patch("litellm.callbacks", [inspector]):
from litellm.proxy.utils import ProxyLogging
from litellm.caching.caching import DualCache

proxy_logging = ProxyLogging(user_api_key_cache=DualCache())

await proxy_logging.post_call_response_headers_hook(
data={"model": "gpt-4", "litellm_metadata": {"model_info": {"id": "deploy-xyz"}}},
user_api_key_dict=UserAPIKeyAuth(api_key="test-key"),
response=MockResponse(),
)

assert inspector.received_call_info["model_info"]["id"] == "deploy-xyz"
assert inspector.received_call_info["custom_llm_provider"] == "azure"


@pytest.mark.asyncio
async def test_litellm_call_info_with_none_response():
"""Test that litellm_call_info handles None response (failure path)."""
inspector = CallInfoInspectorLogger()

with patch("litellm.callbacks", [inspector]):
from litellm.proxy.utils import ProxyLogging
from litellm.caching.caching import DualCache

proxy_logging = ProxyLogging(user_api_key_cache=DualCache())

await proxy_logging.post_call_response_headers_hook(
data={"model": "gpt-4", "metadata": {}},
user_api_key_dict=UserAPIKeyAuth(api_key="test-key"),
response=None,
)

assert inspector.called is True
assert inspector.received_call_info is not None
assert inspector.received_call_info["custom_llm_provider"] is None
assert inspector.received_call_info["model_info"] == {}


@pytest.mark.asyncio
async def test_litellm_call_info_backwards_compatible():
"""Test that existing callbacks without litellm_call_info parameter still work."""
# HeaderInjectorLogger doesn't accept litellm_call_info — must not crash
injector = HeaderInjectorLogger(headers={"x-test": "1"})

class MockResponse:
_hidden_params = {"custom_llm_provider": "openai", "api_base": "https://api.openai.com", "model_id": "m1"}

with patch("litellm.callbacks", [injector]):
from litellm.proxy.utils import ProxyLogging
from litellm.caching.caching import DualCache

proxy_logging = ProxyLogging(user_api_key_cache=DualCache())

result = await proxy_logging.post_call_response_headers_hook(
data={"model": "gpt-4", "metadata": {}},
user_api_key_dict=UserAPIKeyAuth(api_key="test-key"),
response=MockResponse(),
)

assert result == {"x-test": "1"}
assert injector.called is True
Loading