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
53 changes: 34 additions & 19 deletions litellm/litellm_core_utils/bug_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,22 @@


@dataclass(frozen=True, slots=True)
class BugReport:
class EnvironmentReport:
surface: Surface
exception_type: str
litellm_frames: tuple[str, ...]
litellm_version: str
python_version: str
deployment: str | None
config_lines: tuple[str, ...]


@dataclass(frozen=True, slots=True)
class BugReport:
environment: EnvironmentReport
exception_type: str
litellm_frames: tuple[str, ...]
call_type: str | None
custom_llm_provider: str | None
stream: bool | None
config_lines: tuple[str, ...]


def bug_report_enabled() -> bool:
Expand Down Expand Up @@ -69,6 +75,22 @@ def allowlisted(value: object, allowed: frozenset[str]) -> str | None:
return value if isinstance(value, str) and value in allowed else None


def _deployment(surface: Surface) -> str | None:
if surface == "sdk":
return "pip / Python SDK"
return "Docker" if os.path.exists("/.dockerenv") else None


def build_environment_report(*, surface: Surface, config_lines: tuple[str, ...] = ()) -> EnvironmentReport:
return EnvironmentReport(
surface=surface,
litellm_version=litellm_version,
python_version=platform.python_version(),
deployment=_deployment(surface),
config_lines=config_lines,
)


def build_bug_report(
exc: BaseException,
*,
Expand All @@ -79,20 +101,17 @@ def build_bug_report(
config_lines: tuple[str, ...] = (),
) -> BugReport:
return BugReport(
surface=surface,
environment=build_environment_report(surface=surface, config_lines=config_lines),
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),
stream=stream if isinstance(stream, bool) else None,
config_lines=config_lines,
)


def _domain(report: BugReport) -> str:
if report.surface == "sdk":
if report.environment.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"
Expand All @@ -119,29 +138,25 @@ def _description(report: BugReport, frames: tuple[str, ...], config_lines: tuple
"```\n\n```\n\n"
f"Exception: `{report.exception_type}`\n\n"
f"{frame_block}"
f"Surface: {report.surface}\n"
f"Surface: {report.environment.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"
f"LiteLLM: {report.environment.litellm_version}\n"
f"Python: {report.environment.python_version}\n"
f"{stream_line}"
f"{config_block}"
)


def _issue_url(report: BugReport, frames: tuple[str, ...], config_lines: 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 ()
() if report.environment.deployment is None else (("deployment", report.environment.deployment),)
)
fields: Final = (
("template", "bug_report.yml"),
("labels", "bug"),
("title", _title(report, frames)),
("version", report.litellm_version),
("version", report.environment.litellm_version),
("domain", _domain(report)),
("description", _description(report, frames, config_lines)),
) + deployment
Expand All @@ -150,7 +165,7 @@ def _issue_url(report: BugReport, frames: tuple[str, ...], config_lines: tuple[s

def bug_report_issue_url(report: BugReport) -> str:
frames: Final = report.litellm_frames
config_lines: Final = report.config_lines
config_lines: Final = report.environment.config_lines
candidates: Final = (
*((frames, config_lines[:count]) for count in range(len(config_lines), -1, -1)),
*((frames[index:], ()) for index in range(1, len(frames) + 1)),
Expand Down
29 changes: 22 additions & 7 deletions litellm/proxy/bug_report_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,14 @@
from pydantic import JsonValue, TypeAdapter, ValidationError

import litellm
from litellm.litellm_core_utils.bug_report import KNOWN_PROVIDERS, BugReport, allowlisted, build_bug_report
from litellm.litellm_core_utils.bug_report import (
KNOWN_PROVIDERS,
BugReport,
EnvironmentReport,
allowlisted,
build_bug_report,
build_environment_report,
)
from litellm.proxy._types import ConfigGeneralSettings
from litellm.router_utils.routing_groups import VALID_ROUTING_STRATEGIES
from litellm.types.caching import LiteLLMCacheType
Expand Down Expand Up @@ -191,23 +198,31 @@ def safe_config_lines(config: Mapping[str, object], general_settings: Mapping[st
)


def _proxy_config_lines() -> tuple[str, ...]:
from litellm.proxy import proxy_server

return safe_config_lines(
proxy_server.proxy_config.config,
_object_map(proxy_server.general_settings), # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # bare dict global, validated by _object_map
)


def build_proxy_environment_report() -> EnvironmentReport:
return build_environment_report(surface="proxy", config_lines=_proxy_config_lines())


def build_proxy_bug_report(
exc: BaseException,
*,
call_type: str | None = None,
custom_llm_provider: object = None,
stream: object = None,
) -> BugReport:
from litellm.proxy import proxy_server

return build_bug_report(
exc,
surface="proxy",
call_type=call_type,
custom_llm_provider=custom_llm_provider,
stream=stream,
config_lines=safe_config_lines(
proxy_server.proxy_config.config,
_object_map(proxy_server.general_settings), # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # bare dict global, validated by _object_map
),
config_lines=_proxy_config_lines(),
)
22 changes: 21 additions & 1 deletion litellm/proxy/common_utils/debug_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,19 @@
import tracemalloc
from collections import Counter
from collections.abc import Mapping, Sequence
from typing import Any, Final, NamedTuple, Protocol, TypedDict
from typing import Annotated, Any, Final, NamedTuple, Protocol, TypedDict

from fastapi import APIRouter, Depends, HTTPException, Query
from typing_extensions import ReadOnly

from litellm import get_secret_str
from litellm._logging import verbose_proxy_logger
from litellm.constants import PYTHON_GC_THRESHOLD
from litellm.litellm_core_utils.bug_report import EnvironmentReport
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.bug_report_config import build_proxy_environment_report
from litellm.proxy.common_utils.resource_ownership import is_proxy_admin

router: Final = APIRouter()

Expand Down Expand Up @@ -783,6 +786,23 @@ async def configure_gc_thresholds_endpoint(
}


@router.get("/debug/report", include_in_schema=False)
async def get_debug_report(
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
) -> EnvironmentReport:
"""
The same LiteLLM-owned environment facts the bug report link puts in a GitHub issue:
versions, deployment kind, and config flags whose keys and values LiteLLM defines.
Nothing from the operator's config values, request data, or errors

Example usage:
curl http://localhost:4000/debug/report -H "Authorization: Bearer sk-1234"
"""
if not is_proxy_admin(user_api_key_dict):
raise HTTPException(status_code=403, detail="Only proxy admins can read /debug/report")
return build_proxy_environment_report()


@router.get(
"/otel-spans",
dependencies=[Depends(user_api_key_auth)],
Expand Down
26 changes: 26 additions & 0 deletions tests/test_litellm/litellm_core_utils/test_bug_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
bug_report_issue_url,
bug_report_notice,
build_bug_report,
build_environment_report,
should_report_bug,
strip_bug_report_notice,
)
Expand Down Expand Up @@ -202,3 +203,28 @@ def test_oversized_config_is_trimmed_from_the_end_before_any_frame():
assert all(frame in description for frame in report.litellm_frames)
assert "general_settings.flag_0000 = true" in description
assert "general_settings.flag_0399 = true" not in description


def test_issue_url_carries_exactly_the_environment_report_fields():
report = build_bug_report(
RuntimeError("boom"),
surface="proxy",
config_lines=("litellm_settings.drop_params = true",),
)
environment = report.environment
query = parse_qs(urlparse(bug_report_issue_url(report)).query)
description = query["description"][0]

assert environment == build_environment_report(
surface="proxy", config_lines=("litellm_settings.drop_params = true",)
)
assert query["version"] == [environment.litellm_version]
assert f"Surface: {environment.surface}\n" in description
assert f"LiteLLM: {environment.litellm_version}\n" in description
assert f"Python: {environment.python_version}\n" in description
assert "\nlitellm_settings.drop_params = true\n" in description
assert query.get("deployment") == (None if environment.deployment is None else [environment.deployment])


def test_sdk_environment_reports_the_pip_deployment():
assert build_environment_report(surface="sdk").deployment == "pip / Python SDK"
81 changes: 80 additions & 1 deletion tests/test_litellm/proxy/common_utils/test_debug_utils.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,25 @@
import json
import os
import socket
from collections.abc import Iterator, Mapping
from dataclasses import asdict
from pathlib import Path

import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
Comment thread
greptile-apps[bot] marked this conversation as resolved.

from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy import proxy_server
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.bug_report_config import build_proxy_bug_report
from litellm.proxy.common_utils.debug_utils import (
PSUTIL_MISSING_ERROR,
_ProcFilesystemProcess,
_summary_process_memory,
get_memory_summary,
)
from litellm.proxy.common_utils.debug_utils import router as debug_router

PAGE_SIZE = 4096
STATM_SIZE_PAGES = 100_000
Expand Down Expand Up @@ -67,3 +76,73 @@ async def test_memory_summary_names_the_host_and_worker_that_answered() -> None:
assert summary["hostname"] == socket.gethostname()
assert summary["worker_pid"] == os.getpid()
assert summary["memory"]["ram_usage_mb"] > 0


HOSTILE_CONFIG: Mapping[str, object] = {
"model_list": [
{
"model_name": "acme-prod-gpt4",
"litellm_params": {
"model": "azure/acme-gpt4o-deployment",
"api_base": "https://acme-eastus.openai.azure.com",
"api_key": "sk-live-secret-1",
},
}
],
"litellm_settings": {"drop_params": True, "callbacks": ["langfuse", "acme_hooks.audit_logger"]},
}

HOSTILE_GENERAL_SETTINGS: Mapping[str, object] = {
"master_key": "sk-live-secret-master",
"database_url": "postgres://user:hunter2@10.0.0.7/litellm",
"store_model_in_db": True,
}

HOSTILE_STRINGS = ("acme", "sk-live-secret", "hunter2", "10.0.0.7", "azure.com")
Comment thread
greptile-apps[bot] marked this conversation as resolved.


@pytest.fixture
def hostile_proxy_config(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
previous_config = proxy_server.proxy_config.get_config_state()
proxy_server.proxy_config.update_config_state(config=HOSTILE_CONFIG)
monkeypatch.setattr(proxy_server, "general_settings", dict(HOSTILE_GENERAL_SETTINGS))
yield
proxy_server.proxy_config.update_config_state(config=previous_config)


def _debug_client(caller: UserAPIKeyAuth) -> TestClient:
app = FastAPI()
app.include_router(debug_router)
app.dependency_overrides[user_api_key_auth] = lambda: caller
return TestClient(app)


@pytest.mark.parametrize(
"caller",
[
UserAPIKeyAuth(),
UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER),
UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY),
],
)
@pytest.mark.usefixtures("hostile_proxy_config")
def test_debug_report_refuses_everyone_but_proxy_admins(caller: UserAPIKeyAuth) -> None:
response = _debug_client(caller).get("/debug/report")

assert response.status_code == 403, response.text
assert "litellm_version" not in response.text


@pytest.mark.usefixtures("hostile_proxy_config")
def test_debug_report_returns_what_the_bug_report_link_carries_and_nothing_from_the_operator() -> None:
response = _debug_client(UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN)).get("/debug/report")

assert response.status_code == 200, response.text
assert response.json() == json.loads(json.dumps(asdict(build_proxy_bug_report(RuntimeError("boom")).environment)))
assert response.json()["config_lines"] == [
"general_settings.store_model_in_db = true",
"litellm_settings.drop_params = true",
"litellm_settings.callbacks = [langfuse]",
"model_list[*].provider = [azure]",
]
assert not any(hostile in response.text for hostile in HOSTILE_STRINGS), response.text
14 changes: 11 additions & 3 deletions tests/test_litellm/proxy/test_bug_report_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import pytest

from litellm.proxy import proxy_server
from litellm.proxy.bug_report_config import build_proxy_bug_report, safe_config_lines
from litellm.proxy.bug_report_config import build_proxy_bug_report, build_proxy_environment_report, safe_config_lines

CUSTOMER_STRINGS = (
"acme",
Expand Down Expand Up @@ -193,6 +193,14 @@ def loaded_proxy_config(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
def test_build_proxy_bug_report_reads_the_loaded_proxy_config():
report = build_proxy_bug_report(RuntimeError("boom"), stream=False)

assert report.surface == "proxy"
assert report.environment.surface == "proxy"
assert report.stream is False
assert report.config_lines == safe_config_lines(CUSTOMER_CONFIG, CUSTOMER_GENERAL_SETTINGS)
assert report.environment.config_lines == safe_config_lines(CUSTOMER_CONFIG, CUSTOMER_GENERAL_SETTINGS)


@pytest.mark.usefixtures("loaded_proxy_config")
def test_proxy_environment_report_matches_the_bug_report_environment():
environment = build_proxy_environment_report()

assert environment == build_proxy_bug_report(RuntimeError("boom")).environment
assert environment.config_lines == safe_config_lines(CUSTOMER_CONFIG, CUSTOMER_GENERAL_SETTINGS)
Loading
Loading