mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-06 08:16:43 +00:00
Merge remote-tracking branch 'origin' into litellm_new_badge_dot
This commit is contained in:
commit
ca8056f74f
9 changed files with 1070 additions and 45 deletions
|
|
@ -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 ""
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue