From 158e1e32d1b9ff715bc7083beb841f5c28a6c578 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Thu, 29 Jan 2026 11:43:18 -0800 Subject: [PATCH 1/4] error_code in spend logs error metadata --- litellm/litellm_core_utils/litellm_logging.py | 9 ++- .../test_litellm_logging.py | 78 +++++++++++++++++++ 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index 0d9245a686a..b2fa6065156 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -4752,7 +4752,14 @@ class StandardLoggingPayloadSetup: ) -> StandardLoggingPayloadErrorInformation: from litellm.constants import MAXIMUM_TRACEBACK_LINES_TO_LOG - error_status: str = str(getattr(original_exception, "status_code", "")) + # Check for 'code' first (used by ProxyException), then fall back to 'status_code' (used by LiteLLM exceptions) + # Ensure error_code is always a string for Prisma Python JSON field compatibility + error_code_attr = getattr(original_exception, "code", None) + if error_code_attr is not None and str(error_code_attr) not in ("", "None"): + error_status: str = str(error_code_attr) + else: + status_code_attr = getattr(original_exception, "status_code", None) + error_status = str(status_code_attr) if status_code_attr is not None else "" error_class: str = ( str(original_exception.__class__.__name__) if original_exception else "" ) diff --git a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py index 1f3f558a498..316bd49cf89 100644 --- a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py +++ b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py @@ -1060,3 +1060,81 @@ def test_append_system_prompt_messages(): kwargs=None, messages=messages ) assert result == messages + + +def test_get_error_information_error_code_priority(): + """ + Test get_error_information prioritizes 'code' attribute over 'status_code' attribute + and handles edge cases like empty strings and "None" string values. + """ + from litellm.litellm_core_utils.litellm_logging import StandardLoggingPayloadSetup + + # Test case 1: Exception with 'code' attribute (ProxyException style) + class ProxyException(Exception): + def __init__(self, code, message): + self.code = code + self.message = message + super().__init__(message) + + proxy_exception = ProxyException(code="500", message="Internal Server Error") + result = StandardLoggingPayloadSetup.get_error_information(proxy_exception) + assert result["error_code"] == "500" + assert result["error_class"] == "ProxyException" + + # Test case 2: Exception with 'status_code' attribute (LiteLLM style) + class LiteLLMException(Exception): + def __init__(self, status_code, message): + self.status_code = status_code + self.message = message + super().__init__(message) + + litellm_exception = LiteLLMException(status_code=429, message="Rate limit exceeded") + result = StandardLoggingPayloadSetup.get_error_information(litellm_exception) + assert result["error_code"] == "429" + assert result["error_class"] == "LiteLLMException" + + # Test case 3: Exception with both 'code' and 'status_code' - should prefer 'code' + class BothAttributesException(Exception): + def __init__(self, code, status_code, message): + self.code = code + self.status_code = status_code + self.message = message + super().__init__(message) + + both_exception = BothAttributesException( + code="400", status_code=500, message="Bad Request" + ) + result = StandardLoggingPayloadSetup.get_error_information(both_exception) + assert result["error_code"] == "400" # Should prefer 'code' over 'status_code' + + # Test case 4: Exception with 'code' as empty string - should fall back to 'status_code' + empty_code_exception = BothAttributesException( + code="", status_code=404, message="Not Found" + ) + result = StandardLoggingPayloadSetup.get_error_information(empty_code_exception) + assert result["error_code"] == "404" # Should fall back to status_code + + # Test case 5: Exception with 'code' as "None" string - should fall back to 'status_code' + none_string_exception = BothAttributesException( + code="None", status_code=503, message="Service Unavailable" + ) + result = StandardLoggingPayloadSetup.get_error_information(none_string_exception) + assert result["error_code"] == "503" # Should fall back to status_code + + # Test case 6: Exception with 'code' as None - should fall back to 'status_code' + none_code_exception = BothAttributesException( + code=None, status_code=401, message="Unauthorized" + ) + result = StandardLoggingPayloadSetup.get_error_information(none_code_exception) + assert result["error_code"] == "401" # Should fall back to status_code + + # Test case 7: Exception with neither 'code' nor 'status_code' - should return empty string + class NoCodeException(Exception): + def __init__(self, message): + self.message = message + super().__init__(message) + + no_code_exception = NoCodeException(message="Generic error") + result = StandardLoggingPayloadSetup.get_error_information(no_code_exception) + assert result["error_code"] == "" + assert result["error_class"] == "NoCodeException" From d081e01ed0092f1dbdc5b8f5028df95e67467626 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Thu, 29 Jan 2026 12:59:42 -0800 Subject: [PATCH 2/4] Show spend logs settings + allow delete of rentention period --- .../hooks/proxyConfig/useProxyConfig.test.ts | 554 ++++++++++++++++++ .../hooks/proxyConfig/useProxyConfig.ts | 180 ++++++ .../view_logs/ConfigInfoMessage.tsx | 16 +- .../SpendLogsSettingsModal.tsx | 104 +++- .../src/components/view_logs/index.tsx | 8 +- 5 files changed, 832 insertions(+), 30 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/proxyConfig/useProxyConfig.test.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/proxyConfig/useProxyConfig.ts diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/proxyConfig/useProxyConfig.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/proxyConfig/useProxyConfig.test.ts new file mode 100644 index 00000000000..a8ce55d2745 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/proxyConfig/useProxyConfig.test.ts @@ -0,0 +1,554 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import React, { ReactNode } from "react"; +import { + useProxyConfig, + useDeleteProxyConfigField, + getProxyConfigCall, + deleteProxyConfigFieldCall, + ConfigType, + GeneralSettingsFieldName, + type ProxyConfigResponse, + type DeleteProxyConfigFieldRequest, + type DeleteProxyConfigFieldResponse, +} from "./useProxyConfig"; + +const { + mockProxyBaseUrl, + mockAccessToken, + mockHeaderName, + mockProxyConfigResponse, + mockDeleteResponse, + mockUseAuthorized, + mockGetGlobalLitellmHeaderName, + mockDeriveErrorMessage, + mockHandleError, +} = vi.hoisted(() => { + const mockProxyBaseUrl = "https://proxy.example.com"; + const mockAccessToken = "test-access-token"; + const mockHeaderName = "X-LiteLLM-API-Key"; + + const mockProxyConfigResponse: ProxyConfigResponse = [ + { + field_name: "maximum_spend_logs_retention_period", + field_type: "int", + field_description: "Maximum retention period for spend logs", + field_value: 30, + stored_in_db: true, + field_default_value: 7, + premium_field: false, + nested_fields: null, + }, + { + field_name: "another_field", + field_type: "string", + field_description: "Another config field", + field_value: "test-value", + stored_in_db: false, + field_default_value: "default-value", + premium_field: true, + nested_fields: [ + { + field_name: "nested_field", + field_type: "string", + field_description: "Nested field description", + field_default_value: "nested-default", + stored_in_db: true, + }, + ], + }, + ]; + + const mockDeleteResponse: DeleteProxyConfigFieldResponse = { + message: "Field deleted successfully", + }; + + const mockUseAuthorized = vi.fn(); + const mockGetGlobalLitellmHeaderName = vi.fn(() => mockHeaderName); + const mockDeriveErrorMessage = vi.fn((errorData: any) => { + if (typeof errorData === "string") return errorData; + return errorData?.message || errorData?.error || "An error occurred"; + }); + const mockHandleError = vi.fn(); + + return { + mockProxyBaseUrl, + mockAccessToken, + mockHeaderName, + mockProxyConfigResponse, + mockDeleteResponse, + mockUseAuthorized, + mockGetGlobalLitellmHeaderName, + mockDeriveErrorMessage, + mockHandleError, + }; +}); + +vi.mock("../useAuthorized", () => ({ + default: () => mockUseAuthorized(), +})); + +vi.mock("@/components/networking", () => ({ + proxyBaseUrl: mockProxyBaseUrl, + getGlobalLitellmHeaderName: mockGetGlobalLitellmHeaderName, + deriveErrorMessage: mockDeriveErrorMessage, + handleError: mockHandleError, +})); + +vi.mock("../common/queryKeysFactory", () => ({ + createQueryKeys: vi.fn((resource: string) => ({ + all: [resource], + lists: () => [resource, "list"], + list: (params?: any) => [resource, "list", { params }], + details: () => [resource, "detail"], + detail: (uid: string) => [resource, "detail", uid], + })), +})); + +describe("useProxyConfig", () => { + let queryClient: QueryClient; + let fetchSpy: ReturnType; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + vi.clearAllMocks(); + + mockUseAuthorized.mockReturnValue({ + accessToken: mockAccessToken, + userId: "test-user-id", + userRole: "Admin", + token: "test-token", + userEmail: "test@example.com", + premiumUser: false, + disabledPersonalKeyCreation: null, + showSSOBanner: false, + }); + + fetchSpy = vi.fn(); + global.fetch = fetchSpy; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + const wrapper = ({ children }: { children: ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); + + it("should render successfully", () => { + (fetchSpy as any).mockResolvedValue({ + ok: true, + json: async () => mockProxyConfigResponse, + }); + + const { result } = renderHook(() => useProxyConfig(ConfigType.GENERAL_SETTINGS), { wrapper }); + + expect(result.current).toBeDefined(); + expect(result.current.isLoading).toBe(true); + }); + + it("should return proxy config data when query is successful", async () => { + (fetchSpy as any).mockResolvedValue({ + ok: true, + json: async () => mockProxyConfigResponse, + }); + + const { result } = renderHook(() => useProxyConfig(ConfigType.GENERAL_SETTINGS), { wrapper }); + + expect(result.current.isLoading).toBe(true); + expect(result.current.data).toBeUndefined(); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data).toEqual(mockProxyConfigResponse); + expect(result.current.error).toBeNull(); + expect(fetchSpy).toHaveBeenCalledWith( + `${mockProxyBaseUrl}/config/list?config_type=${ConfigType.GENERAL_SETTINGS}`, + { + method: "GET", + headers: { + [mockHeaderName]: `Bearer ${mockAccessToken}`, + "Content-Type": "application/json", + }, + }, + ); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it("should handle error when API call fails", async () => { + const errorMessage = "Failed to fetch proxy config"; + const errorResponse = { message: errorMessage }; + + (fetchSpy as any).mockResolvedValue({ + ok: false, + json: async () => errorResponse, + }); + + const { result } = renderHook(() => useProxyConfig(ConfigType.GENERAL_SETTINGS), { wrapper }); + + expect(result.current.isLoading).toBe(true); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + expect(result.current.isError).toBe(true); + }); + + expect(result.current.error).toBeDefined(); + expect(result.current.data).toBeUndefined(); + }); + + it("should not execute query when accessToken is missing", async () => { + mockUseAuthorized.mockReturnValue({ + accessToken: null, + userId: "test-user-id", + userRole: "Admin", + token: null, + userEmail: "test@example.com", + premiumUser: false, + disabledPersonalKeyCreation: null, + showSSOBanner: false, + }); + + const { result } = renderHook(() => useProxyConfig(ConfigType.GENERAL_SETTINGS), { wrapper }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.data).toBeUndefined(); + expect(result.current.isFetched).toBe(false); + + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("should use correct query key with config type filter", async () => { + (fetchSpy as any).mockResolvedValue({ + ok: true, + json: async () => mockProxyConfigResponse, + }); + + const { result } = renderHook(() => useProxyConfig(ConfigType.GENERAL_SETTINGS), { wrapper }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data).toEqual(mockProxyConfigResponse); + }); + + it("should handle network errors", async () => { + const networkError = new Error("Network error"); + (fetchSpy as any).mockRejectedValue(networkError); + + const { result } = renderHook(() => useProxyConfig(ConfigType.GENERAL_SETTINGS), { wrapper }); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expect(result.current.error).toBeDefined(); + expect(result.current.data).toBeUndefined(); + }); + + it("should handle empty config response", async () => { + (fetchSpy as any).mockResolvedValue({ + ok: true, + json: async () => [], + }); + + const { result } = renderHook(() => useProxyConfig(ConfigType.GENERAL_SETTINGS), { wrapper }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data).toEqual([]); + }); +}); + +describe("useDeleteProxyConfigField", () => { + let queryClient: QueryClient; + let fetchSpy: ReturnType; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + mutations: { + retry: false, + }, + }, + }); + + vi.clearAllMocks(); + + mockUseAuthorized.mockReturnValue({ + accessToken: mockAccessToken, + userId: "test-user-id", + userRole: "Admin", + token: "test-token", + userEmail: "test@example.com", + premiumUser: false, + disabledPersonalKeyCreation: null, + showSSOBanner: false, + }); + + fetchSpy = vi.fn(); + global.fetch = fetchSpy; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + const wrapper = ({ children }: { children: ReactNode }) => + React.createElement(QueryClientProvider, { client: queryClient }, children); + + it("should render successfully", () => { + const { result } = renderHook(() => useDeleteProxyConfigField(), { wrapper }); + + expect(result.current).toBeDefined(); + expect(result.current.isIdle).toBe(true); + }); + + it("should successfully delete a proxy config field", async () => { + (fetchSpy as any).mockResolvedValue({ + ok: true, + json: async () => mockDeleteResponse, + }); + + const { result } = renderHook(() => useDeleteProxyConfigField(), { wrapper }); + + const deleteRequest: DeleteProxyConfigFieldRequest = { + config_type: ConfigType.GENERAL_SETTINGS, + field_name: GeneralSettingsFieldName.MAXIMUM_SPEND_LOGS_RETENTION_PERIOD, + }; + + result.current.mutate(deleteRequest); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data).toEqual(mockDeleteResponse); + expect(fetchSpy).toHaveBeenCalledWith(`${mockProxyBaseUrl}/config/field/delete`, { + method: "POST", + headers: { + [mockHeaderName]: `Bearer ${mockAccessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(deleteRequest), + }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it("should handle error when delete request fails", async () => { + const errorMessage = "Failed to delete field"; + const errorResponse = { message: errorMessage }; + + (fetchSpy as any).mockResolvedValue({ + ok: false, + json: async () => errorResponse, + }); + + const { result } = renderHook(() => useDeleteProxyConfigField(), { wrapper }); + + const deleteRequest: DeleteProxyConfigFieldRequest = { + config_type: ConfigType.GENERAL_SETTINGS, + field_name: GeneralSettingsFieldName.MAXIMUM_SPEND_LOGS_RETENTION_PERIOD, + }; + + result.current.mutate(deleteRequest); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expect(result.current.error).toBeDefined(); + expect(result.current.data).toBeUndefined(); + }); + + it("should throw error when accessToken is missing", async () => { + mockUseAuthorized.mockReturnValue({ + accessToken: null, + userId: "test-user-id", + userRole: "Admin", + token: null, + userEmail: "test@example.com", + premiumUser: false, + disabledPersonalKeyCreation: null, + showSSOBanner: false, + }); + + const { result } = renderHook(() => useDeleteProxyConfigField(), { wrapper }); + + const deleteRequest: DeleteProxyConfigFieldRequest = { + config_type: ConfigType.GENERAL_SETTINGS, + field_name: GeneralSettingsFieldName.MAXIMUM_SPEND_LOGS_RETENTION_PERIOD, + }; + + result.current.mutate(deleteRequest); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expect(result.current.error?.message).toBe("Access token is required"); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("should handle network errors during delete", async () => { + const networkError = new Error("Network error"); + (fetchSpy as any).mockRejectedValue(networkError); + + const { result } = renderHook(() => useDeleteProxyConfigField(), { wrapper }); + + const deleteRequest: DeleteProxyConfigFieldRequest = { + config_type: ConfigType.GENERAL_SETTINGS, + field_name: GeneralSettingsFieldName.MAXIMUM_SPEND_LOGS_RETENTION_PERIOD, + }; + + result.current.mutate(deleteRequest); + + await waitFor(() => { + expect(result.current.isError).toBe(true); + }); + + expect(result.current.error).toBeDefined(); + }); +}); + +describe("getProxyConfigCall", () => { + let fetchSpy: ReturnType; + let consoleErrorSpy: ReturnType; + + beforeEach(() => { + fetchSpy = vi.fn(); + global.fetch = fetchSpy; + consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("should successfully fetch proxy config", async () => { + (fetchSpy as any).mockResolvedValue({ + ok: true, + json: async () => mockProxyConfigResponse, + }); + + const result = await getProxyConfigCall(mockAccessToken, ConfigType.GENERAL_SETTINGS); + + expect(result).toEqual(mockProxyConfigResponse); + expect(fetchSpy).toHaveBeenCalledWith( + `${mockProxyBaseUrl}/config/list?config_type=${ConfigType.GENERAL_SETTINGS}`, + { + method: "GET", + headers: { + [mockHeaderName]: `Bearer ${mockAccessToken}`, + "Content-Type": "application/json", + }, + }, + ); + }); + + it("should throw error when API returns error response", async () => { + const errorMessage = "Failed to fetch config"; + const errorResponse = { message: errorMessage }; + + (fetchSpy as any).mockResolvedValue({ + ok: false, + json: async () => errorResponse, + }); + + await expect(getProxyConfigCall(mockAccessToken, ConfigType.GENERAL_SETTINGS)).rejects.toThrow(errorMessage); + }); + + it("should handle network errors", async () => { + const networkError = new Error("Network error"); + (fetchSpy as any).mockRejectedValue(networkError); + + await expect(getProxyConfigCall(mockAccessToken, ConfigType.GENERAL_SETTINGS)).rejects.toThrow("Network error"); + expect(consoleErrorSpy).toHaveBeenCalled(); + }); +}); + +describe("deleteProxyConfigFieldCall", () => { + let fetchSpy: ReturnType; + let consoleErrorSpy: ReturnType; + + beforeEach(() => { + fetchSpy = vi.fn(); + global.fetch = fetchSpy; + consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("should successfully delete proxy config field", async () => { + (fetchSpy as any).mockResolvedValue({ + ok: true, + json: async () => mockDeleteResponse, + }); + + const deleteRequest: DeleteProxyConfigFieldRequest = { + config_type: ConfigType.GENERAL_SETTINGS, + field_name: GeneralSettingsFieldName.MAXIMUM_SPEND_LOGS_RETENTION_PERIOD, + }; + + const result = await deleteProxyConfigFieldCall(mockAccessToken, deleteRequest); + + expect(result).toEqual(mockDeleteResponse); + expect(fetchSpy).toHaveBeenCalledWith(`${mockProxyBaseUrl}/config/field/delete`, { + method: "POST", + headers: { + [mockHeaderName]: `Bearer ${mockAccessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(deleteRequest), + }); + }); + + it("should throw error when API returns error response", async () => { + const errorMessage = "Failed to delete field"; + const errorResponse = { message: errorMessage }; + + (fetchSpy as any).mockResolvedValue({ + ok: false, + json: async () => errorResponse, + }); + + const deleteRequest: DeleteProxyConfigFieldRequest = { + config_type: ConfigType.GENERAL_SETTINGS, + field_name: GeneralSettingsFieldName.MAXIMUM_SPEND_LOGS_RETENTION_PERIOD, + }; + + await expect(deleteProxyConfigFieldCall(mockAccessToken, deleteRequest)).rejects.toThrow(errorMessage); + }); + + it("should handle network errors", async () => { + const networkError = new Error("Network error"); + (fetchSpy as any).mockRejectedValue(networkError); + + const deleteRequest: DeleteProxyConfigFieldRequest = { + config_type: ConfigType.GENERAL_SETTINGS, + field_name: GeneralSettingsFieldName.MAXIMUM_SPEND_LOGS_RETENTION_PERIOD, + }; + + await expect(deleteProxyConfigFieldCall(mockAccessToken, deleteRequest)).rejects.toThrow("Network error"); + expect(consoleErrorSpy).toHaveBeenCalled(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/proxyConfig/useProxyConfig.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/proxyConfig/useProxyConfig.ts new file mode 100644 index 00000000000..b823ce4ffd8 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/proxyConfig/useProxyConfig.ts @@ -0,0 +1,180 @@ +import { useQuery, useMutation, UseMutationResult } from "@tanstack/react-query"; +import { createQueryKeys } from "../common/queryKeysFactory"; +import useAuthorized from "../useAuthorized"; +import { proxyBaseUrl, getGlobalLitellmHeaderName, deriveErrorMessage, handleError } from "@/components/networking"; + +/** + * Enum for config types that can be fetched from the proxy config endpoint. + * Currently supports general_settings, but can be extended as more config types are added. + */ +export enum ConfigType { + GENERAL_SETTINGS = "general_settings", +} + +/** + * Enum for supported field names that can be deleted from general_settings. + * This should match the fields available in ConfigGeneralSettings. + */ +export enum GeneralSettingsFieldName { + MAXIMUM_SPEND_LOGS_RETENTION_PERIOD = "maximum_spend_logs_retention_period", + // Add more field names here as needed +} + +/** + * Field detail for nested fields within a config field + */ +export interface FieldDetail { + field_name: string; + field_type: string; + field_description: string; + field_default_value: any; + stored_in_db: boolean | null; +} + +/** + * Configuration list item returned from /config/list endpoint + */ +export interface ConfigListItem { + field_name: string; + field_type: string; + field_description: string; + field_value: any; + stored_in_db: boolean | null; + field_default_value: any; + premium_field?: boolean; + nested_fields?: FieldDetail[] | null; +} + +/** + * Response type for /config/list endpoint + */ +export type ProxyConfigResponse = ConfigListItem[]; + +/** + * Request body for /config/field/delete endpoint + */ +export interface DeleteProxyConfigFieldRequest { + config_type: ConfigType; + field_name: string; +} + +/** + * Response type for /config/field/delete endpoint + */ +export interface DeleteProxyConfigFieldResponse { + message?: string; + [key: string]: any; +} + +/** + * Network call function to fetch proxy config by config type + * @param accessToken - The access token for authentication + * @param configType - The type of config to fetch (from ConfigType enum) + * @returns Promise resolving to the config list response + */ +export const getProxyConfigCall = async (accessToken: string, configType: ConfigType): Promise => { + try { + const url = proxyBaseUrl + ? `${proxyBaseUrl}/config/list?config_type=${configType}` + : `/config/list?config_type=${configType}`; + + const response = await fetch(url, { + method: "GET", + headers: { + [getGlobalLitellmHeaderName()]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + const errorData = await response.json(); + const errorMessage = deriveErrorMessage(errorData); + handleError(errorMessage); + throw new Error(errorMessage); + } + + const data = await response.json(); + return data; + } catch (error) { + console.error(`Failed to get proxy config for ${configType}:`, error); + throw error; + } +}; + +const proxyConfigKeys = createQueryKeys("proxyConfig"); + +/** + * Network call function to delete a proxy config field + * @param accessToken - The access token for authentication + * @param request - The delete request containing config_type and field_name + * @returns Promise resolving to the delete response + */ +export const deleteProxyConfigFieldCall = async ( + accessToken: string, + request: DeleteProxyConfigFieldRequest, +): Promise => { + try { + const url = proxyBaseUrl ? `${proxyBaseUrl}/config/field/delete` : `/config/field/delete`; + + const response = await fetch(url, { + method: "POST", + headers: { + [getGlobalLitellmHeaderName()]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(request), + }); + + if (!response.ok) { + const errorData = await response.json(); + const errorMessage = deriveErrorMessage(errorData); + handleError(errorMessage); + throw new Error(errorMessage); + } + + const data = await response.json(); + return data; + } catch (error) { + console.error(`Failed to delete proxy config field ${request.field_name}:`, error); + throw error; + } +}; + +/** + * React Query hook to fetch proxy config by config type + * @param configType - The type of config to fetch (from ConfigType enum) + * @returns React Query result with the config list data + */ +export const useProxyConfig = (configType: ConfigType) => { + const { accessToken } = useAuthorized(); + return useQuery({ + queryKey: proxyConfigKeys.list({ + filters: { + configType, + }, + }), + queryFn: async () => await getProxyConfigCall(accessToken!, configType), + enabled: Boolean(accessToken), + }); +}; + +/** + * React Query hook to delete a proxy config field + * @returns React Query mutation result for deleting config fields + */ +export const useDeleteProxyConfigField = (): UseMutationResult< + DeleteProxyConfigFieldResponse, + Error, + DeleteProxyConfigFieldRequest +> => { + const { accessToken } = useAuthorized(); + + return useMutation({ + mutationFn: async (request: DeleteProxyConfigFieldRequest) => { + if (!accessToken) { + throw new Error("Access token is required"); + } + return await deleteProxyConfigFieldCall(accessToken, request); + }, + }); +}; diff --git a/ui/litellm-dashboard/src/components/view_logs/ConfigInfoMessage.tsx b/ui/litellm-dashboard/src/components/view_logs/ConfigInfoMessage.tsx index 3d8b8ea079c..509b1c73de2 100644 --- a/ui/litellm-dashboard/src/components/view_logs/ConfigInfoMessage.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/ConfigInfoMessage.tsx @@ -2,9 +2,10 @@ import React from "react"; interface ConfigInfoMessageProps { show: boolean; + onOpenSettings?: () => void; } -export const ConfigInfoMessage: React.FC = ({ show }) => { +export const ConfigInfoMessage: React.FC = ({ show, onOpenSettings }) => { if (!show) return null; return ( @@ -30,7 +31,18 @@ export const ConfigInfoMessage: React.FC = ({ show }) =>

Request/Response Data Not Available

To view request and response details, enable prompt storage in your LiteLLM configuration by adding the - following to your proxy_config.yaml file: + following to your proxy_config.yaml file + {onOpenSettings && ( + <> or{" "} + + {" "}to configure this directly. + + )}

           {`general_settings:
diff --git a/ui/litellm-dashboard/src/components/view_logs/SpendLogsSettingsModal/SpendLogsSettingsModal.tsx b/ui/litellm-dashboard/src/components/view_logs/SpendLogsSettingsModal/SpendLogsSettingsModal.tsx
index cf8e51be94b..6fe8b495346 100644
--- a/ui/litellm-dashboard/src/components/view_logs/SpendLogsSettingsModal/SpendLogsSettingsModal.tsx
+++ b/ui/litellm-dashboard/src/components/view_logs/SpendLogsSettingsModal/SpendLogsSettingsModal.tsx
@@ -1,11 +1,12 @@
 "use client";
 
 import { StoreRequestInSpendLogsParams, useStoreRequestInSpendLogs } from "@/app/(dashboard)/hooks/storeRequestInSpendLogs/useStoreRequestInSpendLogs";
+import { ConfigType, useProxyConfig, useDeleteProxyConfigField, GeneralSettingsFieldName } from "@/app/(dashboard)/hooks/proxyConfig/useProxyConfig";
 import NotificationsManager from "@/components/molecules/notifications_manager";
 import { parseErrorMessage } from "@/components/shared/errorUtils";
 import { ClockCircleOutlined } from "@ant-design/icons";
-import { Button, Form, Input, Modal, Space, Switch } from "antd";
-import React from "react";
+import { Button, Form, Input, Modal, Skeleton, Space, Switch } from "antd";
+import React, { useEffect, useMemo } from "react";
 
 interface SpendLogsSettingsModalProps {
   isVisible: boolean;
@@ -16,14 +17,69 @@ interface SpendLogsSettingsModalProps {
 const SpendLogsSettingsModal: React.FC = ({ isVisible, onCancel, onSuccess }) => {
   const [form] = Form.useForm();
   const { mutateAsync, isPending } = useStoreRequestInSpendLogs();
+  const { mutateAsync: deleteField, isPending: isDeletingField } = useDeleteProxyConfigField();
+  const { data: proxyConfigData, isLoading: isLoadingConfig, refetch } = useProxyConfig(ConfigType.GENERAL_SETTINGS);
   const storePromptsValue = Form.useWatch('store_prompts_in_spend_logs', form);
 
+  // Refetch config when modal opens to ensure we have the latest values
+  useEffect(() => {
+    if (isVisible) {
+      refetch();
+    }
+  }, [isVisible, refetch]);
+
+  // Compute initial values from fetched config data
+  const initialValues = useMemo(() => {
+    if (!proxyConfigData) {
+      return {
+        store_prompts_in_spend_logs: false,
+        maximum_spend_logs_retention_period: undefined,
+      };
+    }
+
+    const storePromptsField = proxyConfigData.find(field => field.field_name === 'store_prompts_in_spend_logs');
+    const retentionPeriodField = proxyConfigData.find(field => field.field_name === 'maximum_spend_logs_retention_period');
+
+    return {
+      store_prompts_in_spend_logs: storePromptsField?.field_value ?? false,
+      maximum_spend_logs_retention_period: retentionPeriodField?.field_value ?? undefined,
+    };
+  }, [proxyConfigData]);
+
   const handleFormSubmit = async (formValues: StoreRequestInSpendLogsParams) => {
     try {
-      await mutateAsync(formValues, {
+      // If maximum_spend_logs_retention_period is empty/null, delete the field first
+      const retentionPeriodValue = formValues.maximum_spend_logs_retention_period;
+      const shouldDeleteRetentionPeriod =
+        !retentionPeriodValue ||
+        (typeof retentionPeriodValue === "string" && retentionPeriodValue.trim() === "");
+
+      if (shouldDeleteRetentionPeriod) {
+        try {
+          await deleteField({
+            config_type: ConfigType.GENERAL_SETTINGS,
+            field_name: GeneralSettingsFieldName.MAXIMUM_SPEND_LOGS_RETENTION_PERIOD,
+          });
+        } catch (deleteError) {
+          // If field doesn't exist, that's okay - continue with update
+          console.warn("Failed to delete retention period field (may not exist):", deleteError);
+        }
+      }
+
+      // Update the settings (excluding maximum_spend_logs_retention_period if it's empty)
+      const updateParams: StoreRequestInSpendLogsParams = {
+        store_prompts_in_spend_logs: formValues.store_prompts_in_spend_logs,
+        ...(retentionPeriodValue &&
+          typeof retentionPeriodValue === "string" &&
+          retentionPeriodValue.trim() !== "" && {
+          maximum_spend_logs_retention_period: retentionPeriodValue,
+        }),
+      };
+
+      await mutateAsync(updateParams, {
         onSuccess: () => {
           NotificationsManager.success("Spend logs settings updated successfully");
-          form.resetFields();
+          refetch(); // Refetch config to get updated values
           onSuccess?.();
         },
         onError: (error) => {
@@ -44,53 +100,53 @@ const SpendLogsSettingsModal: React.FC = ({ isVisib
     
-          
-          
         
       }
       onCancel={handleCancel}
     >
+
       
f.field_name === 'store_prompts_in_spend_logs')?.field_description || + "When enabled, prompts will be stored in spend logs for tracking and analysis purposes." + } valuePropName="checked" > -
- Store Prompts in Spend Logs - form.setFieldValue('store_prompts_in_spend_logs', checked)} /> +
+ + {isLoadingConfig ? : form.setFieldValue('store_prompts_in_spend_logs', checked)} />}
- f.field_name === 'maximum_spend_logs_retention_period')?.field_description || + "Set the maximum retention period for spend logs (e.g., '7d' for 7 days, '30d' for 30 days). Leave empty for no limit." + } > - : } - /> + />} diff --git a/ui/litellm-dashboard/src/components/view_logs/index.tsx b/ui/litellm-dashboard/src/components/view_logs/index.tsx index 22af41f6d08..daf52619f8d 100644 --- a/ui/litellm-dashboard/src/components/view_logs/index.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/index.tsx @@ -555,7 +555,7 @@ export default function SpendLogsTable({ setIsSpendLogsSettingsModalVisible(true)} />} getRowCanExpand={() => true} // Optionally: add session-specific row expansion state /> @@ -754,7 +754,7 @@ export default function SpendLogsTable({ setIsSpendLogsSettingsModalVisible(true)} />} getRowCanExpand={() => true} />
@@ -780,7 +780,7 @@ export default function SpendLogsTable({ ); } -export function RequestViewer({ row }: { row: Row }) { +export function RequestViewer({ row, onOpenSettings }: { row: Row; onOpenSettings?: () => void }) { // Helper function to clean metadata by removing specific fields const formatData = (input: any) => { if (typeof input === "string") { @@ -991,7 +991,7 @@ export function RequestViewer({ row }: { row: Row }) { {/* Configuration Info Message - Show when data is missing */} - + {/* Request/Response Panel */}
From e080f92b7fd218e55aef40bb5b0895cc24894316 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Thu, 29 Jan 2026 13:05:07 -0800 Subject: [PATCH 3/4] Adding tests --- .../SpendLogsSettingsModal.test.tsx | 162 ++++++++++++++++-- 1 file changed, 149 insertions(+), 13 deletions(-) diff --git a/ui/litellm-dashboard/src/components/view_logs/SpendLogsSettingsModal/SpendLogsSettingsModal.test.tsx b/ui/litellm-dashboard/src/components/view_logs/SpendLogsSettingsModal/SpendLogsSettingsModal.test.tsx index e955f42872b..40d06d90461 100644 --- a/ui/litellm-dashboard/src/components/view_logs/SpendLogsSettingsModal/SpendLogsSettingsModal.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/SpendLogsSettingsModal/SpendLogsSettingsModal.test.tsx @@ -1,3 +1,4 @@ +import { useDeleteProxyConfigField, useProxyConfig } from "@/app/(dashboard)/hooks/proxyConfig/useProxyConfig"; import { useStoreRequestInSpendLogs } from "@/app/(dashboard)/hooks/storeRequestInSpendLogs/useStoreRequestInSpendLogs"; import NotificationsManager from "@/components/molecules/notifications_manager"; import { parseErrorMessage } from "@/components/shared/errorUtils"; @@ -8,6 +9,7 @@ import { renderWithProviders } from "../../../../tests/test-utils"; import SpendLogsSettingsModal from "./SpendLogsSettingsModal"; vi.mock("@/app/(dashboard)/hooks/storeRequestInSpendLogs/useStoreRequestInSpendLogs"); +vi.mock("@/app/(dashboard)/hooks/proxyConfig/useProxyConfig"); vi.mock("@/components/molecules/notifications_manager", () => ({ default: { success: vi.fn(), @@ -19,6 +21,8 @@ vi.mock("@/components/shared/errorUtils", () => ({ })); const mockUseStoreRequestInSpendLogs = vi.mocked(useStoreRequestInSpendLogs); +const mockUseProxyConfig = vi.mocked(useProxyConfig); +const mockUseDeleteProxyConfigField = vi.mocked(useDeleteProxyConfigField); const mockNotificationsManager = vi.mocked(NotificationsManager); const mockParseErrorMessage = vi.mocked(parseErrorMessage); @@ -26,6 +30,8 @@ describe("SpendLogsSettingsModal", () => { const mockOnCancel = vi.fn(); const mockOnSuccess = vi.fn(); const mockMutateAsync = vi.fn(); + const mockDeleteField = vi.fn(); + const mockRefetch = vi.fn(); const defaultProps = { isVisible: true, @@ -39,6 +45,15 @@ describe("SpendLogsSettingsModal", () => { mutateAsync: mockMutateAsync, isPending: false, } as any); + mockUseDeleteProxyConfigField.mockReturnValue({ + mutateAsync: mockDeleteField, + isPending: false, + } as any); + mockUseProxyConfig.mockReturnValue({ + data: [], + isLoading: false, + refetch: mockRefetch, + } as any); mockParseErrorMessage.mockImplementation((error: any) => error?.message || String(error)); }); @@ -127,6 +142,7 @@ describe("SpendLogsSettingsModal", () => { await user.click(saveButton); await waitFor(() => { + expect(mockDeleteField).not.toHaveBeenCalled(); expect(mockMutateAsync).toHaveBeenCalledWith( { store_prompts_in_spend_logs: true, @@ -139,6 +155,7 @@ describe("SpendLogsSettingsModal", () => { it("should submit form with store prompts disabled and no retention period", async () => { const user = userEvent.setup(); + mockDeleteField.mockResolvedValue({ message: "Field deleted successfully" }); mockMutateAsync.mockImplementation(async (params, options) => { await Promise.resolve(); options?.onSuccess?.(); @@ -151,10 +168,10 @@ describe("SpendLogsSettingsModal", () => { await user.click(saveButton); await waitFor(() => { + expect(mockDeleteField).toHaveBeenCalled(); expect(mockMutateAsync).toHaveBeenCalledWith( { store_prompts_in_spend_logs: false, - maximum_spend_logs_retention_period: undefined, }, expect.any(Object) ); @@ -163,6 +180,7 @@ describe("SpendLogsSettingsModal", () => { it("should show success notification and call onSuccess on successful submission", async () => { const user = userEvent.setup(); + mockDeleteField.mockResolvedValue({ message: "Field deleted successfully" }); mockMutateAsync.mockImplementation(async (params, options) => { await Promise.resolve(); options?.onSuccess?.(); @@ -176,6 +194,7 @@ describe("SpendLogsSettingsModal", () => { await waitFor(() => { expect(mockNotificationsManager.success).toHaveBeenCalledWith("Spend logs settings updated successfully"); + expect(mockRefetch).toHaveBeenCalled(); expect(mockOnSuccess).toHaveBeenCalledTimes(1); }); }); @@ -227,6 +246,31 @@ describe("SpendLogsSettingsModal", () => { expect(cancelButton).toBeDisabled(); }); + it("should disable cancel button when deleting field", () => { + mockUseDeleteProxyConfigField.mockReturnValue({ + mutateAsync: mockDeleteField, + isPending: true, + } as any); + + renderWithProviders(); + + const cancelButton = screen.getByRole("button", { name: "Cancel" }); + expect(cancelButton).toBeDisabled(); + }); + + it("should disable cancel button when loading config", () => { + mockUseProxyConfig.mockReturnValue({ + data: undefined, + isLoading: true, + refetch: mockRefetch, + } as any); + + renderWithProviders(); + + const cancelButton = screen.getByRole("button", { name: "Cancel" }); + expect(cancelButton).toBeDisabled(); + }); + it("should show loading state on save button when pending", () => { mockUseStoreRequestInSpendLogs.mockReturnValue({ mutateAsync: mockMutateAsync, @@ -240,6 +284,19 @@ describe("SpendLogsSettingsModal", () => { expect(saveButton.className).toContain("ant-btn-loading"); }); + it("should show loading state on save button when deleting field", () => { + mockUseDeleteProxyConfigField.mockReturnValue({ + mutateAsync: mockDeleteField, + isPending: true, + } as any); + + renderWithProviders(); + + const saveButton = screen.getByRole("button", { name: /Saving/i }); + expect(saveButton).toBeInTheDocument(); + expect(saveButton.className).toContain("ant-btn-loading"); + }); + it("should call onCancel when cancel button is clicked after modifying form", async () => { const user = userEvent.setup(); renderWithProviders(); @@ -259,15 +316,16 @@ describe("SpendLogsSettingsModal", () => { expect(mockOnCancel).toHaveBeenCalledTimes(1); }); - it("should reset form fields after successful submission", async () => { + it("should call refetch after successful submission", async () => { const user = userEvent.setup(); + mockDeleteField.mockResolvedValue({ message: "Field deleted successfully" }); mockMutateAsync.mockImplementation(async (params, options) => { await Promise.resolve(); options?.onSuccess?.(); return { message: "Success" }; }); - const { rerender } = renderWithProviders(); + renderWithProviders(); const switchElement = screen.getByRole("switch"); await user.click(switchElement); @@ -283,20 +341,13 @@ describe("SpendLogsSettingsModal", () => { await waitFor(() => { expect(mockNotificationsManager.success).toHaveBeenCalled(); - }); - - rerender(); - - await waitFor(() => { - const updatedSwitchElement = screen.getByRole("switch"); - const updatedRetentionInput = screen.getByPlaceholderText("e.g., 7d, 30d"); - expect(updatedSwitchElement).not.toBeChecked(); - expect(updatedRetentionInput).toHaveValue(""); + expect(mockRefetch).toHaveBeenCalled(); }); }); it("should not call onSuccess when it is not provided", async () => { const user = userEvent.setup(); + mockDeleteField.mockResolvedValue({ message: "Field deleted successfully" }); mockMutateAsync.mockImplementation(async (params, options) => { await Promise.resolve(); options?.onSuccess?.(); @@ -319,8 +370,93 @@ describe("SpendLogsSettingsModal", () => { expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); }); + it("should call refetch when modal opens", () => { + renderWithProviders(); + + expect(mockRefetch).toHaveBeenCalledTimes(1); + }); + + it("should render form with initial values from config data", () => { + mockUseProxyConfig.mockReturnValue({ + data: [ + { + field_name: "store_prompts_in_spend_logs", + field_type: "bool", + field_description: "Store prompts in spend logs", + field_value: true, + stored_in_db: true, + field_default_value: false, + }, + { + field_name: "maximum_spend_logs_retention_period", + field_type: "string", + field_description: "Maximum retention period", + field_value: "30d", + stored_in_db: true, + field_default_value: undefined, + }, + ], + isLoading: false, + refetch: mockRefetch, + } as any); + + renderWithProviders(); + + const switchElement = screen.getByRole("switch"); + const retentionInput = screen.getByPlaceholderText("e.g., 7d, 30d"); + + expect(switchElement).toBeChecked(); + expect(retentionInput).toHaveValue("30d"); + }); + + it("should show skeleton loaders when config is loading", () => { + mockUseProxyConfig.mockReturnValue({ + data: undefined, + isLoading: true, + refetch: mockRefetch, + } as any); + + renderWithProviders(); + + // Check that switch and input are not present when loading (skeletons are shown instead) + expect(screen.queryByRole("switch")).not.toBeInTheDocument(); + expect(screen.queryByPlaceholderText("e.g., 7d, 30d")).not.toBeInTheDocument(); + + // Check for skeleton elements (Ant Design Skeleton.Input renders with ant-skeleton class) + const skeletons = document.querySelectorAll(".ant-skeleton"); + expect(skeletons.length).toBeGreaterThan(0); + }); + + it("should continue with update even if deleteField fails", async () => { + const user = userEvent.setup(); + const deleteError = new Error("Field does not exist"); + mockDeleteField.mockRejectedValue(deleteError); + mockMutateAsync.mockImplementation(async (params, options) => { + await Promise.resolve(); + options?.onSuccess?.(); + return { message: "Success" }; + }); + + renderWithProviders(); + + const saveButton = screen.getByRole("button", { name: "Save Settings" }); + await user.click(saveButton); + + await waitFor(() => { + expect(mockDeleteField).toHaveBeenCalled(); + expect(mockMutateAsync).toHaveBeenCalledWith( + { + store_prompts_in_spend_logs: false, + }, + expect.any(Object) + ); + expect(mockNotificationsManager.success).toHaveBeenCalled(); + }); + }); + it("should submit form with only store prompts enabled and no retention period", async () => { const user = userEvent.setup(); + mockDeleteField.mockResolvedValue({ message: "Field deleted successfully" }); mockMutateAsync.mockImplementation(async (params, options) => { await Promise.resolve(); options?.onSuccess?.(); @@ -336,10 +472,10 @@ describe("SpendLogsSettingsModal", () => { await user.click(saveButton); await waitFor(() => { + expect(mockDeleteField).toHaveBeenCalled(); expect(mockMutateAsync).toHaveBeenCalledWith( { store_prompts_in_spend_logs: true, - maximum_spend_logs_retention_period: undefined, }, expect.any(Object) ); From 96cb2efedb4819a167a36d241d66f1ad94e7eac6 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Thu, 29 Jan 2026 13:05:38 -0800 Subject: [PATCH 4/4] Adding proxy_server --- litellm/proxy/proxy_server.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 53b10e03e13..1be7712380a 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -10616,6 +10616,8 @@ async def get_config_list( "max_request_size_mb": {"type": "Integer"}, "max_response_size_mb": {"type": "Integer"}, "pass_through_endpoints": {"type": "PydanticModel"}, + "store_prompts_in_spend_logs": {"type": "Boolean"}, + "maximum_spend_logs_retention_period": {"type": "String"}, } return_val = []