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:
ryan-crabbe-berri 2026-07-02 14:18:06 -07:00
commit 7b9b4bcdb4
14 changed files with 780 additions and 696 deletions

View file

@ -1,5 +1,5 @@
{
"@typescript-eslint/no-explicit-any": 2012,
"@typescript-eslint/no-explicit-any": 1990,
"complexity": 126,
"max-depth": 61
"max-depth": 59
}

View file

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

View file

@ -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);
});
});

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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;
}

View file

@ -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,
},
];

View file

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

View file

@ -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) };
};

View file

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

View file

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