diff --git a/litellm/litellm_core_utils/bug_report.py b/litellm/litellm_core_utils/bug_report.py new file mode 100644 index 00000000000..6b4cf992dad --- /dev/null +++ b/litellm/litellm_core_utils/bug_report.py @@ -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)}" + ) diff --git a/litellm/litellm_core_utils/exception_mapping_utils.py b/litellm/litellm_core_utils/exception_mapping_utils.py index 425714d730f..da2f11f2593 100644 --- a/litellm/litellm_core_utils/exception_mapping_utils.py +++ b/litellm/litellm_core_utils/exception_mapping_utils.py @@ -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 diff --git a/litellm/proxy/common_request_processing.py b/litellm/proxy/common_request_processing.py index f6e0d56127f..c95cc3e0529 100644 --- a/litellm/proxy/common_request_processing.py +++ b/litellm/proxy/common_request_processing.py @@ -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), diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 6fa7a7d761f..77246804dcd 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -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, diff --git a/litellm/proxy/utils.py b/litellm/proxy/utils.py index bfd412db1a7..b4846d529ab 100644 --- a/litellm/proxy/utils.py +++ b/litellm/proxy/utils.py @@ -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, diff --git a/tests/test_litellm/litellm_core_utils/test_bug_report.py b/tests/test_litellm/litellm_core_utils/test_bug_report.py new file mode 100644 index 00000000000..c9189ebbbdd --- /dev/null +++ b/tests/test_litellm/litellm_core_utils/test_bug_report.py @@ -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" diff --git a/tests/test_litellm/litellm_core_utils/test_exception_mapping_utils.py b/tests/test_litellm/litellm_core_utils/test_exception_mapping_utils.py index cfe7470fa76..5c5c2c9536b 100644 --- a/tests/test_litellm/litellm_core_utils/test_exception_mapping_utils.py +++ b/tests/test_litellm/litellm_core_utils/test_exception_mapping_utils.py @@ -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.""" diff --git a/tests/test_litellm/proxy/test_common_request_processing.py b/tests/test_litellm/proxy/test_common_request_processing.py index 218d8246715..af1604219d1 100644 --- a/tests/test_litellm/proxy/test_common_request_processing.py +++ b/tests/test_litellm/proxy/test_common_request_processing.py @@ -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.""" diff --git a/tests/test_litellm/proxy/test_proxy_utils.py b/tests/test_litellm/proxy/test_proxy_utils.py index 734408d9b61..0fc7295a717 100644 --- a/tests/test_litellm/proxy/test_proxy_utils.py +++ b/tests/test_litellm/proxy/test_proxy_utils.py @@ -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