From beac7fcb5d9cf1ba0a704c6aae1d0c1b23eaec6f Mon Sep 17 00:00:00 2001 From: ryan Date: Tue, 22 Sep 2026 06:28:55 +0000 Subject: [PATCH 1/6] feat(proxy): add admin-only /debug/report sharing the bug report environment fields Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/litellm_core_utils/bug_report.py | 53 +++++++----- litellm/proxy/bug_report_config.py | 29 +++++-- litellm/proxy/common_utils/debug_utils.py | 20 +++++ .../litellm_core_utils/test_bug_report.py | 26 ++++++ .../proxy/common_utils/test_debug_utils.py | 81 ++++++++++++++++++- .../proxy/test_bug_report_config.py | 4 +- 6 files changed, 184 insertions(+), 29 deletions(-) diff --git a/litellm/litellm_core_utils/bug_report.py b/litellm/litellm_core_utils/bug_report.py index 4a8f4bc65ad6..25fddd6a10e6 100644 --- a/litellm/litellm_core_utils/bug_report.py +++ b/litellm/litellm_core_utils/bug_report.py @@ -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: @@ -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 fa4a26042098..033d89423102 100644 --- a/litellm/proxy/bug_report_config.py +++ b/litellm/proxy/bug_report_config.py @@ -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 @@ -163,6 +170,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, *, @@ -170,16 +190,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 dc329e55e31b..2d6dbc9ce661 100644 --- a/litellm/proxy/common_utils/debug_utils.py +++ b/litellm/proxy/common_utils/debug_utils.py @@ -16,8 +16,11 @@ 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: 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 f47361dda2e0..62d7090b9605 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 @@ 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 163ea530be9e..a64c94c29911 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 ce4baa946f99..3b5781dbad2a 100644 --- a/tests/test_litellm/proxy/test_bug_report_config.py +++ b/tests/test_litellm/proxy/test_bug_report_config.py @@ -149,6 +149,6 @@ 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) From 2c478d175cb475febe44a0b0c4a456939931fee0 Mon Sep 17 00:00:00 2001 From: ryan Date: Tue, 22 Sep 2026 06:48:14 +0000 Subject: [PATCH 2/6] feat(proxy): add verbose=true to /debug/report listing every config key with typed values Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/bug_report_config.py | 82 +++++++++++- litellm/proxy/common_utils/debug_utils.py | 8 +- .../proxy/common_utils/test_debug_utils.py | 32 ++++- .../proxy/test_bug_report_config.py | 121 +++++++++++++++++- 4 files changed, 234 insertions(+), 9 deletions(-) diff --git a/litellm/proxy/bug_report_config.py b/litellm/proxy/bug_report_config.py index 033d89423102..eb9259c9b9bf 100644 --- a/litellm/proxy/bug_report_config.py +++ b/litellm/proxy/bug_report_config.py @@ -29,6 +29,22 @@ _OBJECT_LIST: Final[TypeAdapter[tuple[object, ...]]] = TypeAdapter(tuple[object, ...]) _JSON: Final[TypeAdapter[JsonValue]] = TypeAdapter(JsonValue) +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", + } +) + def _object_map(value: object) -> Mapping[str, object]: try: @@ -156,6 +172,63 @@ def _model_list_lines(model_list: object) -> tuple[str, ...]: return (f"model_list[*].provider = [{', '.join(providers)}]",) if providers else () +def _verbose_scalar(value: object) -> str: + 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 "" if provider is None else f"{provider}/" + case _: + return "" + + +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) -> tuple[str, ...]: + entries: Final = _mapping_or_none(value) + if entries is not None and key in OPERATOR_KEYED_MAPS: + return (f"{path} = <{len(entries)} keys>",) + 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) + ) + items: Final = _items_or_none(value) + if items is None: + return (f"{path} = {_verbose_scalar(value)}",) + if not any(_is_container(item) for item in items): + return (f"{path} = [{', '.join(_verbose_scalar(item) for item in items)}]",) + return tuple(line for index, item in enumerate(items) for line in _verbose_lines(f"{path}[{index}]", key, item)) + + +def verbose_config_lines(config: Mapping[str, object], general_settings: Mapping[str, object]) -> tuple[str, ...]: + return _verbose_lines("", "", {**config, "general_settings": general_settings}) + + 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 ( @@ -170,17 +243,18 @@ def safe_config_lines(config: Mapping[str, object], general_settings: Mapping[st ) -def _proxy_config_lines() -> tuple[str, ...]: +def _proxy_config_lines(*, verbose: bool = False) -> tuple[str, ...]: from litellm.proxy import proxy_server - return safe_config_lines( + 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() -> EnvironmentReport: - return build_environment_report(surface="proxy", config_lines=_proxy_config_lines()) +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( diff --git a/litellm/proxy/common_utils/debug_utils.py b/litellm/proxy/common_utils/debug_utils.py index 2d6dbc9ce661..bcd57adec3f6 100644 --- a/litellm/proxy/common_utils/debug_utils.py +++ b/litellm/proxy/common_utils/debug_utils.py @@ -788,6 +788,7 @@ async def configure_gc_thresholds_endpoint( @router.get("/debug/report", include_in_schema=False) async def get_debug_report( + verbose: bool = False, user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), ) -> EnvironmentReport: """ @@ -795,12 +796,17 @@ async def get_debug_report( 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 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() + return build_proxy_environment_report(verbose=verbose) @router.get( 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 a64c94c29911..418ef6a7f6ad 100644 --- a/tests/test_litellm/proxy/common_utils/test_debug_utils.py +++ b/tests/test_litellm/proxy/common_utils/test_debug_utils.py @@ -127,10 +127,12 @@ def _debug_client(caller: UserAPIKeyAuth) -> TestClient: ) @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") + client = _debug_client(caller) - assert response.status_code == 403, response.text - assert "litellm_version" not in response.text + for url in ("/debug/report", "/debug/report?verbose=true"): + response = client.get(url) + assert response.status_code == 403, response.text + assert "litellm_version" not in response.text @pytest.mark.usefixtures("hostile_proxy_config") @@ -146,3 +148,27 @@ def test_debug_report_returns_what_the_bug_report_link_carries_and_nothing_from_ "model_list[*].provider = [azure]", ] assert not any(hostile in response.text for hostile in HOSTILE_STRINGS), response.text + + +@pytest.mark.usefixtures("hostile_proxy_config") +def test_verbose_debug_report_lists_every_config_key_with_operator_values_typed_out() -> None: + client = _debug_client(UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN)) + + response = client.get("/debug/report?verbose=true") + + assert response.status_code == 200, response.text + assert response.json() == { + **client.get("/debug/report").json(), + "config_lines": [ + "model_list[0].model_name = ", + "model_list[0].litellm_params.model = azure/", + "model_list[0].litellm_params.api_base = ", + "model_list[0].litellm_params.api_key = ", + "litellm_settings.drop_params = true", + "litellm_settings.callbacks = [langfuse, ]", + "general_settings.master_key = ", + "general_settings.database_url = ", + "general_settings.store_model_in_db = true", + ], + } + 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 3b5781dbad2a..25ff1f78dd1d 100644 --- a/tests/test_litellm/proxy/test_bug_report_config.py +++ b/tests/test_litellm/proxy/test_bug_report_config.py @@ -5,7 +5,12 @@ 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, + verbose_config_lines, +) CUSTOMER_STRINGS = ( "acme", @@ -15,6 +20,25 @@ "10.0.0.7", ) +CUSTOMER_VALUES = ( + "acme-prod", + "acme-gpt4o", + "acme-mini", + "acme-backup", + "acme-bare", + "acme-custom", + "acme-pii", + "acme-policy", + "acme_hooks", + "acme_guardrails", + "ACME_", + "sk-live-secret", + "hunter2", + "postgres://", + "10.0.0.7", + "azure.com", +) + CUSTOMER_CONFIG: Mapping[str, object] = { "model_list": [ { @@ -124,6 +148,86 @@ def test_secrets_numbers_and_unknown_values_leave_no_line(): assert safe_config_lines({}, general_settings) == ("general_settings.background_health_checks = false",) +def test_verbose_lines_show_every_key_but_only_litellm_defined_or_numeric_values(): + lines = verbose_config_lines(CUSTOMER_CONFIG, CUSTOMER_GENERAL_SETTINGS) + + assert lines == ( + "model_list[0].model_name = ", + "model_list[0].litellm_params.model = azure/", + "model_list[0].litellm_params.api_base = ", + "model_list[0].litellm_params.api_key = ", + "model_list[0].litellm_params.rpm = 600", + "model_list[0].litellm_params.acme_extra_param = ", + "model_list[1].model_name = ", + "model_list[1].litellm_params.model = openai/", + "model_list[1].litellm_params.api_key = ", + "model_list[2].model_name = ", + "model_list[2].litellm_params.model = azure/", + "model_list[2].litellm_params.api_key = ", + "model_list[3].model_name = ", + "model_list[3].litellm_params.model = ", + "litellm_settings.callbacks = [langfuse, ]", + "litellm_settings.drop_params = true", + "litellm_settings.num_retries = 3", + "litellm_settings.acme_internal_flag = true", + "litellm_settings.cache = true", + "litellm_settings.cache_params.type = redis", + "litellm_settings.cache_params.host = ", + "litellm_settings.cache_params.port = 6379", + "litellm_settings.cache_params.password = ", + "litellm_settings.cache_params.acme_cache_option = ", + "router_settings.routing_strategy = latency-based-routing", + "router_settings.redis_host = ", + "router_settings.acme_router_option = true", + "guardrails[0].guardrail_name = ", + "guardrails[0].litellm_params.guardrail = presidio", + "guardrails[0].litellm_params.mode = pre_call", + "guardrails[1].guardrail_name = ", + "guardrails[1].litellm_params.guardrail = ", + "guardrails[1].litellm_params.api_key = ", + "environment_variables = <2 keys>", + "general_settings.master_key = ", + "general_settings.database_url = ", + "general_settings.key_management_system = aws_secret_manager", + "general_settings.store_model_in_db = true", + "general_settings.health_check_interval = 300", + "general_settings.acme_sso_tenant = ", + ) + assert not any(customer_value in "\n".join(lines) for customer_value in CUSTOMER_VALUES) + + +def test_verbose_lines_count_operator_keyed_maps_and_type_unknown_leaves(): + config: Mapping[str, object] = { + "router_settings": { + "model_group_alias": {"acme-gpt4": "acme-prod-gpt4", "acme-fast": {"model": "acme-mini", "hidden": True}}, + "timeout": None, + "fallbacks": [{"acme-prod-gpt4": ["acme-backup"]}], + }, + "model_list": [ + { + "model_name": "acme-prod-gpt4", + "litellm_params": {"model": "acme/acme-custom", "extra_headers": {"x-acme-tenant": "acme"}}, + "model_info": {"metadata": {"owner": "acme"}, "supports_vision": True, "weight": 1.5}, + } + ], + "litellm_settings": {"callbacks": object(), "tags": ["acme", 7, False]}, + } + + assert verbose_config_lines(config, {}) == ( + "router_settings.model_group_alias = <2 keys>", + "router_settings.timeout = null", + "router_settings.fallbacks[0] = <1 keys>", + "model_list[0].model_name = ", + "model_list[0].litellm_params.model = ", + "model_list[0].litellm_params.extra_headers = <1 keys>", + "model_list[0].model_info.metadata = <1 keys>", + "model_list[0].model_info.supports_vision = true", + "model_list[0].model_info.weight = 1.5", + "litellm_settings.callbacks = ", + "litellm_settings.tags = [, 7, false]", + ) + + def test_malformed_sections_produce_no_lines(): config: Mapping[str, object] = { "litellm_settings": "acme", @@ -152,3 +256,18 @@ def test_build_proxy_bug_report_reads_the_loaded_proxy_config(): assert report.environment.surface == "proxy" assert report.stream is False assert report.environment.config_lines == safe_config_lines(CUSTOMER_CONFIG, CUSTOMER_GENERAL_SETTINGS) + + +@pytest.mark.usefixtures("loaded_proxy_config") +def test_proxy_environment_report_switches_between_safe_and_verbose_config_lines(): + safe = build_proxy_environment_report() + verbose = build_proxy_environment_report(verbose=True) + + assert safe.config_lines == safe_config_lines(CUSTOMER_CONFIG, CUSTOMER_GENERAL_SETTINGS) + assert verbose.config_lines == verbose_config_lines(CUSTOMER_CONFIG, CUSTOMER_GENERAL_SETTINGS) + assert (safe.surface, safe.litellm_version, safe.python_version, safe.deployment) == ( + verbose.surface, + verbose.litellm_version, + verbose.python_version, + verbose.deployment, + ) From b572c7ce24a7e2ce3e6ffa980ca95a355b90bac3 Mon Sep 17 00:00:00 2001 From: ryan Date: Tue, 22 Sep 2026 06:55:06 +0000 Subject: [PATCH 3/6] fix(proxy): inject auth into /debug/report through Annotated to keep the B008 budget flat Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/common_utils/debug_utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/common_utils/debug_utils.py b/litellm/proxy/common_utils/debug_utils.py index bcd57adec3f6..42aa6b41cb73 100644 --- a/litellm/proxy/common_utils/debug_utils.py +++ b/litellm/proxy/common_utils/debug_utils.py @@ -8,7 +8,7 @@ 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 @@ -788,8 +788,8 @@ 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, - user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), ) -> EnvironmentReport: """ The same LiteLLM-owned environment facts the bug report link puts in a GitHub issue: From df4ec19858fc66cbfe815f0729ac665ee9f7a4ce Mon Sep 17 00:00:00 2001 From: ryan Date: Tue, 22 Sep 2026 07:02:57 +0000 Subject: [PATCH 4/6] fix(proxy): bound the verbose config walk, drop nested-list recursion from the safe renderer, regenerate schema.d.ts Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/bug_report_config.py | 36 +++++---- .../code_coverage_tests/recursive_detector.py | 1 + .../proxy/test_bug_report_config.py | 16 ++++ ui/litellm-dashboard/src/lib/http/schema.d.ts | 77 +++++++++++++++++++ 4 files changed, 117 insertions(+), 13 deletions(-) diff --git a/litellm/proxy/bug_report_config.py b/litellm/proxy/bug_report_config.py index eb9259c9b9bf..dcd49949e1f0 100644 --- a/litellm/proxy/bug_report_config.py +++ b/litellm/proxy/bug_report_config.py @@ -28,6 +28,7 @@ _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( { @@ -110,19 +111,23 @@ def _cache_params_keys() -> frozenset[str]: 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: 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)) @@ -207,26 +212,31 @@ 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) -> tuple[str, ...]: +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} = ",) 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) + for line in _verbose_lines(f"{path}.{child_key}" if path else child_key, child_key, child, depth + 1) ) - items: Final = _items_or_none(value) - if items is None: - return (f"{path} = {_verbose_scalar(value)}",) - if not any(_is_container(item) for item in items): - return (f"{path} = [{', '.join(_verbose_scalar(item) for item in items)}]",) - return tuple(line for index, item in enumerate(items) for line in _verbose_lines(f"{path}[{index}]", key, item)) + 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}) + return _verbose_lines("", "", {**config, "general_settings": general_settings}, 0) def safe_config_lines(config: Mapping[str, object], general_settings: Mapping[str, object]) -> tuple[str, ...]: diff --git a/tests/code_coverage_tests/recursive_detector.py b/tests/code_coverage_tests/recursive_detector.py index 071191183df7..a4a9a7ca17da 100644 --- a/tests/code_coverage_tests/recursive_detector.py +++ b/tests/code_coverage_tests/recursive_detector.py @@ -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 "" 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. ] diff --git a/tests/test_litellm/proxy/test_bug_report_config.py b/tests/test_litellm/proxy/test_bug_report_config.py index 25ff1f78dd1d..4a560e74f950 100644 --- a/tests/test_litellm/proxy/test_bug_report_config.py +++ b/tests/test_litellm/proxy/test_bug_report_config.py @@ -6,6 +6,7 @@ from litellm.proxy import proxy_server from litellm.proxy.bug_report_config import ( + MAX_CONFIG_DEPTH, build_proxy_bug_report, build_proxy_environment_report, safe_config_lines, @@ -228,6 +229,21 @@ def test_verbose_lines_count_operator_keyed_maps_and_type_unknown_leaves(): ) +def test_verbose_lines_stop_at_the_depth_cap_and_survive_self_referencing_config(): + deep: dict[str, object] = {"drop_params": True} + for _ in range(MAX_CONFIG_DEPTH + 5): + deep = {"nested": deep} + loop: list[object] = [] + loop.append(loop) + + lines = verbose_config_lines({"litellm_settings": deep, "guardrails": loop}, {}) + + assert lines == ( + "litellm_settings" + ".nested" * (MAX_CONFIG_DEPTH - 1) + " = ", + "guardrails" + "[0]" * (MAX_CONFIG_DEPTH - 1) + " = ", + ) + + def test_malformed_sections_produce_no_lines(): config: Mapping[str, object] = { "litellm_settings": "acme", diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 38cfdc9666bd..2393931f8e06 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -4356,6 +4356,36 @@ 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 + * + * 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 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" + */ + 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 +28809,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: { /** @@ -48549,6 +48595,37 @@ export interface operations { }; }; }; + get_debug_report_debug_report_get: { + parameters: { + query?: { + verbose?: boolean; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EnvironmentReport"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; delete_allowed_ip_delete_allowed_ip_post: { parameters: { query?: never; From b05afaef279360a9b9bf260f981aac76c7ac3e69 Mon Sep 17 00:00:00 2001 From: ryan Date: Tue, 22 Sep 2026 07:15:34 +0000 Subject: [PATCH 5/6] fix(proxy): count pass-through and mcp header maps plus operator-named budget maps in verbose /debug/report, single-exit scalar renderers Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/bug_report_config.py | 43 ++++++++++--------- .../proxy/test_bug_report_config.py | 33 ++++++++++++++ 2 files changed, 55 insertions(+), 21 deletions(-) diff --git a/litellm/proxy/bug_report_config.py b/litellm/proxy/bug_report_config.py index dcd49949e1f0..ada538ee6ba5 100644 --- a/litellm/proxy/bug_report_config.py +++ b/litellm/proxy/bug_report_config.py @@ -41,8 +41,13 @@ "content_policy_fallbacks", "extra_headers", "default_headers", + "headers", + "static_headers", "extra_body", "metadata", + "model_alias_map", + "tag_budget_config", + "priority_reservation", } ) @@ -112,13 +117,11 @@ def _cache_params_keys() -> frozenset[str]: def _render_json_scalar(value: JsonValue) -> str | None: - match value: - case bool(): - return str(value).lower() - case str(): - return value if value in _known_values() else None - case _: - return None + if isinstance(value, bool): + return str(value).lower() + if isinstance(value, str) and value in _known_values(): + return value + return None def _render_json(value: JsonValue) -> str | None: @@ -178,20 +181,18 @@ def _model_list_lines(model_list: object) -> tuple[str, ...]: def _verbose_scalar(value: object) -> str: - 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 "" if provider is None else f"{provider}/" - case _: - return "" + if isinstance(value, bool): + return str(value).lower() + if isinstance(value, int | float): + return str(value) + if value is None: + return "null" + if not isinstance(value, str): + return "" + if value in _known_values(): + return value + provider: Final = _deployment_provider(value) + return "" if provider is None else f"{provider}/" def _mapping_or_none(value: object) -> Mapping[str, object] | None: diff --git a/tests/test_litellm/proxy/test_bug_report_config.py b/tests/test_litellm/proxy/test_bug_report_config.py index 4a560e74f950..a1894f84d9ea 100644 --- a/tests/test_litellm/proxy/test_bug_report_config.py +++ b/tests/test_litellm/proxy/test_bug_report_config.py @@ -229,6 +229,39 @@ def test_verbose_lines_count_operator_keyed_maps_and_type_unknown_leaves(): ) +def test_verbose_lines_count_pass_through_headers_and_operator_named_budget_maps(): + config: Mapping[str, object] = { + "litellm_settings": { + "model_alias_map": {"acme-gpt4": "gpt-4o"}, + "tag_budget_config": {"acme-team": {"max_budget": 10}}, + "priority_reservation": {"acme-prod": 0.9}, + }, + "mcp_servers": {"acme_mcp": {"url": "https://mcp.acme.example", "static_headers": {"x-acme-token": "t"}}}, + } + general_settings: Mapping[str, object] = { + "pass_through_endpoints": [ + { + "path": "/acme", + "target": "https://acme.example", + "headers": {"x-acme-tenant": "acme", "Authorization": "k"}, + } + ] + } + + lines = verbose_config_lines(config, general_settings) + + assert lines == ( + "litellm_settings.model_alias_map = <1 keys>", + "litellm_settings.tag_budget_config = <1 keys>", + "litellm_settings.priority_reservation = <1 keys>", + "mcp_servers = <1 keys>", + "general_settings.pass_through_endpoints[0].path = ", + "general_settings.pass_through_endpoints[0].target = ", + "general_settings.pass_through_endpoints[0].headers = <2 keys>", + ) + assert "acme" not in "\n".join(lines) + + def test_verbose_lines_stop_at_the_depth_cap_and_survive_self_referencing_config(): deep: dict[str, object] = {"drop_params": True} for _ in range(MAX_CONFIG_DEPTH + 5): From 892d9b4575f1c0f2fd5f5761a4418b4519d4ecca Mon Sep 17 00:00:00 2001 From: ryan Date: Tue, 22 Sep 2026 17:28:55 +0000 Subject: [PATCH 6/6] chore(ui): regenerate schema.d.ts after dropping the verbose query from /debug/report Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ui/litellm-dashboard/src/lib/http/schema.d.ts | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 2393931f8e06..27c112cbceca 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -4369,13 +4369,8 @@ export interface paths { * 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 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" */ get: operations["get_debug_report_debug_report_get"]; put?: never; @@ -48597,9 +48592,7 @@ export interface operations { }; get_debug_report_debug_report_get: { parameters: { - query?: { - verbose?: boolean; - }; + query?: never; header?: never; path?: never; cookie?: never; @@ -48615,15 +48608,6 @@ export interface operations { "application/json": components["schemas"]["EnvironmentReport"]; }; }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; }; }; delete_allowed_ip_delete_allowed_ip_post: {