mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-24 00:52:24 +00:00
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:
parent
9d299d016a
commit
5035c458fb
7 changed files with 255 additions and 31 deletions
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)],
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
61
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
61
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue