fix(proxy): include litellm_call_id in LLM API exception logs

_log_llm_api_exception() logged the error with only the exception text and a
timestamp, with no request-correlating id even though the caller
(_handle_llm_api_exception) has self.data in scope with the litellm_call_id.
Under concurrent traffic there was no way to tell which client request a
timeout/error log line belonged to, so it could not be cross-referenced against
the x-litellm-call-id response header or the spend logs table.

Thread litellm_call_id into _log_llm_api_exception and emit it via logging
extra so the JsonFormatter surfaces it as a first-class field on the structured
record. Closes #37532.

Signed-off-by: chjnett <cheonhyeonjun583@gmail.com>
This commit is contained in:
chjnett 2026-08-27 16:26:11 +09:00
parent cd63c7e5a7
commit 690b2e7d58
No known key found for this signature in database
2 changed files with 46 additions and 3 deletions

View file

@ -1418,7 +1418,7 @@ def _has_attribute_error_in_chain(exc: Exception) -> bool:
_CLIENT_DISCONNECT_DETAIL: Final = "Client disconnected the request"
def _log_llm_api_exception(e: Exception) -> None:
def _log_llm_api_exception(e: Exception, litellm_call_id: str | None = None) -> None:
if getattr(e, "status_code", None) == 499 and getattr(e, "detail", None) == _CLIENT_DISCONNECT_DETAIL:
verbose_proxy_logger.info(
"litellm.proxy.proxy_server._handle_llm_api_exception(): client disconnected, upstream LLM request cancelled"
@ -1429,7 +1429,15 @@ def _log_llm_api_exception(e: Exception) -> None:
if is_expected_client_error(e) and not litellm.log_client_error_tracebacks
else verbose_proxy_logger.exception
)
log_fn("litellm.proxy.proxy_server._handle_llm_api_exception(): Exception occured - %s", e)
log_fn(
"litellm.proxy.proxy_server._handle_llm_api_exception(): Exception occured - %s",
e,
extra=(
{"litellm_call_id": litellm_call_id}
if litellm_call_id is not None
else None
),
)
async def _cancel_llm_call_on_client_disconnect(
@ -3164,7 +3172,7 @@ class ProxyBaseLLMRequestProcessing:
version: str | None = None,
):
"""Raises ProxyException (OpenAI API compatible) if an exception is raised"""
_log_llm_api_exception(e)
_log_llm_api_exception(e, self.data.get("litellm_call_id"))
# Allow callbacks to transform the error response
transformed_exception: Final = await proxy_logging_obj.post_call_failure_hook(
user_api_key_dict=user_api_key_dict,

View file

@ -7409,3 +7409,38 @@ def test_log_llm_api_exception_traceback_only_for_unexpected_errors(exc, expect_
records = [r for r in caplog.records if "_handle_llm_api_exception(): Exception occured" in r.getMessage()]
assert len(records) == 1
assert (records[0].exc_info is not None) is expect_traceback
def test_log_llm_api_exception_includes_litellm_call_id(caplog):
"""Regression for #37532: proxy error log lines carry litellm_call_id so a
logged exception can be correlated back to the request that failed."""
from litellm._logging import verbose_proxy_logger
from litellm.proxy.common_request_processing import _log_llm_api_exception
verbose_proxy_logger.propagate = True
try:
with caplog.at_level("ERROR", logger="LiteLLM Proxy"):
_log_llm_api_exception(ValueError("boom"), "abc-123")
finally:
verbose_proxy_logger.propagate = False
records = [r for r in caplog.records if "_handle_llm_api_exception(): Exception occured" in r.getMessage()]
assert len(records) == 1
assert records[0].litellm_call_id == "abc-123"
def test_log_llm_api_exception_without_call_id_omits_field(caplog):
"""When no call id is available, the record still logs without the extra field."""
from litellm._logging import verbose_proxy_logger
from litellm.proxy.common_request_processing import _log_llm_api_exception
verbose_proxy_logger.propagate = True
try:
with caplog.at_level("ERROR", logger="LiteLLM Proxy"):
_log_llm_api_exception(ValueError("boom"))
finally:
verbose_proxy_logger.propagate = False
records = [r for r in caplog.records if "_handle_llm_api_exception(): Exception occured" in r.getMessage()]
assert len(records) == 1
assert not hasattr(records[0], "litellm_call_id")