Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
121 changes: 110 additions & 11 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 All @@ -21,6 +28,23 @@
_OBJECT_MAP: Final[TypeAdapter[Mapping[str, object]]] = TypeAdapter(Mapping[str, object])
_OBJECT_LIST: Final[TypeAdapter[tuple[object, ...]]] = TypeAdapter(tuple[object, ...])
_JSON: Final[TypeAdapter[JsonValue]] = TypeAdapter(JsonValue)
MAX_CONFIG_DEPTH: Final = 32

OPERATOR_KEYED_MAPS: Final = frozenset(
{
"environment_variables",
"mcp_servers",
"model_group_alias",
"model_group_retry_policy",
"fallbacks",
"context_window_fallbacks",
"content_policy_fallbacks",
"extra_headers",
"default_headers",
"extra_body",
"metadata",
}
)
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved


def _object_map(value: object) -> Mapping[str, object]:
Expand Down Expand Up @@ -87,19 +111,23 @@
return frozenset(name for name in inspect.signature(Cache.__init__).parameters if name != "self") # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # untyped params, only names are read


def _render_json(value: JsonValue) -> str | None:
def _render_json_scalar(value: JsonValue) -> str | None:
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
match value:
case bool():
return str(value).lower()
case str():
return value if value in _known_values() else None
case list():
known_items: Final = tuple(rendered for item in value if (rendered := _render_json(item)) is not None)
return f"[{', '.join(known_items)}]" if known_items else None
case _:
return None


def _render_json(value: JsonValue) -> str | None:
if not isinstance(value, list):
return _render_json_scalar(value)
known_items: Final = tuple(rendered for item in value if (rendered := _render_json_scalar(item)) is not None)
return f"[{', '.join(known_items)}]" if known_items else None


def _render(value: object) -> str | None:
try:
return _render_json(_JSON.validate_python(value))
Expand Down Expand Up @@ -149,6 +177,68 @@
return (f"model_list[*].provider = [{', '.join(providers)}]",) if providers else ()


def _verbose_scalar(value: object) -> str:
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
match value:
case bool():
return str(value).lower()
case int() | float():
return str(value)
case None:
return "null"
case str() if value in _known_values():
return value
case str():
provider: Final = _deployment_provider(value)
return "<str>" if provider is None else f"{provider}/<str>"
case _:
return "<object>"


def _mapping_or_none(value: object) -> Mapping[str, object] | None:
try:
return _OBJECT_MAP.validate_python(value)
except ValidationError:
return None


def _items_or_none(value: object) -> Sequence[object] | None:
try:
return _OBJECT_LIST.validate_python(value)
except ValidationError:
return None


def _is_container(value: object) -> bool:
return _mapping_or_none(value) is not None or _items_or_none(value) is not None


def _verbose_lines(path: str, key: str, value: object, depth: int) -> tuple[str, ...]:
entries: Final = _mapping_or_none(value)
items: Final = _items_or_none(value)
if entries is None and items is None:
return (f"{path} = {_verbose_scalar(value)}",)
if entries is not None and key in OPERATOR_KEYED_MAPS:
return (f"{path} = <{len(entries)} keys>",)
if depth >= MAX_CONFIG_DEPTH:
return (f"{path} = <object>",)
if entries is not None:
return tuple(
line
for child_key, child in entries.items()
for line in _verbose_lines(f"{path}.{child_key}" if path else child_key, child_key, child, depth + 1)
)
children: Final = items or ()
if not any(_is_container(item) for item in children):
return (f"{path} = [{', '.join(_verbose_scalar(item) for item in children)}]",)
return tuple(
line for index, item in enumerate(children) for line in _verbose_lines(f"{path}[{index}]", key, item, depth + 1)
)


def verbose_config_lines(config: Mapping[str, object], general_settings: Mapping[str, object]) -> tuple[str, ...]:
return _verbose_lines("", "", {**config, "general_settings": general_settings}, 0)


def safe_config_lines(config: Mapping[str, object], general_settings: Mapping[str, object]) -> tuple[str, ...]:
litellm_settings: Final = _object_map(config.get("litellm_settings"))
return (
Expand All @@ -163,23 +253,32 @@
)


def _proxy_config_lines(*, verbose: bool = False) -> tuple[str, ...]:
from litellm.proxy import proxy_server

render: Final = verbose_config_lines if verbose else safe_config_lines
return render(
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(*, verbose: bool = False) -> EnvironmentReport:
return build_environment_report(surface="proxy", config_lines=_proxy_config_lines(verbose=verbose))


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(),
)
28 changes: 27 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,29 @@ 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)],
verbose: bool = False,
) -> 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

verbose=true walks the whole loaded config instead: every key path, with booleans, numbers
and LiteLLM-defined enum values shown, every other string shown as <str> and operator-keyed
maps (environment_variables, model_group_alias, headers, metadata) shown as a key count

Example usage:
curl http://localhost:4000/debug/report -H "Authorization: Bearer sk-1234"
curl "http://localhost:4000/debug/report?verbose=true" -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(verbose=verbose)


@router.get(
"/otel-spans",
dependencies=[Depends(user_api_key_auth)],
Expand Down
1 change: 1 addition & 0 deletions tests/code_coverage_tests/recursive_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
"_redact_agent_params_tree", # max depth set (default 10), same shape as _redact_sensitive_litellm_params.
"_restore_redacted_nested_value", # max depth set (default 10), mirrors _redact_agent_params_tree on the write side.
"_unqualified", # bounded by the qualifier depth of a static TypedDict annotation (Annotated, Required/NotRequired, ReadOnly around one type, no cycles possible).
"_verbose_lines", # max depth set (MAX_CONFIG_DEPTH=32); fails closed by rendering "<object>" at the cap.
"completion_cost", # max depth 1: recursion only fires for mixed-tier Responses WS logging objects, and each split part carries a single service_tier so _split_responses_ws_logging_object_by_service_tier returns None.
]

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"
Loading
Loading