mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-12 23:01:41 +00:00
Merge remote-tracking branch 'origin/litellm_internal_staging' into litellm_model_edit_form_delta
# Conflicts: # ui/litellm-dashboard/eslint-metrics.json
This commit is contained in:
commit
7b9b4bcdb4
14 changed files with 780 additions and 696 deletions
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"@typescript-eslint/no-explicit-any": 2012,
|
||||
"@typescript-eslint/no-explicit-any": 1990,
|
||||
"complexity": 126,
|
||||
"max-depth": 61
|
||||
"max-depth": 59
|
||||
}
|
||||
|
|
|
|||
|
|
@ -834,11 +834,6 @@
|
|||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/caching/components/cache_settings/CacheFieldRenderer.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/(dashboard)/caching/components/cache_settings/RedisTypeSelector.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
|
|
|
|||
|
|
@ -1,138 +0,0 @@
|
|||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import CacheFieldGroup from "./CacheFieldGroup";
|
||||
|
||||
describe("CacheFieldGroup", () => {
|
||||
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
|
||||
default: () => ({
|
||||
token: "mock-token",
|
||||
accessToken: "mock-access-token",
|
||||
userId: "mock-user-id",
|
||||
userEmail: "test@example.com",
|
||||
userRole: "Admin",
|
||||
premiumUser: false,
|
||||
disabledPersonalKeyCreation: false,
|
||||
showSSOBanner: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("should filter and render fields based on redisType", () => {
|
||||
/**
|
||||
* Tests that CacheFieldGroup filters fields based on redis_type and redisType prop.
|
||||
* This is the core functionality that shows/hides fields based on Redis deployment type.
|
||||
*/
|
||||
const fields = [
|
||||
{
|
||||
field_name: "host",
|
||||
field_type: "String",
|
||||
ui_field_name: "Host",
|
||||
redis_type: null, // Applies to all types
|
||||
},
|
||||
{
|
||||
field_name: "redis_startup_nodes",
|
||||
field_type: "List",
|
||||
ui_field_name: "Startup Nodes",
|
||||
redis_type: "cluster", // Only for cluster
|
||||
},
|
||||
{
|
||||
field_name: "sentinel_nodes",
|
||||
field_type: "List",
|
||||
ui_field_name: "Sentinel Nodes",
|
||||
redis_type: "sentinel", // Only for sentinel
|
||||
},
|
||||
];
|
||||
|
||||
const cacheSettings = {
|
||||
host: "localhost",
|
||||
redis_startup_nodes: [],
|
||||
};
|
||||
|
||||
// Test with cluster type - should show host and redis_startup_nodes
|
||||
const { rerender } = render(
|
||||
<CacheFieldGroup title="Cluster Settings" fields={fields} cacheSettings={cacheSettings} redisType="cluster" />,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Cluster Settings")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("Host")).toHaveLength(1);
|
||||
expect(screen.getByText("Startup Nodes")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Sentinel Nodes")).not.toBeInTheDocument();
|
||||
|
||||
// Test with sentinel type - should show host and sentinel_nodes
|
||||
rerender(
|
||||
<CacheFieldGroup title="Sentinel Settings" fields={fields} cacheSettings={cacheSettings} redisType="sentinel" />,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Sentinel Settings")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("Host")).toHaveLength(1);
|
||||
expect(screen.getByText("Sentinel Nodes")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Startup Nodes")).not.toBeInTheDocument();
|
||||
|
||||
// Test with node type - should only show host
|
||||
rerender(<CacheFieldGroup title="Node Settings" fields={fields} cacheSettings={cacheSettings} redisType="node" />);
|
||||
|
||||
expect(screen.getByText("Node Settings")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("Host")).toHaveLength(1);
|
||||
expect(screen.queryByText("Startup Nodes")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Sentinel Nodes")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should return null when no fields are visible", () => {
|
||||
/**
|
||||
* Tests that CacheFieldGroup returns null when no fields match the redisType.
|
||||
* This prevents rendering empty sections in the UI.
|
||||
*/
|
||||
const fields = [
|
||||
{
|
||||
field_name: "redis_startup_nodes",
|
||||
field_type: "List",
|
||||
ui_field_name: "Startup Nodes",
|
||||
redis_type: "cluster", // Only for cluster
|
||||
},
|
||||
];
|
||||
|
||||
const cacheSettings = {};
|
||||
|
||||
const { container } = render(
|
||||
<CacheFieldGroup
|
||||
title="Cluster Settings"
|
||||
fields={fields}
|
||||
cacheSettings={cacheSettings}
|
||||
redisType="node" // No fields match this type
|
||||
/>,
|
||||
);
|
||||
|
||||
// Component should return null, so container should be empty
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("should use field_default when currentValue is not available", () => {
|
||||
/**
|
||||
* Tests that CacheFieldGroup falls back to field_default when currentValue is missing.
|
||||
* This ensures fields display default values when cache settings are not set.
|
||||
*/
|
||||
const fields = [
|
||||
{
|
||||
field_name: "port",
|
||||
field_type: "Integer",
|
||||
ui_field_name: "Port",
|
||||
field_default: 6379,
|
||||
redis_type: null,
|
||||
},
|
||||
];
|
||||
|
||||
const cacheSettings = {}; // No port value set
|
||||
|
||||
render(
|
||||
<CacheFieldGroup title="Connection Settings" fields={fields} cacheSettings={cacheSettings} redisType="node" />,
|
||||
);
|
||||
|
||||
const input = screen.getByRole("spinbutton", { name: "" });
|
||||
expect(input).toBeInTheDocument();
|
||||
expect(input).toHaveAttribute("name", "port");
|
||||
expect(input).toHaveValue(6379);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
import React from "react";
|
||||
import CacheFieldRenderer from "./CacheFieldRenderer";
|
||||
|
||||
interface CacheFieldGroupProps {
|
||||
title: string;
|
||||
fields: any[];
|
||||
cacheSettings: { [key: string]: any };
|
||||
redisType: string;
|
||||
gridCols?: string;
|
||||
}
|
||||
|
||||
const CacheFieldGroup: React.FC<CacheFieldGroupProps> = ({
|
||||
title,
|
||||
fields,
|
||||
cacheSettings,
|
||||
redisType,
|
||||
gridCols = "grid-cols-1 gap-6 sm:grid-cols-2",
|
||||
}) => {
|
||||
const shouldShowField = (field: any): boolean => {
|
||||
// Show field if it applies to all types (redis_type is null/undefined) or to current selected type
|
||||
if (field.redis_type === null || field.redis_type === undefined) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return field.redis_type === redisType;
|
||||
};
|
||||
|
||||
const visibleFields = fields.filter(shouldShowField);
|
||||
|
||||
if (visibleFields.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pt-4 border-t border-gray-200">
|
||||
<h4 className="text-sm font-medium text-gray-900">{title}</h4>
|
||||
<div className={`grid ${gridCols}`}>
|
||||
{visibleFields.map((field) => {
|
||||
const currentValue = cacheSettings[field.field_name] ?? field.field_default ?? "";
|
||||
return <CacheFieldRenderer key={field.field_name} field={field} currentValue={currentValue} />;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CacheFieldGroup;
|
||||
|
|
@ -1,126 +0,0 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import CacheFieldRenderer from "./CacheFieldRenderer";
|
||||
|
||||
// Mock the useAuthorized hook to avoid Next.js router dependency
|
||||
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
|
||||
default: () => ({
|
||||
token: "mock-token",
|
||||
accessToken: "mock-access-token",
|
||||
userId: "mock-user-id",
|
||||
userEmail: "test@example.com",
|
||||
userRole: "Admin",
|
||||
premiumUser: false,
|
||||
disabledPersonalKeyCreation: false,
|
||||
showSSOBanner: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("CacheFieldRenderer", () => {
|
||||
it("should render a checkbox for Boolean field type", () => {
|
||||
/**
|
||||
* Tests that Boolean fields render as checkboxes with proper defaultChecked value.
|
||||
* This is the core functionality for boolean cache settings.
|
||||
*/
|
||||
const field = {
|
||||
field_name: "ssl",
|
||||
field_type: "Boolean",
|
||||
ui_field_name: "Enable SSL",
|
||||
field_description: "Enable SSL encryption",
|
||||
};
|
||||
|
||||
render(<CacheFieldRenderer field={field} currentValue={true} />);
|
||||
|
||||
const checkbox = screen.getByRole("checkbox", { name: "" });
|
||||
expect(checkbox).toBeInTheDocument();
|
||||
expect(checkbox).toBeChecked();
|
||||
expect(screen.getByText("Enable SSL")).toBeInTheDocument();
|
||||
expect(screen.getByText("Enable SSL encryption")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render a textarea for List field type", () => {
|
||||
/**
|
||||
* Tests that List fields render as textareas with JSON stringified values.
|
||||
* This handles array/list cache settings like redis_startup_nodes.
|
||||
*/
|
||||
const field = {
|
||||
field_name: "redis_startup_nodes",
|
||||
field_type: "List",
|
||||
ui_field_name: "Redis Startup Nodes",
|
||||
field_description: "List of Redis cluster nodes",
|
||||
};
|
||||
|
||||
const currentValue = [
|
||||
{ host: "localhost", port: 6379 },
|
||||
{ host: "localhost", port: 6380 },
|
||||
];
|
||||
|
||||
render(<CacheFieldRenderer field={field} currentValue={currentValue} />);
|
||||
|
||||
const textarea = screen.getByRole("textbox");
|
||||
expect(textarea).toBeInTheDocument();
|
||||
expect(textarea.tagName).toBe("TEXTAREA");
|
||||
expect(textarea).toHaveValue(JSON.stringify(currentValue, null, 2));
|
||||
expect(screen.getByText("Redis Startup Nodes")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render a password input for password field", () => {
|
||||
/**
|
||||
* Tests that password fields render as password inputs.
|
||||
* This ensures sensitive data is masked in the UI.
|
||||
*/
|
||||
const field = {
|
||||
field_name: "password",
|
||||
field_type: "String",
|
||||
ui_field_name: "Password",
|
||||
field_description: "Redis password",
|
||||
};
|
||||
|
||||
render(<CacheFieldRenderer field={field} currentValue="secret123" />);
|
||||
|
||||
const input = screen.getByPlaceholderText("Redis password");
|
||||
expect(input).toBeInTheDocument();
|
||||
expect(input).toHaveAttribute("type", "password");
|
||||
expect(input).toHaveValue("secret123");
|
||||
});
|
||||
|
||||
it("should render a number input for Integer field type", () => {
|
||||
/**
|
||||
* Tests that Integer fields render as number inputs.
|
||||
* This ensures proper validation for numeric cache settings.
|
||||
*/
|
||||
const field = {
|
||||
field_name: "port",
|
||||
field_type: "Integer",
|
||||
ui_field_name: "Port",
|
||||
field_description: "Redis port number",
|
||||
};
|
||||
|
||||
render(<CacheFieldRenderer field={field} currentValue={6379} />);
|
||||
|
||||
const input = screen.getByPlaceholderText("Redis port number");
|
||||
expect(input).toBeInTheDocument();
|
||||
expect(input).toHaveAttribute("type", "number");
|
||||
expect(input).toHaveValue(6379);
|
||||
});
|
||||
|
||||
it("should render a text input for String field type", () => {
|
||||
/**
|
||||
* Tests that String fields render as text inputs.
|
||||
* This is the default rendering for text-based cache settings.
|
||||
*/
|
||||
const field = {
|
||||
field_name: "host",
|
||||
field_type: "String",
|
||||
ui_field_name: "Host",
|
||||
field_description: "Redis host address",
|
||||
};
|
||||
|
||||
render(<CacheFieldRenderer field={field} currentValue="localhost" />);
|
||||
|
||||
const input = screen.getByPlaceholderText("Redis host address");
|
||||
expect(input).toBeInTheDocument();
|
||||
expect(input).toHaveAttribute("type", "text");
|
||||
expect(input).toHaveValue("localhost");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,149 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { NumberInput, TextInput } from "@tremor/react";
|
||||
import { Select } from "antd";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { fetchAvailableModels, ModelGroup } from "@/components/llm_calls/fetch_models";
|
||||
import NumericalInput from "@/components/shared/numerical_input";
|
||||
|
||||
interface CacheFieldRendererProps {
|
||||
field: any;
|
||||
currentValue: any;
|
||||
}
|
||||
|
||||
const CacheFieldRenderer: React.FC<CacheFieldRendererProps> = ({ field, currentValue }) => {
|
||||
const [modelInfo, setModelInfo] = useState<ModelGroup[]>([]);
|
||||
const [selectedModel, setSelectedModel] = useState<string>(currentValue || "");
|
||||
const { accessToken } = useAuthorized();
|
||||
|
||||
useEffect(() => {
|
||||
if (!accessToken) return;
|
||||
|
||||
const loadModels = async () => {
|
||||
try {
|
||||
const uniqueModels = await fetchAvailableModels(accessToken);
|
||||
console.log("Fetched models for selector:", uniqueModels);
|
||||
|
||||
if (uniqueModels.length > 0) {
|
||||
setModelInfo(uniqueModels);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching model info:", error);
|
||||
}
|
||||
};
|
||||
|
||||
loadModels();
|
||||
}, [accessToken]);
|
||||
|
||||
if (field.field_type === "Boolean") {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700">{field.ui_field_name}</label>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
name={field.field_name}
|
||||
defaultChecked={currentValue === true || currentValue === "true"}
|
||||
className="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"
|
||||
/>
|
||||
<span className="ml-2 text-sm text-gray-500">{field.field_description}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.field_type === "Integer" || field.field_type === "Float") {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700">{field.ui_field_name}</label>
|
||||
<NumericalInput
|
||||
name={field.field_name}
|
||||
type="number"
|
||||
defaultValue={currentValue}
|
||||
placeholder={field.field_description}
|
||||
/>
|
||||
<p className="text-xs text-gray-500">{field.field_description}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.field_type === "List") {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700">{field.ui_field_name}</label>
|
||||
<textarea
|
||||
name={field.field_name}
|
||||
defaultValue={typeof currentValue === "object" ? JSON.stringify(currentValue, null, 2) : currentValue}
|
||||
placeholder={field.field_description}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
|
||||
rows={4}
|
||||
/>
|
||||
<p className="text-xs text-gray-500">{field.field_description}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.field_type === "Models_Select") {
|
||||
const embeddingModels = modelInfo
|
||||
.filter((option: ModelGroup) => option.mode === "embedding")
|
||||
.map((option: ModelGroup) => ({
|
||||
value: option.model_group,
|
||||
label: option.model_group,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700">{field.ui_field_name}</label>
|
||||
<Select
|
||||
value={selectedModel}
|
||||
onChange={setSelectedModel}
|
||||
showSearch={true}
|
||||
placeholder="Search and select a model..."
|
||||
options={embeddingModels}
|
||||
style={{ width: "100%" }}
|
||||
className="rounded-md"
|
||||
filterOption={(input, option) => (option?.label ?? "").toLowerCase().includes(input.toLowerCase())}
|
||||
/>
|
||||
{/* Hidden input to capture the value for form submission */}
|
||||
<input type="hidden" name={field.field_name} value={selectedModel} />
|
||||
{field.field_description && <p className="text-xs text-gray-500">{field.field_description}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Render number input for numeric fields
|
||||
if (field.field_type === "Integer" || field.field_type === "Float") {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700">{field.ui_field_name}</label>
|
||||
<NumberInput
|
||||
name={field.field_name}
|
||||
defaultValue={currentValue}
|
||||
placeholder={field.field_description}
|
||||
step={field.field_type === "Float" ? 0.01 : 1}
|
||||
/>
|
||||
{field.field_description && <p className="text-xs text-gray-500">{field.field_description}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Determine input type for text-based fields
|
||||
const inputType: "text" | "password" | "email" | "url" | undefined =
|
||||
field.field_name === "password" || field.field_name.includes("password") ? "password" : "text";
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700">{field.ui_field_name}</label>
|
||||
<TextInput
|
||||
name={field.field_name}
|
||||
type={inputType}
|
||||
defaultValue={currentValue}
|
||||
placeholder={field.field_description}
|
||||
/>
|
||||
{field.field_description && <p className="text-xs text-gray-500">{field.field_description}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CacheFieldRenderer;
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import React from "react";
|
||||
import CacheFormField, { EmbeddingModelOption } from "./CacheFormField";
|
||||
import { fieldsForSection } from "./cacheSettingsUtils";
|
||||
import { CacheSection, RedisType } from "./cacheSettingsFields";
|
||||
|
||||
interface CacheFieldSectionProps {
|
||||
title: string;
|
||||
section: CacheSection;
|
||||
redisType: RedisType;
|
||||
embeddingModels: EmbeddingModelOption[];
|
||||
gridCols?: string;
|
||||
headingLevel?: "h4" | "h5";
|
||||
}
|
||||
|
||||
const CacheFieldSection: React.FC<CacheFieldSectionProps> = ({
|
||||
title,
|
||||
section,
|
||||
redisType,
|
||||
embeddingModels,
|
||||
gridCols = "grid-cols-1 gap-6 sm:grid-cols-2",
|
||||
headingLevel = "h4",
|
||||
}) => {
|
||||
const fields = fieldsForSection(section, redisType);
|
||||
if (fields.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const Heading = headingLevel;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Heading className="text-sm font-medium text-gray-900">{title}</Heading>
|
||||
<div className={`grid ${gridCols}`}>
|
||||
{fields.map((field) => (
|
||||
<CacheFormField key={field.name} field={field} embeddingModels={embeddingModels} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CacheFieldSection;
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
import { Form, Input, Select, Switch } from "antd";
|
||||
import React from "react";
|
||||
import { CacheField } from "./cacheSettingsFields";
|
||||
|
||||
export interface EmbeddingModelOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface CacheFormFieldProps {
|
||||
field: CacheField;
|
||||
embeddingModels: EmbeddingModelOption[];
|
||||
}
|
||||
|
||||
const renderControl = (field: CacheField, embeddingModels: EmbeddingModelOption[]): React.ReactNode => {
|
||||
switch (field.type) {
|
||||
case "boolean":
|
||||
return <Switch />;
|
||||
case "password":
|
||||
return <Input.Password placeholder={field.helpText} autoComplete="new-password" />;
|
||||
case "integer":
|
||||
case "float":
|
||||
return <Input inputMode="decimal" placeholder={field.helpText} />;
|
||||
case "list":
|
||||
return <Input.TextArea rows={4} placeholder={field.helpText} />;
|
||||
case "model-select":
|
||||
return (
|
||||
<Select
|
||||
showSearch
|
||||
allowClear
|
||||
placeholder="Search and select a model..."
|
||||
options={embeddingModels}
|
||||
optionFilterProp="label"
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <Input placeholder={field.helpText} />;
|
||||
}
|
||||
};
|
||||
|
||||
const CacheFormField: React.FC<CacheFormFieldProps> = ({ field, embeddingModels }) => (
|
||||
<Form.Item
|
||||
name={field.name}
|
||||
label={field.label}
|
||||
extra={field.helpText}
|
||||
rules={field.rules}
|
||||
valuePropName={field.type === "boolean" ? "checked" : "value"}
|
||||
>
|
||||
{renderControl(field, embeddingModels)}
|
||||
</Form.Item>
|
||||
);
|
||||
|
||||
export default CacheFormField;
|
||||
|
|
@ -3,7 +3,7 @@ import { Select, SelectItem } from "@tremor/react";
|
|||
|
||||
interface RedisTypeSelectorProps {
|
||||
redisType: string;
|
||||
redisTypeDescriptions: { [key: string]: string };
|
||||
redisTypeDescriptions: Readonly<Record<string, string>>;
|
||||
onTypeChange: (type: string) => void;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,261 @@
|
|||
import type { FormItemProps } from "antd";
|
||||
|
||||
export type CacheFieldType = "string" | "password" | "integer" | "float" | "boolean" | "list" | "model-select";
|
||||
|
||||
export type RedisType = "node" | "cluster" | "sentinel" | "semantic";
|
||||
|
||||
export type CacheSection = "connection" | "cluster" | "sentinel" | "semantic" | "ssl" | "cacheManagement" | "gcp";
|
||||
|
||||
export type CacheFieldRule = NonNullable<FormItemProps["rules"]>[number];
|
||||
|
||||
export interface CacheField {
|
||||
readonly name: string;
|
||||
readonly label: string;
|
||||
readonly type: CacheFieldType;
|
||||
readonly section: CacheSection;
|
||||
readonly helpText: string;
|
||||
readonly redisType: RedisType | null;
|
||||
readonly defaultValue?: string | number | boolean;
|
||||
readonly rules?: CacheFieldRule[];
|
||||
}
|
||||
|
||||
export const REDIS_TYPES: readonly RedisType[] = ["node", "cluster", "sentinel", "semantic"];
|
||||
|
||||
export const REDIS_TYPE_DESCRIPTIONS: Readonly<Record<RedisType, string>> = {
|
||||
node: "Standard Redis node/single instance",
|
||||
cluster: "Redis Cluster mode for high availability and horizontal scaling",
|
||||
sentinel: "Redis Sentinel mode for high availability with automatic failover",
|
||||
semantic: "Semantic caching that reuses responses for similar prompts",
|
||||
};
|
||||
|
||||
const portRule: CacheFieldRule = {
|
||||
validator: (_rule, value) => {
|
||||
if (value === undefined || value === null || String(value).trim() === "") {
|
||||
return Promise.resolve();
|
||||
}
|
||||
const port = Number(value);
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
return Promise.reject(new Error("Port must be an integer between 1 and 65535"));
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
};
|
||||
|
||||
const jsonListRule: CacheFieldRule = {
|
||||
validator: (_rule, value) => {
|
||||
if (value === undefined || value === null || String(value).trim() === "") {
|
||||
return Promise.resolve();
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(String(value));
|
||||
} catch {
|
||||
return Promise.reject(new Error("Must be a valid JSON array (use double quotes)"));
|
||||
}
|
||||
if (!Array.isArray(parsed)) {
|
||||
return Promise.reject(new Error("Must be a JSON array"));
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
};
|
||||
|
||||
const nonNegativeIntegerRule: CacheFieldRule = {
|
||||
validator: (_rule, value) => {
|
||||
if (value === undefined || value === null || String(value).trim() === "") {
|
||||
return Promise.resolve();
|
||||
}
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed < 0) {
|
||||
return Promise.reject(new Error("Must be a non-negative integer"));
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
};
|
||||
|
||||
const numberRule: CacheFieldRule = {
|
||||
validator: (_rule, value) => {
|
||||
if (value === undefined || value === null || String(value).trim() === "") {
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (Number.isNaN(Number(value))) {
|
||||
return Promise.reject(new Error("Must be a number"));
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
};
|
||||
|
||||
export const CACHE_FIELDS: readonly CacheField[] = [
|
||||
{
|
||||
name: "url",
|
||||
label: "Redis URL",
|
||||
type: "string",
|
||||
section: "connection",
|
||||
helpText:
|
||||
"Full Redis/Valkey connection URL (e.g. redis://:password@host:6379/1). When set, it takes precedence over Host, Port, Password, and Database Index.",
|
||||
redisType: null,
|
||||
},
|
||||
{
|
||||
name: "host",
|
||||
label: "Host",
|
||||
type: "string",
|
||||
section: "connection",
|
||||
helpText: "Redis server hostname or IP address",
|
||||
redisType: null,
|
||||
},
|
||||
{
|
||||
name: "port",
|
||||
label: "Port",
|
||||
type: "string",
|
||||
section: "connection",
|
||||
helpText: "Redis server port number",
|
||||
redisType: null,
|
||||
defaultValue: "6379",
|
||||
rules: [portRule],
|
||||
},
|
||||
{
|
||||
name: "db",
|
||||
label: "Database Index",
|
||||
type: "integer",
|
||||
section: "connection",
|
||||
helpText: "Logical database index to isolate the cache (e.g. 1 for redis://host:6379/1)",
|
||||
redisType: null,
|
||||
rules: [nonNegativeIntegerRule],
|
||||
},
|
||||
{
|
||||
name: "password",
|
||||
label: "Password",
|
||||
type: "password",
|
||||
section: "connection",
|
||||
helpText: "Redis server password",
|
||||
redisType: null,
|
||||
},
|
||||
{
|
||||
name: "username",
|
||||
label: "Username",
|
||||
type: "string",
|
||||
section: "connection",
|
||||
helpText: "Redis server username (if required)",
|
||||
redisType: null,
|
||||
},
|
||||
{
|
||||
name: "redis_startup_nodes",
|
||||
label: "Startup Nodes",
|
||||
type: "list",
|
||||
section: "cluster",
|
||||
helpText: 'List of startup nodes for Redis Cluster (e.g., [{"host": "127.0.0.1", "port": "7001"}])',
|
||||
redisType: "cluster",
|
||||
rules: [jsonListRule],
|
||||
},
|
||||
{
|
||||
name: "sentinel_nodes",
|
||||
label: "Sentinel Nodes",
|
||||
type: "list",
|
||||
section: "sentinel",
|
||||
helpText: 'List of Sentinel nodes (e.g., [["localhost", 26379]])',
|
||||
redisType: "sentinel",
|
||||
rules: [jsonListRule],
|
||||
},
|
||||
{
|
||||
name: "service_name",
|
||||
label: "Service Name",
|
||||
type: "string",
|
||||
section: "sentinel",
|
||||
helpText: "Master service name for Redis Sentinel",
|
||||
redisType: "sentinel",
|
||||
},
|
||||
{
|
||||
name: "sentinel_password",
|
||||
label: "Sentinel Password",
|
||||
type: "password",
|
||||
section: "sentinel",
|
||||
helpText: "Password for Redis Sentinel authentication",
|
||||
redisType: "sentinel",
|
||||
},
|
||||
{
|
||||
name: "similarity_threshold",
|
||||
label: "Similarity Threshold",
|
||||
type: "float",
|
||||
section: "semantic",
|
||||
helpText: "Similarity threshold for semantic cache",
|
||||
redisType: "semantic",
|
||||
defaultValue: 0.8,
|
||||
rules: [numberRule],
|
||||
},
|
||||
{
|
||||
name: "redis_semantic_cache_embedding_model",
|
||||
label: "Embedding Model",
|
||||
type: "model-select",
|
||||
section: "semantic",
|
||||
helpText: "Embedding model for semantic cache",
|
||||
redisType: "semantic",
|
||||
},
|
||||
{
|
||||
name: "ssl",
|
||||
label: "SSL",
|
||||
type: "boolean",
|
||||
section: "ssl",
|
||||
helpText: "Enable SSL/TLS connection",
|
||||
redisType: null,
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
name: "ssl_cert_reqs",
|
||||
label: "SSL Cert Reqs",
|
||||
type: "string",
|
||||
section: "ssl",
|
||||
helpText: "SSL certificate requirements (None, CERT_REQUIRED, CERT_OPTIONAL)",
|
||||
redisType: null,
|
||||
},
|
||||
{
|
||||
name: "ssl_check_hostname",
|
||||
label: "SSL Check Hostname",
|
||||
type: "boolean",
|
||||
section: "ssl",
|
||||
helpText: "Enable SSL hostname verification",
|
||||
redisType: null,
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
name: "namespace",
|
||||
label: "Namespace",
|
||||
type: "string",
|
||||
section: "cacheManagement",
|
||||
helpText: "Namespace prefix for cache keys",
|
||||
redisType: null,
|
||||
},
|
||||
{
|
||||
name: "ttl",
|
||||
label: "TTL (seconds)",
|
||||
type: "float",
|
||||
section: "cacheManagement",
|
||||
helpText: "Time-to-live for cached items in seconds",
|
||||
redisType: null,
|
||||
rules: [numberRule],
|
||||
},
|
||||
{
|
||||
name: "max_connections",
|
||||
label: "Max Connections",
|
||||
type: "integer",
|
||||
section: "cacheManagement",
|
||||
helpText: "Maximum number of connections in the connection pool",
|
||||
redisType: null,
|
||||
rules: [nonNegativeIntegerRule],
|
||||
},
|
||||
{
|
||||
name: "gcp_service_account",
|
||||
label: "GCP Service Account",
|
||||
type: "string",
|
||||
section: "gcp",
|
||||
helpText:
|
||||
"GCP service account for IAM authentication (e.g., projects/-/serviceAccounts/your-sa@project.iam.gserviceaccount.com)",
|
||||
redisType: null,
|
||||
},
|
||||
{
|
||||
name: "gcp_ssl_ca_certs",
|
||||
label: "GCP SSL CA Certs",
|
||||
type: "string",
|
||||
section: "gcp",
|
||||
helpText: "Path to SSL CA certificate file for GCP Memorystore Redis",
|
||||
redisType: null,
|
||||
},
|
||||
];
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { buildCachePayload, buildInitialValues, fieldsForSection } from "./cacheSettingsUtils";
|
||||
|
||||
describe("fieldsForSection", () => {
|
||||
it("should only include a redis-type-specific field when that type is selected", () => {
|
||||
expect(fieldsForSection("cluster", "cluster").map((f) => f.name)).toEqual(["redis_startup_nodes"]);
|
||||
expect(fieldsForSection("cluster", "node")).toEqual([]);
|
||||
});
|
||||
|
||||
it("should include connection fields for every redis type in schema order", () => {
|
||||
expect(fieldsForSection("connection", "node").map((f) => f.name)).toEqual([
|
||||
"url",
|
||||
"host",
|
||||
"port",
|
||||
"db",
|
||||
"password",
|
||||
"username",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildInitialValues", () => {
|
||||
it("should apply defaults as strings for text inputs and coerce booleans", () => {
|
||||
const values = buildInitialValues({});
|
||||
expect(values.port).toBe("6379");
|
||||
expect(values.similarity_threshold).toBe("0.8");
|
||||
expect(values.ssl).toBe(false);
|
||||
expect(values.db).toBe("");
|
||||
});
|
||||
|
||||
it("should stringify list values so they render in a textarea", () => {
|
||||
const nodes = [{ host: "127.0.0.1", port: "7001" }];
|
||||
const values = buildInitialValues({ redis_startup_nodes: nodes });
|
||||
expect(values.redis_startup_nodes).toBe(JSON.stringify(nodes, null, 2));
|
||||
});
|
||||
|
||||
it("should render numeric current values as strings for their text inputs", () => {
|
||||
const values = buildInitialValues({ max_connections: 10 });
|
||||
expect(values.max_connections).toBe("10");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildCachePayload", () => {
|
||||
it("should tag the payload as redis and drop empty fields and the UI-only redis_type", () => {
|
||||
const payload = buildCachePayload("node", { host: "localhost", port: "6379", username: "" }, { forTesting: false });
|
||||
expect(payload).toEqual({
|
||||
type: "redis",
|
||||
host: "localhost",
|
||||
port: "6379",
|
||||
ssl: false,
|
||||
ssl_check_hostname: false,
|
||||
});
|
||||
expect(payload).not.toHaveProperty("redis_type");
|
||||
expect(payload).not.toHaveProperty("username");
|
||||
});
|
||||
|
||||
it("should parse list fields from their textarea string into arrays", () => {
|
||||
const payload = buildCachePayload(
|
||||
"cluster",
|
||||
{ redis_startup_nodes: '[{"host":"127.0.0.1","port":"7001"}]' },
|
||||
{ forTesting: false },
|
||||
);
|
||||
expect(payload.redis_startup_nodes).toEqual([{ host: "127.0.0.1", port: "7001" }]);
|
||||
});
|
||||
|
||||
it("should omit a list field whose textarea holds invalid JSON", () => {
|
||||
const payload = buildCachePayload("cluster", { redis_startup_nodes: "not json" }, { forTesting: false });
|
||||
expect(payload).not.toHaveProperty("redis_startup_nodes");
|
||||
});
|
||||
|
||||
it("should send type redis-semantic when saving a semantic cache", () => {
|
||||
const payload = buildCachePayload("semantic", { similarity_threshold: 0.9 }, { forTesting: false });
|
||||
expect(payload.type).toBe("redis-semantic");
|
||||
expect(payload.similarity_threshold).toBe(0.9);
|
||||
});
|
||||
|
||||
it("should keep type redis when testing a semantic cache so the test endpoint accepts it", () => {
|
||||
const payload = buildCachePayload("semantic", { similarity_threshold: 0.9 }, { forTesting: true });
|
||||
expect(payload.type).toBe("redis");
|
||||
});
|
||||
|
||||
it("should exclude fields that do not belong to the selected redis type", () => {
|
||||
const payload = buildCachePayload("node", { sentinel_nodes: '[["localhost",26379]]' }, { forTesting: false });
|
||||
expect(payload).not.toHaveProperty("sentinel_nodes");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,110 +1,81 @@
|
|||
/**
|
||||
* Utility functions for cache settings form handling
|
||||
*/
|
||||
import { CACHE_FIELDS, CacheField, CacheSection, RedisType } from "./cacheSettingsFields";
|
||||
|
||||
export const shouldShowField = (field: any, redisType: string): boolean => {
|
||||
// Show field if it applies to all types (redis_type is null/undefined) or to current selected type
|
||||
if (field.redis_type === null || field.redis_type === undefined) {
|
||||
return true;
|
||||
export type CacheFormValue = string | number | boolean | undefined;
|
||||
export type CacheFormValues = Record<string, CacheFormValue>;
|
||||
export type CacheSavePayloadValue = string | number | boolean | unknown[];
|
||||
export type CacheSavePayload = Record<string, CacheSavePayloadValue>;
|
||||
|
||||
export const isFieldVisible = (field: CacheField, redisType: RedisType): boolean =>
|
||||
field.redisType === null || field.redisType === redisType;
|
||||
|
||||
export const fieldsForSection = (section: CacheSection, redisType: RedisType): CacheField[] =>
|
||||
CACHE_FIELDS.filter((field) => field.section === section && isFieldVisible(field, redisType));
|
||||
|
||||
const initialValueForField = (field: CacheField, raw: unknown): CacheFormValue => {
|
||||
const source = raw ?? field.defaultValue;
|
||||
|
||||
if (field.type === "boolean") {
|
||||
return source === true || source === "true";
|
||||
}
|
||||
|
||||
return field.redis_type === redisType;
|
||||
if (field.type === "list") {
|
||||
if (source === undefined || source === null || source === "") {
|
||||
return "";
|
||||
}
|
||||
return typeof source === "string" ? source : JSON.stringify(source, null, 2);
|
||||
}
|
||||
|
||||
if (source === undefined || source === null) {
|
||||
return "";
|
||||
}
|
||||
return String(source);
|
||||
};
|
||||
|
||||
export const getFieldByName = (fields: any[], fieldName: string) => {
|
||||
return fields.find((f) => f.field_name === fieldName);
|
||||
export const buildInitialValues = (currentValues: Record<string, unknown>): CacheFormValues =>
|
||||
Object.fromEntries(CACHE_FIELDS.map((field) => [field.name, initialValueForField(field, currentValues[field.name])]));
|
||||
|
||||
const saveValueForField = (field: CacheField, raw: CacheFormValue): CacheSavePayloadValue | undefined => {
|
||||
if (field.type === "boolean") {
|
||||
return Boolean(raw);
|
||||
}
|
||||
|
||||
if (field.type === "list") {
|
||||
if (typeof raw !== "string" || raw.trim() === "") {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(raw) as unknown[];
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (field.type === "integer" || field.type === "float") {
|
||||
if (raw === undefined || raw === null || raw === "") {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = Number(raw);
|
||||
return Number.isNaN(parsed) ? undefined : parsed;
|
||||
}
|
||||
|
||||
if (typeof raw !== "string") {
|
||||
return raw === undefined ? undefined : String(raw);
|
||||
}
|
||||
const trimmed = raw.trim();
|
||||
return trimmed === "" ? undefined : trimmed;
|
||||
};
|
||||
|
||||
export const groupFieldsByCategory = (fields: any[], redisType: string) => {
|
||||
// Basic fields that are always shown
|
||||
const basicFieldNames = ["host", "port", "password", "username"];
|
||||
const basicFields = basicFieldNames.map((name) => getFieldByName(fields, name)).filter(Boolean);
|
||||
export const buildCachePayload = (
|
||||
redisType: RedisType,
|
||||
values: CacheFormValues,
|
||||
{ forTesting }: { forTesting: boolean },
|
||||
): CacheSavePayload => {
|
||||
const type = !forTesting && redisType === "semantic" ? "redis-semantic" : "redis";
|
||||
|
||||
// Advanced field groups
|
||||
const sslFields = ["ssl", "ssl_cert_reqs", "ssl_check_hostname"]
|
||||
.map((name) => getFieldByName(fields, name))
|
||||
.filter(Boolean);
|
||||
|
||||
const cacheManagementFields = ["namespace", "ttl", "max_connections"]
|
||||
.map((name) => getFieldByName(fields, name))
|
||||
.filter(Boolean);
|
||||
|
||||
const gcpFields = ["gcp_service_account", "gcp_ssl_ca_certs"]
|
||||
.map((name) => getFieldByName(fields, name))
|
||||
.filter(Boolean);
|
||||
|
||||
// Redis type-specific fields
|
||||
const clusterFields = fields.filter((f) => f.redis_type === "cluster");
|
||||
const sentinelFields = fields.filter((f) => f.redis_type === "sentinel");
|
||||
const semanticFields = fields.filter((f) => f.redis_type === "semantic");
|
||||
|
||||
return {
|
||||
basicFields,
|
||||
sslFields,
|
||||
cacheManagementFields,
|
||||
gcpFields,
|
||||
clusterFields,
|
||||
sentinelFields,
|
||||
semanticFields,
|
||||
};
|
||||
};
|
||||
|
||||
export const gatherFormValues = (fields: any[], redisType: string): { [key: string]: any } => {
|
||||
const values: { [key: string]: any } = {
|
||||
type: "redis", // Cache class accepts 'type' parameter (LiteLLMCacheType enum)
|
||||
};
|
||||
|
||||
// Iterate through all fields from backend
|
||||
fields.forEach((field) => {
|
||||
// Skip redis_type - it's UI-only, not sent to backend
|
||||
if (field.field_name === "redis_type") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if field should be shown for current redis type
|
||||
if (!shouldShowField(field, redisType)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fieldName = field.field_name;
|
||||
let value: any = null;
|
||||
|
||||
if (field.field_type === "Boolean") {
|
||||
const checkboxEl = document.querySelector(`input[name="${fieldName}"]`) as HTMLInputElement | null;
|
||||
if (checkboxEl?.checked !== undefined) {
|
||||
value = checkboxEl.checked;
|
||||
}
|
||||
} else if (field.field_type === "List") {
|
||||
const textareaEl = document.querySelector(`textarea[name="${fieldName}"]`) as HTMLTextAreaElement | null;
|
||||
if (textareaEl?.value) {
|
||||
try {
|
||||
value = JSON.parse(textareaEl.value);
|
||||
} catch (e) {
|
||||
console.error(`Invalid JSON for ${fieldName}:`, e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const inputEl = document.querySelector(`input[name="${fieldName}"]`) as HTMLInputElement | null;
|
||||
if (inputEl?.value) {
|
||||
const trimmedValue = inputEl.value.trim();
|
||||
if (trimmedValue !== "") {
|
||||
if (field.field_type === "Integer") {
|
||||
const num = Number(trimmedValue);
|
||||
if (!isNaN(num)) value = num;
|
||||
} else if (field.field_type === "Float") {
|
||||
const num = Number(trimmedValue);
|
||||
if (!isNaN(num)) value = num;
|
||||
} else {
|
||||
value = trimmedValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (value !== null && value !== undefined) {
|
||||
values[fieldName] = value;
|
||||
}
|
||||
const entries = CACHE_FIELDS.filter((field) => isFieldVisible(field, redisType)).flatMap((field) => {
|
||||
const value = saveValueForField(field, values[field.name]);
|
||||
return value === undefined ? [] : [[field.name, value] as const];
|
||||
});
|
||||
|
||||
return values;
|
||||
return { type, ...Object.fromEntries(entries) };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,147 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import CacheSettings from "./index";
|
||||
|
||||
const { getCacheSettingsCall, testCacheConnectionCall, updateCacheSettingsCall } = vi.hoisted(() => ({
|
||||
getCacheSettingsCall: vi.fn(),
|
||||
testCacheConnectionCall: vi.fn(),
|
||||
updateCacheSettingsCall: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
getCacheSettingsCall,
|
||||
testCacheConnectionCall,
|
||||
updateCacheSettingsCall,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/llm_calls/fetch_models", () => ({
|
||||
fetchAvailableModels: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
const renderSettings = () => render(<CacheSettings accessToken="sk-test" userRole="Admin" userID="u1" />);
|
||||
|
||||
describe("CacheSettings", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getCacheSettingsCall.mockResolvedValue({ current_values: {} });
|
||||
updateCacheSettingsCall.mockResolvedValue({ status: "success" });
|
||||
testCacheConnectionCall.mockResolvedValue({ status: "success" });
|
||||
});
|
||||
|
||||
it("should render the connection fields once current values load", async () => {
|
||||
renderSettings();
|
||||
expect(await screen.findByText("Connection Settings")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("when the redis type is node", () => {
|
||||
it("should show the connection fields and hide cluster/sentinel/semantic fields", async () => {
|
||||
renderSettings();
|
||||
|
||||
expect(await screen.findByText("Redis URL")).toBeInTheDocument();
|
||||
expect(screen.getByText("Database Index")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Startup Nodes")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Sentinel Nodes")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Embedding Model")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("when the redis type is cluster", () => {
|
||||
it("should reveal the cluster startup nodes field", async () => {
|
||||
getCacheSettingsCall.mockResolvedValue({ current_values: { redis_type: "cluster" } });
|
||||
renderSettings();
|
||||
expect(await screen.findByText("Startup Nodes")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("when the redis type is sentinel", () => {
|
||||
it("should reveal the sentinel fields", async () => {
|
||||
getCacheSettingsCall.mockResolvedValue({ current_values: { redis_type: "sentinel" } });
|
||||
renderSettings();
|
||||
expect(await screen.findByText("Sentinel Nodes")).toBeInTheDocument();
|
||||
expect(screen.getByText("Service Name")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("when the redis type is semantic", () => {
|
||||
it("should reveal the semantic fields", async () => {
|
||||
getCacheSettingsCall.mockResolvedValue({ current_values: { redis_type: "semantic" } });
|
||||
renderSettings();
|
||||
expect(await screen.findByText("Similarity Threshold")).toBeInTheDocument();
|
||||
expect(screen.getByText("Embedding Model")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("when a field fails inline validation", () => {
|
||||
it("should block save and surface the validation message", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSettings();
|
||||
|
||||
const port = await screen.findByLabelText("Port");
|
||||
await user.clear(port);
|
||||
await user.type(port, "99999");
|
||||
await user.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
|
||||
expect(await screen.findByText(/Port must be an integer between 1 and 65535/i)).toBeInTheDocument();
|
||||
expect(updateCacheSettingsCall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should block save when a list field holds malformed JSON instead of silently dropping it", async () => {
|
||||
const user = userEvent.setup();
|
||||
getCacheSettingsCall.mockResolvedValue({ current_values: { redis_type: "cluster" } });
|
||||
renderSettings();
|
||||
|
||||
const startupNodes = await screen.findByLabelText("Startup Nodes");
|
||||
await user.type(startupNodes, "not json");
|
||||
await user.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
|
||||
expect(await screen.findByText(/Must be a valid JSON array/i)).toBeInTheDocument();
|
||||
expect(updateCacheSettingsCall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should block save with an error when a non-numeric value is entered into a numeric field", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSettings();
|
||||
|
||||
const db = await screen.findByLabelText("Database Index");
|
||||
await user.type(db, "redis://host:6379/1");
|
||||
await user.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
|
||||
expect(await screen.findByText(/Must be a non-negative integer/i)).toBeInTheDocument();
|
||||
expect(updateCacheSettingsCall).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("when saving a valid node configuration", () => {
|
||||
it("should send the backend payload shape with type redis and no UI-only fields", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSettings();
|
||||
|
||||
const host = await screen.findByLabelText("Host");
|
||||
await user.type(host, "localhost");
|
||||
await user.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(updateCacheSettingsCall).toHaveBeenCalledWith("sk-test", {
|
||||
type: "redis",
|
||||
host: "localhost",
|
||||
port: "6379",
|
||||
ssl: false,
|
||||
ssl_check_hostname: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should include a numeric field like Database Index in the save payload", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSettings();
|
||||
|
||||
await user.type(await screen.findByLabelText("Redis URL"), "redis://host:6379/1");
|
||||
await user.type(await screen.findByLabelText("Database Index"), "2");
|
||||
await user.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
|
||||
await waitFor(() => expect(updateCacheSettingsCall).toHaveBeenCalled());
|
||||
expect(updateCacheSettingsCall.mock.calls[0][1]).toMatchObject({ db: 2, url: "redis://host:6379/1" });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,10 +1,14 @@
|
|||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Button, Accordion, AccordionHeader, AccordionBody } from "@tremor/react";
|
||||
import { Form } from "antd";
|
||||
import { getCacheSettingsCall, testCacheConnectionCall, updateCacheSettingsCall } from "@/components/networking";
|
||||
import { fetchAvailableModels, ModelGroup } from "@/components/llm_calls/fetch_models";
|
||||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
import RedisTypeSelector from "./RedisTypeSelector";
|
||||
import CacheFieldRenderer from "./CacheFieldRenderer";
|
||||
import { gatherFormValues, groupFieldsByCategory } from "./cacheSettingsUtils";
|
||||
import CacheFieldSection from "./CacheFieldSection";
|
||||
import { EmbeddingModelOption } from "./CacheFormField";
|
||||
import { REDIS_TYPES, REDIS_TYPE_DESCRIPTIONS, RedisType } from "./cacheSettingsFields";
|
||||
import { buildCachePayload, buildInitialValues, CacheFormValues } from "./cacheSettingsUtils";
|
||||
|
||||
interface CacheSettingsProps {
|
||||
accessToken: string | null;
|
||||
|
|
@ -12,66 +16,83 @@ interface CacheSettingsProps {
|
|||
userID: string | null;
|
||||
}
|
||||
|
||||
const CacheSettings: React.FC<CacheSettingsProps> = ({ accessToken, userRole, userID }) => {
|
||||
const [cacheSettings, setCacheSettings] = useState<{ [key: string]: any }>({});
|
||||
const [fields, setFields] = useState<any[]>([]);
|
||||
const [redisTypeDescriptions, setRedisTypeDescriptions] = useState<{ [key: string]: string }>({});
|
||||
const [redisType, setRedisType] = useState<string>("node");
|
||||
const toRedisType = (value: unknown): RedisType =>
|
||||
REDIS_TYPES.includes(value as RedisType) ? (value as RedisType) : "node";
|
||||
|
||||
const CacheSettings: React.FC<CacheSettingsProps> = ({ accessToken }) => {
|
||||
const [form] = Form.useForm<CacheFormValues>();
|
||||
const [redisType, setRedisType] = useState<RedisType>("node");
|
||||
const [embeddingModels, setEmbeddingModels] = useState<EmbeddingModelOption[]>([]);
|
||||
const [isTesting, setIsTesting] = useState<boolean>(false);
|
||||
const [isSaving, setIsSaving] = useState<boolean>(false);
|
||||
|
||||
const loadCacheSettings = useCallback(async () => {
|
||||
if (!accessToken) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = await getCacheSettingsCall(accessToken!);
|
||||
console.log("cache settings from API", data);
|
||||
|
||||
if (data.fields) {
|
||||
setFields(data.fields);
|
||||
}
|
||||
|
||||
// Set current values
|
||||
if (data.current_values) {
|
||||
setCacheSettings(data.current_values);
|
||||
if (data.current_values.redis_type) {
|
||||
setRedisType(data.current_values.redis_type);
|
||||
}
|
||||
}
|
||||
|
||||
// Store Redis type descriptions
|
||||
if (data.redis_type_descriptions) {
|
||||
setRedisTypeDescriptions(data.redis_type_descriptions);
|
||||
}
|
||||
const data = (await getCacheSettingsCall(accessToken)) as { current_values?: Record<string, unknown> };
|
||||
const currentValues = data.current_values ?? {};
|
||||
form.setFieldsValue(buildInitialValues(currentValues));
|
||||
setRedisType(toRedisType(currentValues.redis_type));
|
||||
} catch (error) {
|
||||
console.error("Failed to load cache settings:", error);
|
||||
NotificationsManager.fromBackend("Failed to load cache settings");
|
||||
}
|
||||
}, [accessToken]);
|
||||
}, [accessToken, form]);
|
||||
|
||||
useEffect(() => {
|
||||
loadCacheSettings();
|
||||
}, [loadCacheSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!accessToken) {
|
||||
return;
|
||||
}
|
||||
loadCacheSettings();
|
||||
}, [accessToken, loadCacheSettings]);
|
||||
fetchAvailableModels(accessToken)
|
||||
.then((models: ModelGroup[]) =>
|
||||
setEmbeddingModels(
|
||||
models
|
||||
.filter((model) => model.mode === "embedding")
|
||||
.map((model) => ({ value: model.model_group, label: model.model_group })),
|
||||
),
|
||||
)
|
||||
.catch((error) => console.error("Error fetching embedding models:", error));
|
||||
}, [accessToken]);
|
||||
|
||||
const validate = async (): Promise<CacheFormValues | null> => {
|
||||
try {
|
||||
return await form.validateFields();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
if (!accessToken) {
|
||||
return;
|
||||
}
|
||||
const values = await validate();
|
||||
if (values === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsTesting(true);
|
||||
try {
|
||||
const testSettings = gatherFormValues(fields, redisType);
|
||||
const result = await testCacheConnectionCall(accessToken, testSettings);
|
||||
|
||||
const result = await testCacheConnectionCall(
|
||||
accessToken,
|
||||
buildCachePayload(redisType, values, { forTesting: true }),
|
||||
);
|
||||
if (result.status === "success") {
|
||||
NotificationsManager.success("Cache connection test successful!");
|
||||
} else {
|
||||
NotificationsManager.fromBackend(`Connection test failed: ${result.message || result.error}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
console.error("Test connection error:", error);
|
||||
NotificationsManager.fromBackend(`Connection test failed: ${error.message || "Unknown error"}`);
|
||||
NotificationsManager.fromBackend(
|
||||
`Connection test failed: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
} finally {
|
||||
setIsTesting(false);
|
||||
}
|
||||
|
|
@ -81,16 +102,15 @@ const CacheSettings: React.FC<CacheSettingsProps> = ({ accessToken, userRole, us
|
|||
if (!accessToken) {
|
||||
return;
|
||||
}
|
||||
const values = await validate();
|
||||
if (values === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const settingsToSave = gatherFormValues(fields, redisType);
|
||||
if (redisType === "semantic") {
|
||||
settingsToSave.type = "redis-semantic";
|
||||
}
|
||||
await updateCacheSettingsCall(accessToken, settingsToSave);
|
||||
await updateCacheSettingsCall(accessToken, buildCachePayload(redisType, values, { forTesting: false }));
|
||||
NotificationsManager.success("Cache settings updated successfully");
|
||||
// Reload settings to reflect saved values
|
||||
await loadCacheSettings();
|
||||
} catch (error) {
|
||||
console.error("Failed to save cache settings:", error);
|
||||
|
|
@ -104,127 +124,95 @@ const CacheSettings: React.FC<CacheSettingsProps> = ({ accessToken, userRole, us
|
|||
return null;
|
||||
}
|
||||
|
||||
const { basicFields, sslFields, cacheManagementFields, gcpFields, clusterFields, sentinelFields, semanticFields } =
|
||||
groupFieldsByCategory(fields, redisType);
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-8 py-2">
|
||||
<div className="space-y-6">
|
||||
<Form form={form} layout="vertical" requiredMark={false} className="space-y-6">
|
||||
<div className="max-w-3xl">
|
||||
<h3 className="text-sm font-medium text-gray-900">Cache Settings</h3>
|
||||
<p className="text-xs text-gray-500 mt-1">Configure Redis cache for LiteLLM</p>
|
||||
</div>
|
||||
|
||||
{/* Redis Type Selector */}
|
||||
<RedisTypeSelector
|
||||
redisType={redisType}
|
||||
redisTypeDescriptions={redisTypeDescriptions}
|
||||
onTypeChange={setRedisType}
|
||||
redisTypeDescriptions={REDIS_TYPE_DESCRIPTIONS}
|
||||
onTypeChange={(type) => setRedisType(toRedisType(type))}
|
||||
/>
|
||||
|
||||
{/* Basic Fields */}
|
||||
<div className="space-y-6 pt-4 border-t border-gray-200">
|
||||
<h4 className="text-sm font-medium text-gray-900">Connection Settings</h4>
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||
{basicFields.map((field: any) => {
|
||||
if (!field) return null;
|
||||
const currentValue = cacheSettings[field.field_name] ?? field.field_default ?? "";
|
||||
return <CacheFieldRenderer key={field.field_name} field={field} currentValue={currentValue} />;
|
||||
})}
|
||||
</div>
|
||||
<div className="pt-4 border-t border-gray-200">
|
||||
<CacheFieldSection
|
||||
title="Connection Settings"
|
||||
section="connection"
|
||||
redisType={redisType}
|
||||
embeddingModels={embeddingModels}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Redis Type-Specific Fields */}
|
||||
{redisType === "cluster" && clusterFields.length > 0 && (
|
||||
<div className="space-y-6 pt-4 border-t border-gray-200">
|
||||
<h4 className="text-sm font-medium text-gray-900">Cluster Configuration</h4>
|
||||
<div className="grid grid-cols-1 gap-6">
|
||||
{clusterFields.map((field: any) => {
|
||||
const currentValue = cacheSettings[field.field_name] ?? field.field_default ?? "";
|
||||
return <CacheFieldRenderer key={field.field_name} field={field} currentValue={currentValue} />;
|
||||
})}
|
||||
</div>
|
||||
{redisType === "cluster" && (
|
||||
<div className="pt-4 border-t border-gray-200">
|
||||
<CacheFieldSection
|
||||
title="Cluster Configuration"
|
||||
section="cluster"
|
||||
redisType={redisType}
|
||||
embeddingModels={embeddingModels}
|
||||
gridCols="grid-cols-1 gap-6"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{redisType === "sentinel" && sentinelFields.length > 0 && (
|
||||
<div className="space-y-6 pt-4 border-t border-gray-200">
|
||||
<h4 className="text-sm font-medium text-gray-900">Sentinel Configuration</h4>
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||
{sentinelFields.map((field: any) => {
|
||||
const currentValue = cacheSettings[field.field_name] ?? field.field_default ?? "";
|
||||
return <CacheFieldRenderer key={field.field_name} field={field} currentValue={currentValue} />;
|
||||
})}
|
||||
</div>
|
||||
{redisType === "sentinel" && (
|
||||
<div className="pt-4 border-t border-gray-200">
|
||||
<CacheFieldSection
|
||||
title="Sentinel Configuration"
|
||||
section="sentinel"
|
||||
redisType={redisType}
|
||||
embeddingModels={embeddingModels}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{redisType === "semantic" && semanticFields.length > 0 && (
|
||||
<div className="space-y-6 pt-4 border-t border-gray-200">
|
||||
<h4 className="text-sm font-medium text-gray-900">Semantic Configuration</h4>
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||
{semanticFields.map((field: any) => {
|
||||
const currentValue = cacheSettings[field.field_name] ?? field.field_default ?? "";
|
||||
return <CacheFieldRenderer key={field.field_name} field={field} currentValue={currentValue} />;
|
||||
})}
|
||||
</div>
|
||||
{redisType === "semantic" && (
|
||||
<div className="pt-4 border-t border-gray-200">
|
||||
<CacheFieldSection
|
||||
title="Semantic Configuration"
|
||||
section="semantic"
|
||||
redisType={redisType}
|
||||
embeddingModels={embeddingModels}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Advanced Settings Accordion */}
|
||||
<Accordion className="mt-4">
|
||||
<AccordionHeader>
|
||||
<span className="text-sm font-medium text-gray-900">Advanced Settings</span>
|
||||
</AccordionHeader>
|
||||
<AccordionBody>
|
||||
<div className="space-y-6">
|
||||
{/* SSL Settings */}
|
||||
{sslFields.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<h5 className="text-sm font-medium text-gray-700">SSL Settings</h5>
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||
{sslFields.map((field: any) => {
|
||||
if (!field) return null;
|
||||
const currentValue = cacheSettings[field.field_name] ?? field.field_default ?? "";
|
||||
return <CacheFieldRenderer key={field.field_name} field={field} currentValue={currentValue} />;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cache Management */}
|
||||
{cacheManagementFields.length > 0 && (
|
||||
<div className="space-y-4 pt-4 border-t border-gray-200">
|
||||
<h5 className="text-sm font-medium text-gray-700">Cache Management</h5>
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||
{cacheManagementFields.map((field: any) => {
|
||||
if (!field) return null;
|
||||
const currentValue = cacheSettings[field.field_name] ?? field.field_default ?? "";
|
||||
return <CacheFieldRenderer key={field.field_name} field={field} currentValue={currentValue} />;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* GCP Authentication */}
|
||||
{gcpFields.length > 0 && (
|
||||
<div className="space-y-4 pt-4 border-t border-gray-200">
|
||||
<h5 className="text-sm font-medium text-gray-700">GCP Authentication</h5>
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||
{gcpFields.map((field: any) => {
|
||||
if (!field) return null;
|
||||
const currentValue = cacheSettings[field.field_name] ?? field.field_default ?? "";
|
||||
return <CacheFieldRenderer key={field.field_name} field={field} currentValue={currentValue} />;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<CacheFieldSection
|
||||
title="SSL Settings"
|
||||
section="ssl"
|
||||
redisType={redisType}
|
||||
embeddingModels={embeddingModels}
|
||||
headingLevel="h5"
|
||||
/>
|
||||
<CacheFieldSection
|
||||
title="Cache Management"
|
||||
section="cacheManagement"
|
||||
redisType={redisType}
|
||||
embeddingModels={embeddingModels}
|
||||
headingLevel="h5"
|
||||
/>
|
||||
<CacheFieldSection
|
||||
title="GCP Authentication"
|
||||
section="gcp"
|
||||
redisType={redisType}
|
||||
embeddingModels={embeddingModels}
|
||||
headingLevel="h5"
|
||||
/>
|
||||
</div>
|
||||
</AccordionBody>
|
||||
</Accordion>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="border-t border-gray-200 pt-6 flex justify-end gap-3">
|
||||
<Button variant="secondary" size="sm" onClick={handleTestConnection} disabled={isTesting} className="text-sm">
|
||||
{isTesting ? "Testing..." : "Test Connection"}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue