mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-07 02:59:05 +00:00
fix(proxy): keep litellm_call_id on shaped errors and list_batches failure hook
Already shaped ProxyException and HTTPException errors passing through the moderations, audio speech, Anthropic Messages, and handle_exception_on_proxy paths now answer with the x-litellm-call-id header the route logged under, without overwriting a header the exception was raised with. The GET /v1/batches failure hook receives the resolved request data so the spend log request_id matches the response header and the error log Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
f12feed9a9
commit
581c613f66
10 changed files with 184 additions and 13 deletions
|
|
@ -30,6 +30,7 @@ from litellm.proxy.common_utils.openai_error_payload import (
|
|||
error_status_code,
|
||||
openai_error_param,
|
||||
openai_error_type,
|
||||
with_litellm_call_id,
|
||||
)
|
||||
from litellm.types.utils import TokenCountResponse
|
||||
|
||||
|
|
@ -222,7 +223,9 @@ async def anthropic_response(
|
|||
log_llm_api_exception(e, base_llm_response_processor.litellm_call_id)
|
||||
|
||||
if isinstance(e, ProxyException):
|
||||
return _anthropic_error_json_response(e, request)
|
||||
return _anthropic_error_json_response(
|
||||
with_litellm_call_id(e, base_llm_response_processor.litellm_call_id), request
|
||||
)
|
||||
|
||||
# Extract model_id from request metadata (same as success path)
|
||||
litellm_metadata: Final = data.get("litellm_metadata", {}) or {}
|
||||
|
|
|
|||
|
|
@ -861,7 +861,7 @@ async def list_batches(
|
|||
await proxy_logging_obj.post_call_failure_hook(
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
original_exception=e,
|
||||
request_data={"after": after, "limit": limit},
|
||||
request_data={**data, "after": after, "limit": limit},
|
||||
)
|
||||
litellm_call_id: Final = request_litellm_call_id(data)
|
||||
log_llm_api_exception(e, litellm_call_id)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@ from typing import Final
|
|||
from fastapi import status
|
||||
|
||||
from litellm.constants import STRINGIFIED_NONE
|
||||
from litellm.proxy._types import ProxyException
|
||||
|
||||
LITELLM_CALL_ID_HEADER: Final = "x-litellm-call-id"
|
||||
|
||||
_OPENAI_ERROR_TYPE_BY_STATUS: Final[Mapping[int, str]] = MappingProxyType(
|
||||
{
|
||||
|
|
@ -57,4 +60,11 @@ def openai_error_param(exc: object) -> str | None:
|
|||
def litellm_call_id_headers(litellm_call_id: str | None) -> dict[str, str] | None: # mutable-ok: ProxyException.headers
|
||||
if litellm_call_id is None:
|
||||
return None
|
||||
return {"x-litellm-call-id": litellm_call_id} # mutable-ok: ProxyException mutates its headers dict
|
||||
return {LITELLM_CALL_ID_HEADER: litellm_call_id} # mutable-ok: ProxyException mutates its headers dict
|
||||
|
||||
|
||||
def with_litellm_call_id(exc: ProxyException, litellm_call_id: str | None) -> ProxyException:
|
||||
"""The same error object, answering with ``x-litellm-call-id`` when it was raised without one."""
|
||||
if litellm_call_id is not None:
|
||||
exc.headers.setdefault(LITELLM_CALL_ID_HEADER, litellm_call_id)
|
||||
return exc
|
||||
|
|
|
|||
|
|
@ -390,7 +390,11 @@ from litellm.proxy.common_utils.model_listing_utils import (
|
|||
from litellm.proxy.common_utils.openai_endpoint_utils import (
|
||||
remove_sensitive_info_from_deployment,
|
||||
)
|
||||
from litellm.proxy.common_utils.openai_error_payload import litellm_call_id_headers
|
||||
from litellm.proxy.common_utils.openai_error_payload import (
|
||||
LITELLM_CALL_ID_HEADER,
|
||||
litellm_call_id_headers,
|
||||
with_litellm_call_id,
|
||||
)
|
||||
from litellm.proxy.common_utils.periodic_reload_schedule import (
|
||||
MODEL_COST_MAP_RELOAD_PARAM_NAME,
|
||||
clear_reload_interval,
|
||||
|
|
@ -11530,7 +11534,7 @@ async def moderations(
|
|||
)
|
||||
log_llm_api_exception(e, litellm_call_id)
|
||||
if isinstance(e, ProxyException):
|
||||
raise
|
||||
raise with_litellm_call_id(e, litellm_call_id)
|
||||
if isinstance(e, HTTPException):
|
||||
raise ProxyException(
|
||||
message=getattr(e, "message", str(e)),
|
||||
|
|
@ -11678,8 +11682,14 @@ async def audio_speech(
|
|||
request_data=data,
|
||||
)
|
||||
log_llm_api_exception(e, litellm_call_id)
|
||||
if isinstance(e, (ProxyException, HTTPException)):
|
||||
raise e
|
||||
if isinstance(e, ProxyException):
|
||||
raise with_litellm_call_id(e, litellm_call_id)
|
||||
if isinstance(e, HTTPException):
|
||||
raise HTTPException(
|
||||
status_code=e.status_code,
|
||||
detail=e.detail,
|
||||
headers={LITELLM_CALL_ID_HEADER: litellm_call_id, **(e.headers or {})},
|
||||
)
|
||||
raise ProxyException(
|
||||
message=getattr(e, "message", f"{e}"),
|
||||
type=getattr(e, "type", "None"),
|
||||
|
|
|
|||
|
|
@ -38,7 +38,11 @@ from litellm.proxy._types import (
|
|||
SpendLogsMetadata,
|
||||
SpendLogsPayload,
|
||||
)
|
||||
from litellm.proxy.common_utils.openai_error_payload import litellm_call_id_headers, openai_error_param
|
||||
from litellm.proxy.common_utils.openai_error_payload import (
|
||||
litellm_call_id_headers,
|
||||
openai_error_param,
|
||||
with_litellm_call_id,
|
||||
)
|
||||
from litellm.proxy.spend_tracking.spend_log_error_logger import spend_log_error
|
||||
from litellm.types.guardrails import GuardrailEventHooks
|
||||
from litellm.types.proxy.model_listing import ModelInfoResponse
|
||||
|
|
@ -7660,7 +7664,7 @@ def handle_exception_on_proxy(e: Exception, litellm_call_id: str | None = None)
|
|||
code=getattr(e, "status_code", status.HTTP_500_INTERNAL_SERVER_ERROR),
|
||||
)
|
||||
elif isinstance(e, ProxyException):
|
||||
return e
|
||||
return with_litellm_call_id(e, litellm_call_id)
|
||||
_status_code: Final = getattr(e, "status_code", status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
return ProxyException(
|
||||
message=str(e),
|
||||
|
|
|
|||
|
|
@ -336,6 +336,36 @@ class TestErrorLogCarriesCallId:
|
|||
assert record.litellm_call_id == call_id
|
||||
assert call_id in record.getMessage()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_messages_already_shaped_failure_answers_with_the_call_id(self):
|
||||
import litellm.proxy.anthropic_endpoints.endpoints as ep
|
||||
import litellm.proxy.proxy_server as proxy_server
|
||||
from litellm.proxy._types import ProxyErrorTypes, ProxyException, UserAPIKeyAuth
|
||||
|
||||
call_id = "messages-call-7836-shaped"
|
||||
|
||||
async def fake_process(self, **kwargs):
|
||||
self.data = {**self.data, "litellm_call_id": call_id}
|
||||
raise ProxyException(message="budget exceeded", type=ProxyErrorTypes.budget_exceeded, param="key", code=402)
|
||||
|
||||
request = MagicMock()
|
||||
request.headers = {}
|
||||
|
||||
with (
|
||||
patch.object(ep, "_read_request_body", new=AsyncMock(return_value={"model": "claude-sonnet"})), # test-quality-ok: endpoint reads the body via a module function; no injection seam
|
||||
patch.object(ep.ProxyBaseLLMRequestProcessing, "base_process_llm_request", new=fake_process), # test-quality-ok: the proxy shaped failure happens inside this call; the test targets the endpoint's except block
|
||||
patch.object(proxy_server, "proxy_logging_obj") as mock_logging, # test-quality-ok: module global imported at call time; no injection seam
|
||||
):
|
||||
mock_logging.post_call_failure_hook = AsyncMock()
|
||||
response = await ep.anthropic_response(
|
||||
fastapi_response=MagicMock(),
|
||||
request=request,
|
||||
user_api_key_dict=UserAPIKeyAuth(),
|
||||
)
|
||||
|
||||
assert response.status_code == 402
|
||||
assert response.headers["x-litellm-call-id"] == call_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_count_tokens_failure_log_carries_callers_call_id(self, caplog: pytest.LogCaptureFixture):
|
||||
from fastapi import HTTPException
|
||||
|
|
|
|||
|
|
@ -1976,6 +1976,24 @@ async def test_list__exception_calls_failure_hook(list_harness):
|
|||
assert list_harness.logging.post_call_failure_hook.call_args.kwargs["original_exception"].args[0] == "provider boom"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list__failure_hook_and_response_share_the_request_litellm_call_id(list_harness):
|
||||
call_id = "lit7836-list-batches-call-id"
|
||||
list_harness.pre_call.side_effect = lambda **kw: (
|
||||
{**list_harness.body["body"], "litellm_call_id": call_id},
|
||||
MagicMock(),
|
||||
)
|
||||
list_harness.litellm_alist.side_effect = ValueError("provider boom")
|
||||
|
||||
with pytest.raises(ProxyException) as raised:
|
||||
await call_list(list_harness, after="batch-0", limit=5)
|
||||
|
||||
failure_request_data = list_harness.logging.post_call_failure_hook.call_args.kwargs["request_data"]
|
||||
assert failure_request_data["litellm_call_id"] == call_id
|
||||
assert (failure_request_data["after"], failure_request_data["limit"]) == ("batch-0", 5)
|
||||
assert raised.value.headers["x-litellm-call-id"] == call_id
|
||||
|
||||
|
||||
# =========================================================================== #
|
||||
# #
|
||||
# POST /v1/batches/{batch_id}/cancel - cancel_batch routing-contract tests #
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from litellm.proxy.common_utils.openai_error_payload import (
|
|||
litellm_call_id_headers,
|
||||
openai_error_param,
|
||||
openai_error_type,
|
||||
with_litellm_call_id,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -164,3 +165,27 @@ def test_a_stringified_none_type_or_param_is_treated_as_absent():
|
|||
def test_a_failed_request_answers_with_the_call_id_it_was_logged_under():
|
||||
assert litellm_call_id_headers("call-7836") == {"x-litellm-call-id": "call-7836"}
|
||||
assert litellm_call_id_headers(None) is None
|
||||
|
||||
|
||||
def test_an_already_shaped_proxy_error_answers_with_the_call_id_it_was_logged_under():
|
||||
raised_without_id = ProxyException(message="budget exceeded", type="budget_exceeded", param="key", code=402)
|
||||
|
||||
carried = with_litellm_call_id(raised_without_id, "call-7836")
|
||||
|
||||
assert carried is raised_without_id
|
||||
assert carried.headers == {"x-litellm-call-id": "call-7836"}
|
||||
assert (carried.message, carried.type, carried.param, carried.code) == (
|
||||
"budget exceeded",
|
||||
"budget_exceeded",
|
||||
"key",
|
||||
"402",
|
||||
)
|
||||
|
||||
|
||||
def test_a_proxy_error_keeps_the_call_id_it_was_raised_with():
|
||||
raised_with_id = ProxyException(
|
||||
message="nope", type="None", param=None, code=400, headers={"x-litellm-call-id": "first"}
|
||||
)
|
||||
|
||||
assert with_litellm_call_id(raised_with_id, "second").headers == {"x-litellm-call-id": "first"}
|
||||
assert with_litellm_call_id(ProxyException(message="nope", type="None", param=None, code=400), None).headers == {}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import fastapi.routing
|
|||
import httpx
|
||||
import pytest
|
||||
import yaml
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.testclient import TestClient
|
||||
|
|
@ -32,7 +32,7 @@ from litellm.caching.caching import RedisCache
|
|||
from litellm.caching.redis_cluster_cache import RedisClusterCache
|
||||
from litellm.litellm_core_utils.get_model_cost_map import ModelCostMapReloaded
|
||||
from litellm.caching.dual_cache import DualCache
|
||||
from litellm.proxy._types import LitellmUserRoles, TokenCountRequest, UserAPIKeyAuth
|
||||
from litellm.proxy._types import LitellmUserRoles, ProxyErrorTypes, ProxyException, TokenCountRequest, UserAPIKeyAuth
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.proxy.hooks.parallel_request_limiter_v3 import RequestRateLimiterStash
|
||||
from litellm.proxy.proxy_server import app, initialize
|
||||
|
|
@ -12970,6 +12970,73 @@ async def test_moderations_failure_log_carries_the_callers_litellm_call_id(caplo
|
|||
assert call_id in record.getMessage()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_moderations_already_shaped_failure_answers_with_the_callers_litellm_call_id():
|
||||
"""LIT-7836: a ProxyException raised inside /v1/moderations is re-raised unwrapped but still
|
||||
answers with the caller's x-litellm-call-id so the client can join it to the error log."""
|
||||
call_id = "moderations-call-7836-shaped"
|
||||
exc = ProxyException(message="budget exceeded", type=ProxyErrorTypes.budget_exceeded, param="key", code=402)
|
||||
|
||||
request = MagicMock()
|
||||
request.headers = {"x-litellm-call-id": call_id}
|
||||
request.body = AsyncMock(return_value=b'{"input": "hi"}')
|
||||
fake_logging = MagicMock()
|
||||
fake_logging.post_call_failure_hook = AsyncMock()
|
||||
|
||||
with (
|
||||
patch.object(proxy_server_module, "add_litellm_data_to_request", new=AsyncMock(side_effect=exc)), # test-quality-ok: the route reads this module global, no injection point
|
||||
patch.object(proxy_server_module, "proxy_logging_obj", new=fake_logging), # test-quality-ok: module global, no injection point
|
||||
pytest.raises(ProxyException) as raised,
|
||||
):
|
||||
await proxy_server_module.moderations(
|
||||
request=request,
|
||||
fastapi_response=MagicMock(),
|
||||
user_api_key_dict=UserAPIKeyAuth(api_key="sk-test", spend=0.0),
|
||||
)
|
||||
|
||||
assert raised.value is exc
|
||||
assert raised.value.code == "402"
|
||||
assert raised.value.headers["x-litellm-call-id"] == call_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"exc",
|
||||
[
|
||||
HTTPException(status_code=401, detail="bad key"),
|
||||
ProxyException(message="budget exceeded", type=ProxyErrorTypes.budget_exceeded, param="key", code=402),
|
||||
],
|
||||
ids=["http_exception", "proxy_exception"],
|
||||
)
|
||||
async def test_audio_speech_already_shaped_failure_answers_with_the_callers_litellm_call_id(exc: Exception):
|
||||
"""LIT-7836: /v1/audio/speech re-raises HTTP and proxy shaped failures unchanged, and they must
|
||||
still answer with the caller's x-litellm-call-id."""
|
||||
call_id = "speech-call-7836-shaped"
|
||||
|
||||
request = MagicMock()
|
||||
request.headers = {"x-litellm-call-id": call_id}
|
||||
request.body = AsyncMock(return_value=b'{"model": "tts-1", "input": "hi", "voice": "alloy"}')
|
||||
fake_logging = MagicMock()
|
||||
fake_logging.post_call_failure_hook = AsyncMock()
|
||||
|
||||
with (
|
||||
patch.object(proxy_server_module, "add_litellm_data_to_request", new=AsyncMock(side_effect=exc)), # test-quality-ok: the route reads this module global, no injection point
|
||||
patch.object(proxy_server_module, "proxy_logging_obj", new=fake_logging), # test-quality-ok: module global, no injection point
|
||||
pytest.raises(type(exc)) as raised,
|
||||
):
|
||||
await proxy_server_module.audio_speech(
|
||||
request=request,
|
||||
fastapi_response=MagicMock(),
|
||||
user_api_key_dict=UserAPIKeyAuth(api_key="sk-test", spend=0.0),
|
||||
)
|
||||
|
||||
if isinstance(exc, HTTPException):
|
||||
assert (raised.value.status_code, raised.value.detail) == (401, "bad key")
|
||||
else:
|
||||
assert raised.value is exc
|
||||
assert raised.value.headers["x-litellm-call-id"] == call_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_init_agents_in_db_rebuilds_registry_under_agent_reconcile_lock(monkeypatch):
|
||||
from litellm.proxy.agent_endpoints.agent_registry import (
|
||||
|
|
|
|||
|
|
@ -178,8 +178,12 @@ def test_handle_exception_on_proxy_error_path_none_input_wraps_as_500():
|
|||
|
||||
@pytest.mark.parametrize(
|
||||
"exc",
|
||||
[HTTPException(status_code=401, detail="bad key"), ValueError("provider boom")],
|
||||
ids=["http_exception", "generic_exception"],
|
||||
[
|
||||
HTTPException(status_code=401, detail="bad key"),
|
||||
ValueError("provider boom"),
|
||||
ProxyException(message="already wrapped", type=ProxyErrorTypes.budget_exceeded.value, param="key", code=402),
|
||||
],
|
||||
ids=["http_exception", "generic_exception", "already_proxy_exception"],
|
||||
)
|
||||
def test_handle_exception_on_proxy_returns_the_litellm_call_id_header(exc: Exception):
|
||||
result = handle_exception_on_proxy(exc, "call-7836")
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue