feat(errors): prefilled GitHub issue link on unmapped internal errors (#42065)

* feat(errors): prefilled GitHub issue link on unmapped internal errors

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* refactor(errors): scope bug report link to unmapped exceptions

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(errors): cover bug report link on unmapped SDK exceptions

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(errors): exercise unmapped branch without changing openai mapping

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* style: format bug report changes

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(proxy): guard bug report generation for invalid exceptions

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(proxy): keep bug report notice out of client error bodies

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(errors): strip only the notice separator from client messages

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(errors): only link bug reports for exceptions without a provider status

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* refactor(errors): allowlist-only bug report link, drop exception message and model from URL

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(proxy): read request.url.path for the unhandled-exception bug report route

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(bug_report): drop non-string provider values before the allowlist lookup

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* refactor(bug_report): drop the OS line from the prefilled issue

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: ryan <ryan@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-09-21 22:30:41 -07:00 • committed by GitHub
parent 798d45970b
commit 4936427094
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 553 additions and 7 deletions

View file

@ -0,0 +1,155 @@
from __future__ import annotations
import os
import platform
import traceback
from dataclasses import dataclass
from pathlib import Path
from typing import Final, Literal
from urllib.parse import urlencode
import litellm
from litellm._version import version as litellm_version
from litellm.types.utils import LlmProviders
ISSUE_URL_BASE: Final = "https://github.com/BerriAI/litellm/issues/new"
MAX_URL_LENGTH: Final = 6000
MAX_FRAMES: Final = 12
DISABLE_ENV_VAR: Final = "LITELLM_DISABLE_BUG_REPORT_LINK"
NOTICE_PREFIX: Final = "This looks like a bug in LiteLLM rather than in your request."
KNOWN_PROVIDERS: Final = frozenset(provider.value for provider in LlmProviders)
Surface = Literal["sdk", "proxy"]
@dataclass(frozen=True, slots=True)
class BugReport:
surface: Surface
exception_type: str
litellm_frames: tuple[str, ...]
litellm_version: str
python_version: str
call_type: str | None
custom_llm_provider: str | None
def bug_report_enabled() -> bool:
return os.getenv(DISABLE_ENV_VAR, "").lower() != "true"
def should_report_bug(exc: object) -> bool:
return bug_report_enabled() and isinstance(exc, BaseException) and getattr(exc, "status_code", None) is None
def _format_frame(frame: traceback.FrameSummary, package_dir: Path, package_parent: Path) -> str | None:
frame_path: Final = Path(frame.filename).resolve()
try:
frame_path.relative_to(package_dir)
relative_path: Final = frame_path.relative_to(package_parent)
except ValueError:
return None
return f"{relative_path.as_posix()}:{frame.lineno} in {frame.name}"
def _get_litellm_frames(exc: BaseException) -> tuple[str, ...]:
if exc.__traceback__ is None:
return ()
package_dir: Final = Path(litellm.__file__).resolve().parent
package_parent: Final = package_dir.parent
return tuple(
frame_text
for frame in traceback.extract_tb(exc.__traceback__)
if (frame_text := _format_frame(frame, package_dir, package_parent)) is not None
)[-MAX_FRAMES:]
def allowlisted(value: object, allowed: frozenset[str]) -> str | None:
return value if isinstance(value, str) and value in allowed else None
def build_bug_report(
exc: BaseException,
*,
surface: Surface,
call_type: str | None = None,
custom_llm_provider: object = None,
) -> BugReport:
return BugReport(
surface=surface,
exception_type=type(exc).__name__,
litellm_frames=_get_litellm_frames(exc),
litellm_version=litellm_version,
python_version=platform.python_version(),
call_type=call_type,
custom_llm_provider=allowlisted(custom_llm_provider, KNOWN_PROVIDERS),
)
def _domain(report: BugReport) -> str:
if report.surface == "sdk":
return "Python SDK: the litellm package itself"
if report.custom_llm_provider is not None:
return "LLM translation: a specific provider's request or response"
return "Proxy core: startup, config, health checks, endpoints"
def _title(report: BugReport, frames: tuple[str, ...]) -> str:
location: Final = frames[-1].split(":", 1)[0] if frames else "litellm"
return f"[Bug]: {report.exception_type} in {location}"
def _description(report: BugReport, frames: tuple[str, ...]) -> str:
frame_block: Final = "LiteLLM frames:\n```\n" + "\n".join(frames) + "\n```\n\n" if frames else ""
return (
"Auto-generated by LiteLLM's bug report link. It carries no request data or error text. "
"Please describe what you were doing, and paste the error message from your log below "
"if it contains nothing sensitive.\n\n"
"```\n\n```\n\n"
f"Exception: `{report.exception_type}`\n\n"
f"{frame_block}"
f"Surface: {report.surface}\n"
f"Endpoint / call: {report.call_type or 'unknown'}\n"
f"Provider: {report.custom_llm_provider or 'unknown'}\n"
f"LiteLLM: {report.litellm_version}\n"
f"Python: {report.python_version}\n"
)
def _issue_url(report: BugReport, frames: tuple[str, ...]) -> str:
deployment: Final[tuple[tuple[str, str], ...]] = (
(("deployment", "pip / Python SDK"),)
if report.surface == "sdk"
else (("deployment", "Docker"),)
if os.path.exists("/.dockerenv")
else ()
)
fields: Final = (
("template", "bug_report.yml"),
("labels", "bug"),
("title", _title(report, frames)),
("version", report.litellm_version),
("domain", _domain(report)),
("description", _description(report, frames)),
) + deployment
return f"{ISSUE_URL_BASE}?{urlencode(fields)}"
def bug_report_issue_url(report: BugReport) -> str:
frames: Final = report.litellm_frames
candidates: Final = tuple(_issue_url(report, frames[index:]) for index in range(len(frames) + 1))
return next((candidate for candidate in candidates if len(candidate) <= MAX_URL_LENGTH), candidates[-1])
def strip_bug_report_notice(message: str) -> str:
index: Final = message.find(NOTICE_PREFIX)
if index == -1:
return message
head: Final = message[:index]
return head.removesuffix("\n")
def bug_report_notice(report: BugReport) -> str:
return (
f"{NOTICE_PREFIX} File it with one click "
f"(prefilled, no request data or error text, review before submitting): {bug_report_issue_url(report)}"
)

View file

@ -10,6 +10,11 @@ import httpx
import litellm
from litellm._logging import _ENABLE_SECRET_REDACTION, _redact_string, verbose_logger
from litellm.litellm_core_utils.bug_report import (
bug_report_notice,
build_bug_report,
should_report_bug,
)
from litellm.litellm_core_utils.secret_redaction import redact_string
from litellm.types.utils import LlmProviders
@ -2673,7 +2678,21 @@ def exception_type(
)
else:
raise APIConnectionError(
message=f"{original_exception}\n{_redact_string(traceback.format_exc())}",
message=(
f"{original_exception}\n{_redact_string(traceback.format_exc())}"
+ (
"\n"
+ bug_report_notice(
build_bug_report(
original_exception,
surface="sdk",
custom_llm_provider=custom_llm_provider,
)
)
if should_report_bug(original_exception)
else ""
)
),
llm_provider=custom_llm_provider,
model=model,
request=httpx.Request(method="POST", url="https://api.openai.com/v1/"), # stub the request

View file

@ -19,6 +19,7 @@ from typing import (
overload,
runtime_checkable,
)
from urllib.parse import urlparse
import anyio
import httpx
@ -45,6 +46,13 @@ from litellm.constants import (
UNSAFE_PROXY_RESPONSE_HEADERS,
)
from litellm.integrations.custom_guardrail import CustomGuardrail
from litellm.litellm_core_utils.bug_report import (
allowlisted,
bug_report_notice,
build_bug_report,
should_report_bug,
strip_bug_report_notice,
)
from litellm.litellm_core_utils.core_helpers import (
get_or_create_metadata_bucket,
independent_snapshot,
@ -64,7 +72,7 @@ from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
from litellm.litellm_core_utils.streaming_handler import (
backfill_missing_cache_usage_fields,
)
from litellm.proxy._types import ProxyErrorTypes, ProxyException, UserAPIKeyAuth
from litellm.proxy._types import LiteLLMRoutes, ProxyErrorTypes, ProxyException, UserAPIKeyAuth
from litellm.proxy.auth.auth_checks import (
can_key_call_resolved_model,
request_skips_budget_checks,
@ -107,6 +115,10 @@ from litellm.types.router_weights import validate_router_weights
_LateResponseT = TypeVar("_LateResponseT", bound=Response)
_LlmCallT = TypeVar("_LlmCallT")
KNOWN_PROXY_ROUTES: Final = frozenset(
route for member in LiteLLMRoutes for route in member.value if route.startswith("/")
)
ProxyRouteType: TypeAlias = Literal[
"acompletion",
"aembedding",
@ -3669,8 +3681,27 @@ class ProxyBaseLLMRequestProcessing:
_code = _exc_status_code
else:
_code = status.HTTP_500_INTERNAL_SERVER_ERROR
if should_report_bug(e):
proxy_server_request: Final = self.data.get("proxy_server_request")
request_url: Final = (
proxy_server_request.get("url") if isinstance(proxy_server_request, Mapping) else None
)
request_path: Final = urlparse(str(request_url)).path if request_url is not None else None
verbose_proxy_logger.error(
bug_report_notice(
build_bug_report(
e,
surface="proxy",
call_type=allowlisted(request_path, KNOWN_PROXY_ROUTES),
custom_llm_provider=self.data.get("custom_llm_provider"),
)
)
)
client_message: Final = getattr(e, "message", error_msg)
raise ProxyException(
message=redact_internal_details_from_client_message(getattr(e, "message", error_msg)),
message=redact_internal_details_from_client_message(
strip_bug_report_notice(client_message) if isinstance(client_message, str) else error_msg
),
type=openai_error_type(e, _code),
param=openai_error_param(e),
openai_code=getattr(e, "code", None),

View file

@ -75,6 +75,12 @@ from litellm.constants import (
RUNTIME_UPDATABLE_ROUTER_SETTINGS,
)
from litellm.litellm_core_utils.asyncify import asyncify
from litellm.litellm_core_utils.bug_report import (
allowlisted,
bug_report_notice,
build_bug_report,
should_report_bug,
)
from litellm.litellm_core_utils.litellm_logging import (
_init_custom_logger_compatible_class,
)
@ -371,6 +377,7 @@ from litellm.proxy.batches_endpoints.endpoints import router as batches_router
## Import All Misc routes here ##
from litellm.proxy.caching_routes import router as caching_router
from litellm.proxy.common_request_processing import (
KNOWN_PROXY_ROUTES,
ProxyBaseLLMRequestProcessing,
_is_azure_model_router_request,
_should_return_raw_model_name,
@ -1965,6 +1972,16 @@ async def otel_unhandled_exception_handler(request: Request, exc: Exception):
if isinstance(exc, (ProxyException, HTTPException, RequestValidationError)):
raise exc
verbose_proxy_logger.exception("Unhandled exception in request: %s", type(exc).__name__)
if should_report_bug(exc):
verbose_proxy_logger.error(
bug_report_notice(
build_bug_report(
exc,
surface="proxy",
call_type=allowlisted(request.url.path, KNOWN_PROXY_ROUTES),
)
)
)
_close_dangling_otel_server_span(request, 500, exc=exc)
return JSONResponse(
status_code=500,

View file

@ -57,6 +57,12 @@ from litellm.constants import (
SPEND_LOG_WRITE_BATCH_MAX_BYTES,
SPEND_LOG_WRITE_BATCH_MAX_ROWS,
)
from litellm.litellm_core_utils.bug_report import (
bug_report_notice,
build_bug_report,
should_report_bug,
strip_bug_report_notice,
)
from litellm.proxy._types import (
CommonProxyErrors,
ProxyErrorTypes,
@ -7989,8 +7995,10 @@ def handle_exception_on_proxy(e: Exception, litellm_call_id: str | None = None)
elif isinstance(e, ProxyException):
return with_litellm_call_id(e, litellm_call_id)
_status_code: Final = getattr(e, "status_code", status.HTTP_500_INTERNAL_SERVER_ERROR)
if should_report_bug(e):
verbose_proxy_logger.error(bug_report_notice(build_bug_report(e, surface="proxy")))
return ProxyException(
message=str(e),
message=strip_bug_report_notice(str(e)),
type=ProxyErrorTypes.internal_server_error,
param=openai_error_param(e),
headers=headers,

View file

@ -0,0 +1,170 @@
from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
from typing import cast
from urllib.parse import parse_qs, urlparse
import httpx
import pytest
import litellm
from litellm._version import version
from litellm.exceptions import APIConnectionError, BadRequestError, InternalServerError
from litellm.litellm_core_utils.bug_report import (
DISABLE_ENV_VAR,
ISSUE_URL_BASE,
MAX_FRAMES,
MAX_URL_LENGTH,
allowlisted,
bug_report_enabled,
bug_report_issue_url,
bug_report_notice,
build_bug_report,
should_report_bug,
strip_bug_report_notice,
)
from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider
def test_build_bug_report_keeps_only_litellm_frames():
with pytest.raises(BadRequestError) as raised:
get_llm_provider(cast(str, None))
report = build_bug_report(raised.value, surface="sdk")
assert report.litellm_frames
assert all(frame.startswith("litellm/") for frame in report.litellm_frames)
assert all("test_bug_report.py" not in frame for frame in report.litellm_frames)
def test_issue_url_never_contains_the_exception_message():
secret = "sk-abcdefghijklmnopqrstuvwxyz1234567890"
prompt = "my social security number is 123-45-6789"
report = build_bug_report(RuntimeError(f"{secret} {prompt}"), surface="sdk")
url = bug_report_issue_url(report)
query = parse_qs(urlparse(url).query)
assert url.startswith(ISSUE_URL_BASE)
assert secret not in url and "123-45-6789" not in url and "social" not in url
assert query["title"] == ["[Bug]: RuntimeError in litellm"]
assert query["version"] == [version]
assert query["template"] == ["bug_report.yml"]
assert query["domain"] == ["Python SDK: the litellm package itself"]
assert query["deployment"] == ["pip / Python SDK"]
assert "Exception: `RuntimeError`" in query["description"][0]
assert "Python:" in query["description"][0]
def test_issue_url_drops_unknown_provider_and_call_type():
report = build_bug_report(
ValueError("boom"),
surface="proxy",
custom_llm_provider="acme-internal-gateway",
)
query = parse_qs(urlparse(bug_report_issue_url(report)).query)
assert report.custom_llm_provider is None
assert "acme" not in bug_report_issue_url(report)
assert "Provider: unknown" in query["description"][0]
assert "Endpoint / call: unknown" in query["description"][0]
def test_allowlisted_only_passes_exact_members():
allowed = frozenset({"/v1/chat/completions"})
assert allowlisted("/v1/chat/completions", allowed) == "/v1/chat/completions"
assert allowlisted("/v1/chat/completions/../../admin", allowed) is None
assert allowlisted(None, allowed) is None
assert allowlisted(["/v1/chat/completions"], allowed) is None
assert allowlisted({"provider": "openai"}, allowed) is None
def test_build_bug_report_survives_unhashable_provider_from_request_data():
report = build_bug_report(
KeyError("missing"),
surface="proxy",
custom_llm_provider={"name": "openai"},
)
assert report.custom_llm_provider is None
def test_frames_are_capped_and_url_is_bounded():
namespace: dict[str, object] = {}
exec(
compile(
"def recurse(depth):\n if depth == 0:\n raise RuntimeError('deep')\n recurse(depth - 1)\n",
str(Path(litellm.__file__).with_name("fake_deep_module.py")),
"exec",
),
namespace,
)
recurse = cast(Callable[[int], None], namespace["recurse"])
with pytest.raises(RuntimeError) as raised:
recurse(200)
report = build_bug_report(raised.value, surface="proxy")
assert len(report.litellm_frames) == MAX_FRAMES
assert all(frame.startswith("litellm/fake_deep_module.py:") for frame in report.litellm_frames)
assert len(bug_report_issue_url(report)) <= MAX_URL_LENGTH
def test_issue_url_builds_without_a_traceback():
exc = RuntimeError("no traceback")
assert exc.__traceback__ is None
report = build_bug_report(exc, surface="proxy")
assert report.litellm_frames == ()
assert bug_report_issue_url(report).startswith(ISSUE_URL_BASE)
def test_bug_report_can_be_disabled(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv(DISABLE_ENV_VAR, "true")
assert bug_report_enabled() is False
assert should_report_bug(RuntimeError("boom")) is False
@pytest.mark.parametrize(
"exc",
[
InternalServerError(message="upstream 500", llm_provider="openai", model="gpt-4"),
APIConnectionError(
message="connection reset",
llm_provider="openai",
model="gpt-4",
request=httpx.Request(method="POST", url="https://api.openai.com/v1/"),
),
BadRequestError(message="bad input", llm_provider="openai", model="gpt-4"),
"not an exception",
],
)
def test_should_report_bug_skips_provider_and_network_errors(exc: object):
assert should_report_bug(exc) is False
def test_should_report_bug_accepts_plain_python_errors():
assert should_report_bug(KeyError("missing")) is True
def test_proxy_known_provider_uses_translation_domain():
report = build_bug_report(
RuntimeError("proxy failure"),
surface="proxy",
call_type="/v1/chat/completions",
custom_llm_provider="openai",
)
query = parse_qs(urlparse(bug_report_issue_url(report)).query)
assert report.custom_llm_provider == "openai"
assert query["domain"] == ["LLM translation: a specific provider's request or response"]
assert "Endpoint / call: /v1/chat/completions" in query["description"][0]
def test_strip_bug_report_notice():
report = build_bug_report(RuntimeError("boom"), surface="sdk")
notice = bug_report_notice(report)
assert strip_bug_report_notice(f"boom\n\n{notice}") == "boom\n"
assert strip_bug_report_notice("boom") == "boom"

View file

@ -3,8 +3,6 @@ import openai
import pytest
import litellm
from litellm.litellm_core_utils.exception_mapping_utils import (
ExceptionCheckers,
_get_body_error_code,
@ -974,6 +972,31 @@ def test_an_unmapped_exception_with_no_model_or_provider_is_a_connection_error(q
assert "boom" in raised.value.message
def test_unmapped_sdk_exception_includes_bug_report_link(quiet_exception_mapping):
with pytest.raises(litellm.APIConnectionError) as raised:
exception_type(
model="my-model",
custom_llm_provider="minimax",
original_exception=ValueError("boom"),
)
assert "https://github.com/BerriAI/litellm/issues/new?" in str(raised.value)
assert "ValueError" in str(raised.value)
def test_unmapped_sdk_exception_bug_report_link_can_be_disabled(quiet_exception_mapping, monkeypatch):
monkeypatch.setenv("LITELLM_DISABLE_BUG_REPORT_LINK", "true")
with pytest.raises(litellm.APIConnectionError) as raised:
exception_type(
model="my-model",
custom_llm_provider="minimax",
original_exception=ValueError("boom"),
)
assert "https://github.com/BerriAI/litellm/issues/new?" not in str(raised.value)
def _raise_and_map(model: str | None, original_exception: Exception, custom_llm_provider: str | None) -> None:
"""Calls exception_type() from inside the except block, as litellm/main.py does,
so traceback.format_exc() has a real stack."""

View file

@ -4,6 +4,7 @@ import datetime
import json
from types import MappingProxyType, SimpleNamespace
from typing import AsyncGenerator, Callable, Final, Iterator, Optional, Sequence
from urllib.parse import unquote_plus
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
@ -13,6 +14,12 @@ from fastapi.responses import JSONResponse, StreamingResponse
import litellm
from litellm._uuid import uuid
from litellm.litellm_core_utils.bug_report import (
DISABLE_ENV_VAR,
ISSUE_URL_BASE,
bug_report_notice,
build_bug_report,
)
from litellm.constants import (
CLIENT_REQUESTED_MODEL_SCOPE_KEY,
MAX_LITELLM_CALL_ID_LENGTH,
@ -4290,6 +4297,21 @@ class TestHandleLLMApiExceptionRetryAfter:
proxy_exc = await self._invoke(ValueError("some other failure"))
assert "retry-after" not in proxy_exc.headers
async def test_handle_llm_api_exception_strips_bug_report_notice_from_client_message(self, caplog):
report = build_bug_report(RuntimeError("boom"), surface="sdk")
notice = bug_report_notice(report)
exc = litellm.APIConnectionError(
message=f"boom\n{notice}",
model="gpt-4o",
llm_provider="openai",
)
with caplog.at_level("ERROR"):
proxy_exc = await self._invoke(exc)
assert ISSUE_URL_BASE not in proxy_exc.message
assert ISSUE_URL_BASE in caplog.text
async def test_handle_llm_api_exception_retry_after_survives_callback_headers(self):
from litellm.types.router import RouterRateLimitError
@ -9374,6 +9396,92 @@ async def test_handle_llm_api_exception_forwards_litellm_response_headers_when_r
assert exc_info.value.headers["llm_provider-x-request-id"] == "req_openai_400"
@pytest.mark.asyncio
async def test_handle_llm_api_exception_logs_bug_report_for_unmapped_error(
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
):
monkeypatch.delenv(DISABLE_ENV_VAR, raising=False)
processor = ProxyBaseLLMRequestProcessing(
data={
"proxy_server_request": {"url": "https://example.test/v1/chat/completions?debug=true"},
"model": "acme-prod-gpt4",
"custom_llm_provider": "openai",
}
)
proxy_logging_obj = MagicMock()
proxy_logging_obj.post_call_failure_hook = AsyncMock(return_value=None)
proxy_logging_obj.post_call_response_headers_hook = AsyncMock(return_value={})
with caplog.at_level("ERROR", logger="LiteLLM Proxy"):
with pytest.raises(ProxyException):
await processor._handle_llm_api_exception(
e=RuntimeError("unmapped for user@example.com"),
user_api_key_dict=ProxyUserAPIKeyAuth(api_key="sk-test"),
proxy_logging_obj=proxy_logging_obj,
)
issue_url = next(word for word in caplog.text.split() if word.startswith(ISSUE_URL_BASE))
assert "Endpoint / call: /v1/chat/completions" in unquote_plus(issue_url)
assert "Provider: openai" in unquote_plus(issue_url)
assert "acme-prod-gpt4" not in unquote_plus(issue_url)
assert "user@example.com" not in unquote_plus(issue_url)
@pytest.mark.asyncio
async def test_handle_llm_api_exception_bug_report_drops_unknown_route(
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
):
monkeypatch.delenv(DISABLE_ENV_VAR, raising=False)
processor = ProxyBaseLLMRequestProcessing(
data={"proxy_server_request": {"url": "https://example.test/v1/files/file-customer-123/content"}}
)
proxy_logging_obj = MagicMock()
proxy_logging_obj.post_call_failure_hook = AsyncMock(return_value=None)
proxy_logging_obj.post_call_response_headers_hook = AsyncMock(return_value={})
with caplog.at_level("ERROR", logger="LiteLLM Proxy"):
with pytest.raises(ProxyException):
await processor._handle_llm_api_exception(
e=RuntimeError("unmapped"),
user_api_key_dict=ProxyUserAPIKeyAuth(api_key="sk-test"),
proxy_logging_obj=proxy_logging_obj,
)
issue_url = next(word for word in caplog.text.split() if word.startswith(ISSUE_URL_BASE))
assert "Endpoint / call: unknown" in unquote_plus(issue_url)
assert "file-customer-123" not in unquote_plus(issue_url)
@pytest.mark.asyncio
async def test_handle_llm_api_exception_skips_bug_report_for_provider_status(
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
):
monkeypatch.delenv(DISABLE_ENV_VAR, raising=False)
class ProviderRateLimitError(Exception):
def __init__(self, message: str):
super().__init__(message)
self.status_code = 429
processor = ProxyBaseLLMRequestProcessing(data={})
proxy_logging_obj = MagicMock()
proxy_logging_obj.post_call_failure_hook = AsyncMock(return_value=None)
proxy_logging_obj.post_call_response_headers_hook = AsyncMock(return_value={})
with caplog.at_level("ERROR", logger="LiteLLM Proxy"):
with pytest.raises(ProxyException):
await processor._handle_llm_api_exception(
e=ProviderRateLimitError("rate limited"),
user_api_key_dict=ProxyUserAPIKeyAuth(api_key="sk-test"),
proxy_logging_obj=proxy_logging_obj,
)
assert ISSUE_URL_BASE not in caplog.text
class TestBackgroundResponseRetrievalGovernance:
"""LIT-7175: retrieving a background Response attaches the model's post_call policy pipelines."""

View file

@ -6,10 +6,12 @@ import pytest
from fastapi import HTTPException
from litellm.caching.caching import DualCache
from litellm.exceptions import InternalServerError
from litellm.integrations.custom_guardrail import CustomGuardrail
from litellm.integrations.custom_logger import CustomLogger
from litellm.litellm_core_utils.bug_report import ISSUE_URL_BASE
from litellm.proxy._types import ProxyErrorTypes, UserAPIKeyAuth
from litellm.proxy.utils import PrismaClient, ProxyLogging
from litellm.proxy.utils import PrismaClient, ProxyLogging, handle_exception_on_proxy
from litellm.types.guardrails import GuardrailEventHooks
@ -2418,3 +2420,16 @@ def test_mcp_auth_policy_uses_original_request_model(monkeypatch, model, expecte
synthetic = proxy_logging._convert_mcp_to_llm_format(proxy_logging._create_mcp_request_object_from_kwargs(kwargs), kwargs)
assert ("model-rule" in synthetic["metadata"]["guardrails"]) is expected
assert "request-rule" in synthetic["metadata"]["guardrails"]
def test_handle_exception_on_proxy_logs_bug_report_only_for_unmapped_500(caplog):
with caplog.at_level("ERROR", logger="LiteLLM Proxy"):
provider_result = handle_exception_on_proxy(
InternalServerError(message="upstream 500", llm_provider="openai", model="gpt-4")
)
assert ISSUE_URL_BASE not in caplog.text
internal_result = handle_exception_on_proxy(KeyError("missing"))
assert provider_result.code == internal_result.code == "500"
assert ISSUE_URL_BASE in caplog.text
assert ISSUE_URL_BASE not in internal_result.message