feat(proxy): admin-only /debug/report sharing the bug report environment (#42440)

* 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>

* 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>

* 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>

* 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>

* 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>

* 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>

---------

Co-authored-by: ryan <ryan@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-09-22 12:05:23 -07:00 • committed by GitHub
parent 9d299d016a
commit 5035c458fb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 255 additions and 31 deletions

View file

@ -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)),

View file

@ -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(),
)

View file

@ -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)],

View file

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

View file

@ -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

View file

@ -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)

View file

@ -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;