diff --git a/litellm/proxy/client/cli/commands/debug.py b/litellm/proxy/client/cli/commands/debug.py index 8728a9323b7..89b93e2bbb8 100644 --- a/litellm/proxy/client/cli/commands/debug.py +++ b/litellm/proxy/client/cli/commands/debug.py @@ -15,6 +15,7 @@ from datetime import datetime, timezone from pathlib import Path from types import MappingProxyType from typing import Final +from urllib.parse import quote import click import requests @@ -177,7 +178,7 @@ class SpendLogsFetcher: return DebugFailure(f"Unexpected {uri} response: {e}") def payload(self, request_id: str) -> RequestResponsePayload | None | DebugFailure: - uri: Final = f"/spend/logs/ui/{request_id}" + uri: Final = f"/spend/logs/ui/{quote(request_id, safe='')}" raw: Final = self._get(uri) if isinstance(raw, DebugFailure): return raw diff --git a/tests/unit/proxy/client/cli/test_debug_commands.py b/tests/unit/proxy/client/cli/test_debug_commands.py index 419fd821cfb..311edde0af9 100644 --- a/tests/unit/proxy/client/cli/test_debug_commands.py +++ b/tests/unit/proxy/client/cli/test_debug_commands.py @@ -1,6 +1,8 @@ import json import os +import re import time +from urllib.parse import unquote import pytest import requests @@ -114,6 +116,22 @@ def test_recent_bodies_fetches_latest_turns_even_when_successful(): assert _called_paths() == ["/spend/logs/session/ui", "/spend/logs/ui/req-ok"] +@pytest.mark.parametrize("request_id", ["req#a1", "req?a=1", "req%41b", "req a&b"]) +@responses.activate +def test_bodies_are_fetched_for_request_ids_with_url_special_characters(request_id): + def payload_for_stored_id(request): + stored_id = unquote(request.path_url.split("?")[0].removeprefix("/spend/logs/ui/")) + found = stored_id == request_id + return 200, {}, json.dumps({"response": {"id": "msg_special"}} if found else None) + + _mock_proxy([{**OK_ROW, "request_id": request_id}], {}) + responses.add_callback(responses.GET, re.compile(f"{PROXY}/spend/logs/ui/.*"), callback=payload_for_stored_id) + result = CliRunner().invoke(cli, ["debug", "claude", "--session-id", SESSION, "--no-save"]) + + assert result.exit_code == 0, result.output + assert "msg_special" in result.output + + @responses.activate def test_bodies_are_truncated_to_max_chars(): _mock_proxy([OK_ROW], {"req-ok": {"proxy_server_request": {"body": "a" * 5000}, "response": None}}) diff --git a/ui/litellm-dashboard/src/components/networking.test.ts b/ui/litellm-dashboard/src/components/networking.test.ts index 8b0f0389772..874cdb52fad 100644 --- a/ui/litellm-dashboard/src/components/networking.test.ts +++ b/ui/litellm-dashboard/src/components/networking.test.ts @@ -602,6 +602,27 @@ describe("uiSpendLogsCall exclude_internal_health_checks serialization", () => { }); }); +describe("uiSpendLogDetailsCall", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it.each(["chatcmpl-123#a1", "chatcmpl-123?a=1", "chatcmpl-123%41b", "chatcmpl-123 a&b"])( + "requests the details of %s with the full request id and start date", + async (logId) => { + const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue({ id: logId }) }); + vi.stubGlobal("fetch", mockFetch); + + await Networking.uiSpendLogDetailsCall("token", logId, "2026-01-01 00:00:00"); + + const url = new URL(String(mockFetch.mock.calls.at(-1)?.[0]), "http://example.com"); + expect(decodeURIComponent(url.pathname.split("/spend/logs/ui/")[1])).toBe(logId); + expect(url.hash).toBe(""); + expect([...url.searchParams.entries()]).toEqual([["start_date", "2026-01-01 00:00:00"]]); + }, + ); +}); + describe("sessionSpendLogsCall", () => { const originalFetch = global.fetch; diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 38d150f1d63..486ec2be068 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -4709,8 +4709,8 @@ export const uiSpendLogDetailsCall = async (accessToken: string, logId: string, try { // Construct base URL let url = proxyBaseUrl - ? `${proxyBaseUrl}/spend/logs/ui/${logId}?start_date=${encodeURIComponent(start_date)}` - : `/spend/logs/ui/${logId}?start_date=${encodeURIComponent(start_date)}`; + ? `${proxyBaseUrl}/spend/logs/ui/${encodeURIComponent(logId)}?start_date=${encodeURIComponent(start_date)}` + : `/spend/logs/ui/${encodeURIComponent(logId)}?start_date=${encodeURIComponent(start_date)}`; const response = await fetch(url, { method: "GET",