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/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 = [] 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" 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.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)
       );
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 3782a7199e3..007036a9c9d 100644
--- a/ui/litellm-dashboard/src/components/view_logs/SpendLogsSettingsModal/SpendLogsSettingsModal.tsx
+++ b/ui/litellm-dashboard/src/components/view_logs/SpendLogsSettingsModal/SpendLogsSettingsModal.tsx
@@ -1,12 +1,13 @@
 "use client";
 
 import { StoreRequestInSpendLogsParams, useStoreRequestInSpendLogs } from "@/app/(dashboard)/hooks/storeRequestInSpendLogs/useStoreRequestInSpendLogs";
-import NewBadge from "@/components/common_components/NewBadge";
+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, Typography } from "antd";
-import React from "react";
+import { Button, Form, Input, Modal, Skeleton, Space, Switch, Typography } from "antd";
+import React, { useEffect, useMemo } from "react";
+import NewBadge from "@/components/common_components/NewBadge";
 
 interface SpendLogsSettingsModalProps {
   isVisible: boolean;
@@ -17,14 +18,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) => {
@@ -45,53 +101,53 @@ const SpendLogsSettingsModal: React.FC = ({ isVisib
     Spend Logs Settings}
       open={isVisible}
-      width={600}
       footer={
         
-          
-          
         
       }
       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 f7639311d10..826fc7ccc02 100644 --- a/ui/litellm-dashboard/src/components/view_logs/index.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/index.tsx @@ -554,7 +554,7 @@ export default function SpendLogsTable({ setIsSpendLogsSettingsModalVisible(true)} />} getRowCanExpand={() => true} // Optionally: add session-specific row expansion state /> @@ -753,7 +753,7 @@ export default function SpendLogsTable({ setIsSpendLogsSettingsModalVisible(true)} />} getRowCanExpand={() => true} />
@@ -779,7 +779,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") { @@ -990,7 +990,7 @@ export function RequestViewer({ row }: { row: Row }) { {/* Configuration Info Message - Show when data is missing */} - + {/* Request/Response Panel */}