diff --git a/litellm/litellm_core_utils/bug_report.py b/litellm/litellm_core_utils/bug_report.py index 4a8f4bc65ad..25fddd6a10e 100644 --- a/litellm/litellm_core_utils/bug_report.py +++ b/litellm/litellm_core_utils/bug_report.py @@ -23,16 +23,22 @@ Surface = Literal["sdk", "proxy"] @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: @@ -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, *, @@ -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" @@ -119,11 +138,11 @@ 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}" ) @@ -131,17 +150,13 @@ def _description(report: BugReport, frames: tuple[str, ...], config_lines: tuple 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 @@ -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)), diff --git a/litellm/proxy/bug_report_config.py b/litellm/proxy/bug_report_config.py index 527be65c9a7..d7b920de4d5 100644 --- a/litellm/proxy/bug_report_config.py +++ b/litellm/proxy/bug_report_config.py @@ -11,7 +11,14 @@ from typing import Final 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 @@ -191,6 +198,19 @@ 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, *, @@ -198,16 +218,11 @@ def build_proxy_bug_report( 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(), ) diff --git a/litellm/proxy/common_utils/debug_utils.py b/litellm/proxy/common_utils/debug_utils.py index dc329e55e31..2544321a1b6 100644 --- a/litellm/proxy/common_utils/debug_utils.py +++ b/litellm/proxy/common_utils/debug_utils.py @@ -8,7 +8,7 @@ import sys 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 @@ -16,8 +16,11 @@ 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() @@ -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)], diff --git a/tests/test_litellm/litellm_core_utils/test_bug_report.py b/tests/test_litellm/litellm_core_utils/test_bug_report.py index f47361dda2e..62d7090b960 100644 --- a/tests/test_litellm/litellm_core_utils/test_bug_report.py +++ b/tests/test_litellm/litellm_core_utils/test_bug_report.py @@ -21,6 +21,7 @@ from litellm.litellm_core_utils.bug_report import ( bug_report_issue_url, bug_report_notice, build_bug_report, + build_environment_report, should_report_bug, strip_bug_report_notice, ) @@ -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" diff --git a/tests/test_litellm/proxy/common_utils/test_debug_utils.py b/tests/test_litellm/proxy/common_utils/test_debug_utils.py index 163ea530be9..a64c94c2991 100644 --- a/tests/test_litellm/proxy/common_utils/test_debug_utils.py +++ b/tests/test_litellm/proxy/common_utils/test_debug_utils.py @@ -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 -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 @@ -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") + + +@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 diff --git a/tests/test_litellm/proxy/test_bug_report_config.py b/tests/test_litellm/proxy/test_bug_report_config.py index 06bca8c66fb..6cffa55781e 100644 --- a/tests/test_litellm/proxy/test_bug_report_config.py +++ b/tests/test_litellm/proxy/test_bug_report_config.py @@ -5,7 +5,7 @@ from collections.abc import Iterator, Mapping 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", @@ -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) diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 68d3ab364d6..a0fec542acf 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -4356,6 +4356,31 @@ export interface paths { patch?: never; trace?: never; }; + "/debug/report": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Debug Report + * @description 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" + */ + get: operations["get_debug_report_debug_report_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/delete/allowed_ip": { parameters: { query?: never; @@ -28779,6 +28804,22 @@ export interface components { /** Template Id */ template_id: string; }; + /** EnvironmentReport */ + EnvironmentReport: { + /** Config Lines */ + config_lines: string[]; + /** Deployment */ + deployment: string | null; + /** Litellm Version */ + litellm_version: string; + /** Python Version */ + python_version: string; + /** + * Surface + * @enum {string} + */ + surface: "sdk" | "proxy"; + }; /** ErrorResponse */ ErrorResponse: { /** @@ -48592,6 +48633,26 @@ export interface operations { }; }; }; + get_debug_report_debug_report_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EnvironmentReport"]; + }; + }; + }; + }; delete_allowed_ip_delete_allowed_ip_post: { parameters: { query?: never;