Merge remote-tracking branch 'origin' into litellm_new_badge_dot

This commit is contained in:
yuneng-jiang 2026-01-29 13:37:37 -08:00
commit ca8056f74f
9 changed files with 1070 additions and 45 deletions

View file

@ -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 ""
)

View file

@ -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 = []

View file

@ -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"

View file

@ -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<typeof vi.fn>;
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<typeof vi.fn>;
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<typeof vi.fn>;
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
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<typeof vi.fn>;
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
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();
});
});

View file

@ -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<ProxyConfigResponse> => {
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<DeleteProxyConfigFieldResponse> => {
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<ProxyConfigResponse>({
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<DeleteProxyConfigFieldResponse, Error, DeleteProxyConfigFieldRequest>({
mutationFn: async (request: DeleteProxyConfigFieldRequest) => {
if (!accessToken) {
throw new Error("Access token is required");
}
return await deleteProxyConfigFieldCall(accessToken, request);
},
});
};

View file

@ -2,9 +2,10 @@ import React from "react";
interface ConfigInfoMessageProps {
show: boolean;
onOpenSettings?: () => void;
}
export const ConfigInfoMessage: React.FC<ConfigInfoMessageProps> = ({ show }) => {
export const ConfigInfoMessage: React.FC<ConfigInfoMessageProps> = ({ show, onOpenSettings }) => {
if (!show) return null;
return (
@ -30,7 +31,18 @@ export const ConfigInfoMessage: React.FC<ConfigInfoMessageProps> = ({ show }) =>
<h4 className="text-sm font-medium text-blue-800">Request/Response Data Not Available</h4>
<p className="text-sm text-blue-700 mt-1">
To view request and response details, enable prompt storage in your LiteLLM configuration by adding the
following to your <code className="bg-blue-100 px-1 py-0.5 rounded">proxy_config.yaml</code> file:
following to your <code className="bg-blue-100 px-1 py-0.5 rounded">proxy_config.yaml</code> file
{onOpenSettings && (
<> or{" "}
<button
onClick={onOpenSettings}
className="text-blue-600 hover:text-blue-800 underline font-medium"
>
open the settings
</button>
{" "}to configure this directly.
</>
)}
</p>
<pre className="mt-2 bg-white p-3 rounded border border-blue-200 text-xs font-mono overflow-auto">
{`general_settings:

View file

@ -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(<SpendLogsSettingsModal {...defaultProps} />);
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(<SpendLogsSettingsModal {...defaultProps} />);
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(<SpendLogsSettingsModal {...defaultProps} />);
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(<SpendLogsSettingsModal {...defaultProps} />);
@ -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(<SpendLogsSettingsModal {...defaultProps} />);
renderWithProviders(<SpendLogsSettingsModal {...defaultProps} />);
const switchElement = screen.getByRole("switch");
await user.click(switchElement);
@ -283,20 +341,13 @@ describe("SpendLogsSettingsModal", () => {
await waitFor(() => {
expect(mockNotificationsManager.success).toHaveBeenCalled();
});
rerender(<SpendLogsSettingsModal {...defaultProps} />);
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(<SpendLogsSettingsModal {...defaultProps} />);
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(<SpendLogsSettingsModal {...defaultProps} />);
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(<SpendLogsSettingsModal {...defaultProps} />);
// 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(<SpendLogsSettingsModal {...defaultProps} />);
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)
);

View file

@ -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<SpendLogsSettingsModalProps> = ({ 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<SpendLogsSettingsModalProps> = ({ isVisib
<Modal
title={<span className="flex gap-2"><Typography.Title level={5}>Spend Logs Settings</Typography.Title><NewBadge /></span>}
open={isVisible}
width={600}
footer={
<Space>
<Button onClick={handleCancel} disabled={isPending}>
<Button onClick={handleCancel} disabled={isPending || isDeletingField || isLoadingConfig}>
Cancel
</Button>
<Button type="primary" loading={isPending} onClick={() => form.submit()}>
{isPending ? "Saving..." : "Save Settings"}
<Button type="primary" loading={isPending || isDeletingField} disabled={isLoadingConfig} onClick={() => form.submit()}>
{isPending || isDeletingField ? "Saving..." : "Save Settings"}
</Button>
</Space>
}
onCancel={handleCancel}
>
<Form
key={proxyConfigData ? JSON.stringify(initialValues) : 'loading'}
form={form}
layout="horizontal"
labelCol={{ flex: "auto", style: { textAlign: "left" } }}
wrapperCol={{ flex: "auto", style: { textAlign: "right" } }}
onFinish={handleFormSubmit}
initialValues={{
store_prompts_in_spend_logs: false,
maximum_spend_logs_retention_period: undefined,
}}
initialValues={initialValues}
>
<Form.Item
label="Store Prompts in Spend Logs"
name="store_prompts_in_spend_logs"
tooltip="When enabled, prompts will be stored in spend logs for tracking and analysis purposes."
tooltip={
proxyConfigData?.find(f => 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"
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>Store Prompts in Spend Logs</span>
<Switch checked={storePromptsValue ?? false} onChange={(checked) => form.setFieldValue('store_prompts_in_spend_logs', checked)} />
<div>
{isLoadingConfig ? <Skeleton.Input active block /> : <Switch checked={storePromptsValue ?? false} onChange={(checked) => form.setFieldValue('store_prompts_in_spend_logs', checked)} />}
</div>
</Form.Item>
<Form.Item
label="Maximum Spend Logs Retention Period (Optional)"
name="maximum_spend_logs_retention_period"
tooltip="Set the maximum retention period for spend logs (e.g., '7d' for 7 days, '30d' for 30 days). Leave empty for no limit."
labelCol={{ flex: "auto", style: { textAlign: "left" } }}
wrapperCol={{ flex: "0 0 25%", style: { textAlign: "right" } }}
tooltip={
proxyConfigData?.find(f => 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."
}
>
<Input
{isLoadingConfig ? <Skeleton.Input active block /> : <Input
placeholder="e.g., 7d, 30d"
prefix={<ClockCircleOutlined />}
/>
/>}
</Form.Item>
</Form>
</Modal>

View file

@ -554,7 +554,7 @@ export default function SpendLogsTable({
<DataTable
columns={columns}
data={sessionData}
renderSubComponent={RequestViewer}
renderSubComponent={({ row }) => <RequestViewer row={row} onOpenSettings={() => setIsSpendLogsSettingsModalVisible(true)} />}
getRowCanExpand={() => true}
// Optionally: add session-specific row expansion state
/>
@ -753,7 +753,7 @@ export default function SpendLogsTable({
<DataTable
columns={columns}
data={filteredData}
renderSubComponent={RequestViewer}
renderSubComponent={({ row }) => <RequestViewer row={row} onOpenSettings={() => setIsSpendLogsSettingsModalVisible(true)} />}
getRowCanExpand={() => true}
/>
</div>
@ -779,7 +779,7 @@ export default function SpendLogsTable({
);
}
export function RequestViewer({ row }: { row: Row<LogEntry> }) {
export function RequestViewer({ row, onOpenSettings }: { row: Row<LogEntry>; 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<LogEntry> }) {
<CostBreakdownViewer costBreakdown={row.original.metadata?.cost_breakdown} totalSpend={row.original.spend || 0} />
{/* Configuration Info Message - Show when data is missing */}
<ConfigInfoMessage show={missingData} />
<ConfigInfoMessage show={missingData} onOpenSettings={onOpenSettings} />
{/* Request/Response Panel */}
<div className="w-full max-w-full overflow-hidden">