diff --git a/litellm/proxy/client/cli/commands/debug.py b/litellm/proxy/client/cli/commands/debug.py
index b5774822df7..c5f147eb37a 100644
--- a/litellm/proxy/client/cli/commands/debug.py
+++ b/litellm/proxy/client/cli/commands/debug.py
@@ -8,13 +8,16 @@ single markdown report that can be pasted into a bug report or handed to another
import json
import os
+import re
from collections.abc import Mapping, Sequence
+from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from types import MappingProxyType
from typing import Final
import click
+import requests
from pydantic import BaseModel, ConfigDict, Field, JsonValue, TypeAdapter, ValidationError, field_validator
from ...http_client import HTTPClient
@@ -36,8 +39,9 @@ report was saved to so I can hand it off. If nothing failed, say so.
"""
-class DebugError(Exception):
- """Raised for any user-actionable failure while building the report."""
+@dataclass(frozen=True, slots=True)
+class DebugFailure:
+ message: str
class ErrorInformation(BaseModel):
@@ -113,10 +117,10 @@ _PAYLOAD: Final[TypeAdapter[RequestResponsePayload | None]] = TypeAdapter(Reques
_JSON: Final[TypeAdapter[JsonValue]] = TypeAdapter(JsonValue)
_SESSION_PAGE_SIZE: Final = 100
+_TRANSPORT_BODY_CHARS: Final = 500
def detect_claude_session_id(env: Mapping[str, str], claude_dir: Path) -> str | None:
- """Explicit env var first, else the transcript Claude Code touched most recently."""
explicit: Final = env.get(SESSION_ID_ENV)
if explicit:
return explicit
@@ -127,39 +131,54 @@ def detect_claude_session_id(env: Mapping[str, str], claude_dir: Path) -> str |
return newest.stem
-class SpendLogsFetcher:
- """Thin typed wrapper over the two spend-log endpoints the report needs."""
+def _transport_failure(uri: str, error: requests.exceptions.RequestException) -> DebugFailure:
+ body: Final = error.response.text[:_TRANSPORT_BODY_CHARS] if error.response is not None else ""
+ detail: Final = f"\n{body}" if body else ""
+ return DebugFailure(f"GET {uri} failed: {error}{detail}")
+
+class SpendLogsFetcher:
def __init__(self, http: HTTPClient) -> None:
self._http = http
- def session_rows(self, session_id: str) -> tuple[SpendLogRow, ...]:
+ def session_rows(self, session_id: str) -> tuple[SpendLogRow, ...] | DebugFailure:
first: Final = self._page(session_id, 1)
- rest: Final = tuple(
- row for page in range(2, first.total_pages + 1) for row in self._page(session_id, page).data
- )
- rows: Final = first.data + rest
+ if isinstance(first, DebugFailure):
+ return first
+ rest: Final = tuple(self._page(session_id, page) for page in range(2, first.total_pages + 1))
+ failed_page: Final = next((page for page in rest if isinstance(page, DebugFailure)), None)
+ if failed_page is not None:
+ return failed_page
+ rows: Final = first.data + tuple(row for page in rest if isinstance(page, SessionLogsPage) for row in page.data)
return tuple(sorted(rows, key=lambda r: r.start_time or ""))
- def _get(self, uri: str, params: Mapping[str, str | int] | None = None) -> JsonValue:
- return _JSON.validate_python(self._http.request("GET", uri, params=params)) # pyright: ignore[reportUnknownMemberType] # HTTPClient.request is untyped
+ def _get(self, uri: str, params: Mapping[str, str | int] | None = None) -> JsonValue | DebugFailure:
+ try:
+ return _JSON.validate_python(self._http.request("GET", uri, params=params)) # pyright: ignore[reportUnknownMemberType] # HTTPClient.request is untyped
+ except requests.exceptions.RequestException as e:
+ return _transport_failure(uri, e)
- def _page(self, session_id: str, page: int) -> SessionLogsPage:
+ def _page(self, session_id: str, page: int) -> SessionLogsPage | DebugFailure:
+ uri: Final = "/spend/logs/session/ui"
raw: Final = self._get(
- "/spend/logs/session/ui",
- MappingProxyType({"session_id": session_id, "page": page, "page_size": _SESSION_PAGE_SIZE}),
+ uri, MappingProxyType({"session_id": session_id, "page": page, "page_size": _SESSION_PAGE_SIZE})
)
+ if isinstance(raw, DebugFailure):
+ return raw
try:
return _SESSION_PAGE.validate_python(raw)
except ValidationError as e:
- raise DebugError(f"Unexpected /spend/logs/session/ui response: {e}") from e
+ return DebugFailure(f"Unexpected {uri} response: {e}")
- def payload(self, request_id: str) -> RequestResponsePayload | None:
- raw: Final = self._get(f"/spend/logs/ui/{request_id}")
+ def payload(self, request_id: str) -> RequestResponsePayload | None | DebugFailure:
+ uri: Final = f"/spend/logs/ui/{request_id}"
+ raw: Final = self._get(uri)
+ if isinstance(raw, DebugFailure):
+ return raw
try:
return _PAYLOAD.validate_python(raw)
except ValidationError as e:
- raise DebugError(f"Unexpected /spend/logs/ui/{request_id} response: {e}") from e
+ return DebugFailure(f"Unexpected {uri} response: {e}")
def _fmt_json(value: JsonValue, max_chars: int) -> str:
@@ -169,12 +188,19 @@ def _fmt_json(value: JsonValue, max_chars: int) -> str:
return f"{text[:max_chars]}\n... (truncated, {len(text) - max_chars} more chars)"
+def _fenced(text: str, info: str = "") -> tuple[str, str, str]:
+ longest_run: Final = max((len(run) for run in re.findall(r"`+", text)), default=0)
+ fence: Final = "`" * max(3, longest_run + 1)
+ return (f"{fence}{info}", text, fence)
+
+
def _row_section(row: SpendLogRow, index: int, payload: RequestResponsePayload | None, max_chars: int) -> str:
err: Final = row.error
error_lines: Final = (
(
f"- error: `{err.error_code or '?'}` {err.error_class or ''}".rstrip(),
- f"\n```\n{err.error_message or ''}\n```",
+ "",
+ *_fenced(err.error_message or ""),
)
if err is not None and row.failed
else ()
@@ -184,16 +210,12 @@ def _row_section(row: SpendLogRow, index: int, payload: RequestResponsePayload |
"",
"request body
",
"",
- "```json",
- _fmt_json(payload.proxy_server_request, max_chars),
- "```",
+ *_fenced(_fmt_json(payload.proxy_server_request, max_chars), "json"),
" ",
"",
"response
",
"",
- "```json",
- _fmt_json(payload.response, max_chars),
- "```",
+ *_fenced(_fmt_json(payload.response, max_chars), "json"),
" ",
)
if payload is not None
@@ -246,17 +268,23 @@ def build_report(
base_url: str,
recent_bodies: int,
max_chars: int,
-) -> str:
+) -> str | DebugFailure:
rows: Final = fetcher.session_rows(session_id)
+ if isinstance(rows, DebugFailure):
+ return rows
if not rows:
- raise DebugError(
+ return DebugFailure(
f"No spend logs found for session {session_id!r} on {base_url}. "
"Is Claude Code routed through this proxy (`lite up`), and does your key have log access?"
)
wanted: Final = frozenset(r.request_id for r in rows if r.failed) | frozenset(
r.request_id for r in rows[-recent_bodies:] if recent_bodies > 0
)
- payloads: Final = MappingProxyType({rid: fetcher.payload(rid) for rid in wanted})
+ fetched: Final = MappingProxyType({rid: fetcher.payload(rid) for rid in sorted(wanted)})
+ failed_payload: Final = next((p for p in fetched.values() if isinstance(p, DebugFailure)), None)
+ if failed_payload is not None:
+ return failed_payload
+ payloads: Final = MappingProxyType({rid: p for rid, p in fetched.items() if not isinstance(p, DebugFailure)})
return render_report(session_id=session_id, base_url=base_url, rows=rows, payloads=payloads, max_chars=max_chars)
@@ -318,19 +346,18 @@ def debug_claude(
values: Final = cli_context_values(ctx)
base_url: Final = values["base_url"]
fetcher: Final = SpendLogsFetcher(HTTPClient(base_url, values["api_key"]))
- try:
- report: Final = build_report(
- fetcher=fetcher,
- session_id=resolved,
- base_url=base_url,
- recent_bodies=recent_bodies,
- max_chars=max_body_chars,
- )
- except DebugError as e:
- raise click.ClickException(str(e)) from e
- click.echo(report)
+ outcome: Final = build_report(
+ fetcher=fetcher,
+ session_id=resolved,
+ base_url=base_url,
+ recent_bodies=recent_bodies,
+ max_chars=max_body_chars,
+ )
+ if isinstance(outcome, DebugFailure):
+ raise click.ClickException(outcome.message)
+ click.echo(outcome)
if not no_save:
- path: Final = write_report(report, resolved, REPORT_DIR)
+ path: Final = write_report(outcome, resolved, REPORT_DIR)
click.echo(f"Saved to {path}", err=True)
diff --git a/litellm/proxy/client/cli/main.py b/litellm/proxy/client/cli/main.py
index b78d542085a..eae1b0f5bc9 100644
--- a/litellm/proxy/client/cli/main.py
+++ b/litellm/proxy/client/cli/main.py
@@ -144,7 +144,6 @@ cli.add_command(encryption)
cli.add_command(chat)
# Add the http command group
cli.add_command(http)
-# Add the debug command group (session debug reports for coding agents)
cli.add_command(debug)
# Add the keys command group
cli.add_command(keys)
diff --git a/tests/test_litellm/proxy/client/cli/test_debug_commands.py b/tests/test_litellm/proxy/client/cli/test_debug_commands.py
index 42ce3aaf4d2..1853d0c3468 100644
--- a/tests/test_litellm/proxy/client/cli/test_debug_commands.py
+++ b/tests/test_litellm/proxy/client/cli/test_debug_commands.py
@@ -3,6 +3,7 @@ import os
import time
import pytest
+import requests
import responses
from click.testing import CliRunner
@@ -38,7 +39,6 @@ FAILED_ROW = {
"spend": 0.0,
"prompt_tokens": 0,
"completion_tokens": 0,
- # query_raw hands metadata back as a JSON string on some paths
"metadata": json.dumps(
{
"status": "failure",
@@ -169,3 +169,55 @@ def test_install_slash_command_writes_runnable_command_file(tmp_path):
result = CliRunner().invoke(cli, ["debug", "install-claude-command"])
assert result.exit_code == 0, result.output
assert "/debug-lite" in result.output
+
+
+@responses.activate
+def test_rejected_key_is_a_clear_error_not_a_traceback():
+ responses.get(
+ f"{PROXY}/spend/logs/session/ui",
+ status=401,
+ json={"error": {"message": "Authentication Error, Invalid proxy server token passed", "code": "401"}},
+ )
+ result = CliRunner().invoke(cli, ["debug", "claude", "--session-id", SESSION])
+
+ assert isinstance(result.exception, SystemExit), result.exception
+ assert result.exit_code == 1
+ assert "401" in result.output
+ assert "Invalid proxy server token passed" in result.output
+
+
+@responses.activate
+def test_unreachable_proxy_is_a_clear_error_not_a_traceback():
+ responses.get(f"{PROXY}/spend/logs/session/ui", body=requests.ConnectionError("Connection refused"))
+ result = CliRunner().invoke(cli, ["debug", "claude", "--session-id", SESSION])
+
+ assert isinstance(result.exception, SystemExit), result.exception
+ assert result.exit_code == 1
+ assert "Connection refused" in result.output
+
+
+@responses.activate
+def test_non_json_proxy_response_is_a_clear_error_not_a_traceback():
+ responses.get(f"{PROXY}/spend/logs/session/ui", body="502 Bad Gateway")
+ result = CliRunner().invoke(cli, ["debug", "claude", "--session-id", SESSION])
+
+ assert isinstance(result.exception, SystemExit), result.exception
+ assert result.exit_code == 1
+ assert "/spend/logs/session/ui failed" in result.output
+
+
+@responses.activate
+def test_logged_content_with_code_fences_stays_inside_its_fence():
+ fenced_error_row = {
+ **FAILED_ROW,
+ "metadata": {
+ "status": "failure",
+ "error_information": {"error_code": "400", "error_message": "bad\n```\nrequest"},
+ },
+ }
+ _mock_proxy([fenced_error_row], {"req-failed": {"proxy_server_request": None, "response": "x\n````\ny"}})
+ result = CliRunner().invoke(cli, ["debug", "claude", "--session-id", SESSION, "--no-save"])
+
+ assert result.exit_code == 0, result.output
+ assert "````\nbad\n```\nrequest\n````\n" in result.output
+ assert "`````json\nx\n````\ny\n`````\n" in result.output