mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-08 03:08:45 +00:00
refactor(ui): move the model info edit form off antd Form (#37392)
* test(ui): characterize the model info and cache control submit payloads
Pins the antd behaviour these forms have today, ahead of moving them onto
react-hook-form: the full model info PATCH body, the sticky touched-field
semantics that decide which pricing keys ship, the mounted-only cache control
keys, and the string-typed injection point index.
* refactor(ui): move the model info edit form off antd Form
The deployment edit form on the model info view now runs on react-hook-form
with a zod resolver and shadcn controls, extracted into ModelInfoEditForm so
the view keeps the payload builder and the form keeps the fields.
Cache control injection points become a presentational value/onChange child,
which lets the model info view host it through react-hook-form while the add
model form keeps hosting it through antd. That child never wrote to a real
store on either side: it registered under cache_control_points while both
parents read cache_control_injection_points, so its form prop was inert.
antd marks a field touched on change and never clears it, and neither
touchedFields nor dirtyFields reproduces that, so the four pricing keys that
gate on it track first change explicitly.
The PTU rules move from antd validator wrappers to pure predicates that both
surfaces share, since the add model form still feeds the wrappers to its own
antd form.
* refactor(ui): trim comments and type the model record prop on the edit form
Cuts the explanatory comments that the house rules do not allow, keeping
only the three that record non-obvious library behaviour plus the eslint
directive, and narrows the modelData prop to the two fields the form reads.
Corrects the claim in 5b7ecede4e that the cache control child registered
the wrong key. At the staging tip the Form.List registration is on
cache_control_injection_points and is live, which is why this PR rehosts
it into advanced_settings. The dead part is the three
getFieldValue("cache_control_points") readers, whose key nothing
registers, so updateCacheControlPoints dereferences undefined and the
caught error reaches the console on every role, index or remove change.
This commit is contained in:
parent
bb8324c119
commit
b19d59be09
9 changed files with 1594 additions and 1012 deletions
|
|
@ -1816,12 +1816,6 @@
|
|||
"src/components/add_model/cache_control_settings.tsx": {
|
||||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"prefer-const": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/add_model/conditional_public_model_name.test.tsx": {
|
||||
|
|
@ -2276,15 +2270,6 @@
|
|||
"local/filename-pascal-case": {
|
||||
"count": 1
|
||||
},
|
||||
"local/no-complex-jsx-arrow": {
|
||||
"count": 1
|
||||
},
|
||||
"max-lines": {
|
||||
"count": 1
|
||||
},
|
||||
"no-nested-ternary": {
|
||||
"count": 14
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
|
|
|
|||
818
ui/litellm-dashboard/src/components/ModelInfoEditForm.tsx
Normal file
818
ui/litellm-dashboard/src/components/ModelInfoEditForm.tsx
Normal file
|
|
@ -0,0 +1,818 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
// eslint-disable-next-line no-restricted-imports -- the dashboard has no shadcn date-time picker; the PTU window fields need one
|
||||
import { DatePicker } from "antd";
|
||||
import { CircleHelp } from "lucide-react";
|
||||
import type { Dayjs } from "dayjs";
|
||||
import * as React from "react";
|
||||
import { useForm, type Resolver } from "react-hook-form";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { TagsInput } from "@/app/(dashboard)/guardrails/_components/content_filter/TagsInput";
|
||||
import { FormField } from "@/components/shared/form/FormField";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
|
||||
|
||||
import CacheControlInjectionPoints, {
|
||||
CACHE_CONTROL_LABEL,
|
||||
CACHE_CONTROL_TOOLTIP,
|
||||
type CacheControlInjectionPoint,
|
||||
} from "./add_model/cache_control_settings";
|
||||
import type { CredentialItem } from "./networking";
|
||||
import NumericalInput from "./shared/numerical_input";
|
||||
import type { Tag } from "./tag_management/types";
|
||||
import VectorStoreSelector from "./vector_store_management/VectorStoreSelector";
|
||||
import { formatPtuUtcDisplay, utcIsoToPickerValue } from "../utils/ptuDatetime";
|
||||
import { isMaskedSecret } from "../utils/maskedSecretUtils";
|
||||
import {
|
||||
MAX_COST_PER_PTU_PER_HOUR,
|
||||
MAX_PTU_COUNT,
|
||||
PTU_COUNT_FIELD,
|
||||
PTU_END_FIELD,
|
||||
PTU_RATE_FIELD,
|
||||
PTU_START_FIELD,
|
||||
isFilledPtuValue,
|
||||
isNonNegativePtuRate,
|
||||
isPositiveWholePtuCount,
|
||||
ptuWindowIsOrdered,
|
||||
} from "../utils/ptuValidation";
|
||||
|
||||
interface PtuEditField {
|
||||
name: string;
|
||||
label: string;
|
||||
input: "number" | "datetime";
|
||||
placeholder?: string;
|
||||
isCount?: boolean;
|
||||
}
|
||||
|
||||
const PTU_EDIT_FIELDS: PtuEditField[] = [
|
||||
{ name: PTU_COUNT_FIELD, label: "PTU Count", input: "number", placeholder: "e.g. 15", isCount: true },
|
||||
{ name: PTU_RATE_FIELD, label: "Cost per PTU / Hour (USD)", input: "number", placeholder: "e.g. 2.00" },
|
||||
{ name: PTU_START_FIELD, label: "PTU Effective From (UTC)", input: "datetime" },
|
||||
{ name: PTU_END_FIELD, label: "PTU Effective To (UTC)", input: "datetime" },
|
||||
];
|
||||
|
||||
export type TouchedPricingField = "input_cost" | "output_cost" | "cache_read_cost" | "cache_write_cost";
|
||||
|
||||
const PRICING_FIELDS: readonly TouchedPricingField[] = [
|
||||
"input_cost",
|
||||
"output_cost",
|
||||
"cache_read_cost",
|
||||
"cache_write_cost",
|
||||
] as const;
|
||||
|
||||
const COST_SOURCES: Record<TouchedPricingField, { param: string; info: string }> = {
|
||||
input_cost: { param: "input_cost_per_token", info: "input_cost_per_token" },
|
||||
output_cost: { param: "output_cost_per_token", info: "output_cost_per_token" },
|
||||
cache_read_cost: { param: "cache_read_input_token_cost", info: "cache_read_input_token_cost" },
|
||||
cache_write_cost: { param: "cache_creation_input_token_cost", info: "cache_creation_input_token_cost" },
|
||||
};
|
||||
|
||||
export interface ModelEditFormValues {
|
||||
model_name?: string;
|
||||
litellm_model_name?: string;
|
||||
api_base?: string;
|
||||
custom_llm_provider?: string;
|
||||
organization?: string;
|
||||
tpm?: string | number | null;
|
||||
rpm?: string | number | null;
|
||||
max_retries?: string | number | null;
|
||||
timeout?: string | number | null;
|
||||
stream_timeout?: string | number | null;
|
||||
input_cost?: string | number | null;
|
||||
output_cost?: string | number | null;
|
||||
cache_read_cost?: string | number | null;
|
||||
cache_write_cost?: string | number | null;
|
||||
ptu_count?: string | number | null;
|
||||
cost_per_ptu_per_hour?: string | number | null;
|
||||
ptu_effective_from?: Dayjs | null;
|
||||
ptu_effective_to?: Dayjs | null;
|
||||
cache_control?: boolean;
|
||||
cache_control_injection_points?: CacheControlInjectionPoint[];
|
||||
model_access_group?: string[];
|
||||
guardrails?: string[];
|
||||
vector_store_ids?: string[];
|
||||
tags?: string[];
|
||||
health_check_model?: string | null;
|
||||
litellm_credential_name?: string;
|
||||
litellm_extra_params?: string;
|
||||
model_info?: string;
|
||||
}
|
||||
|
||||
type ModelEditFieldName = keyof ModelEditFormValues;
|
||||
|
||||
const scalar = z.union([z.string(), z.number(), z.null()]).optional();
|
||||
const textish = z.string().optional();
|
||||
|
||||
const modelEditShape = {
|
||||
model_name: textish,
|
||||
litellm_model_name: textish,
|
||||
api_base: textish,
|
||||
custom_llm_provider: textish,
|
||||
organization: textish,
|
||||
tpm: scalar,
|
||||
rpm: scalar,
|
||||
max_retries: scalar,
|
||||
timeout: scalar,
|
||||
stream_timeout: scalar,
|
||||
input_cost: scalar,
|
||||
output_cost: scalar,
|
||||
cache_read_cost: scalar,
|
||||
cache_write_cost: scalar,
|
||||
ptu_count: scalar,
|
||||
cost_per_ptu_per_hour: scalar,
|
||||
ptu_effective_from: z.custom<Dayjs | null>().nullish(),
|
||||
ptu_effective_to: z.custom<Dayjs | null>().nullish(),
|
||||
cache_control: z.boolean().optional(),
|
||||
cache_control_injection_points: z.array(z.custom<CacheControlInjectionPoint>()).optional(),
|
||||
model_access_group: z.array(z.string()).optional(),
|
||||
guardrails: z.array(z.string()).optional(),
|
||||
vector_store_ids: z.array(z.string()).optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
health_check_model: z.string().nullish(),
|
||||
litellm_credential_name: textish,
|
||||
litellm_extra_params: textish,
|
||||
model_info: textish,
|
||||
};
|
||||
|
||||
const isJson = (value: string): boolean => {
|
||||
try {
|
||||
JSON.parse(value);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const buildSchema = (ptuEnabled: boolean, isFieldTouched: (field: TouchedPricingField) => boolean) =>
|
||||
z.object(modelEditShape).superRefine((values, ctx) => {
|
||||
const reject = (path: ModelEditFieldName, message: string) =>
|
||||
ctx.addIssue({ code: "custom", path: [path], message });
|
||||
|
||||
if (values.litellm_extra_params && !isJson(values.litellm_extra_params)) {
|
||||
reject("litellm_extra_params", "Please enter valid JSON");
|
||||
}
|
||||
|
||||
// antd validates only mounted fields, and the PTU block does not render when the flag is off.
|
||||
if (!ptuEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isPositiveWholePtuCount(values.ptu_count)) {
|
||||
reject("ptu_count", `PTU Count must be a whole number between 1 and ${MAX_PTU_COUNT.toLocaleString()}`);
|
||||
}
|
||||
if (!isNonNegativePtuRate(values.cost_per_ptu_per_hour)) {
|
||||
reject(
|
||||
"cost_per_ptu_per_hour",
|
||||
`Cost per PTU / Hour must be between 0 and ${MAX_COST_PER_PTU_PER_HOUR.toLocaleString()}`,
|
||||
);
|
||||
}
|
||||
if (isFilledPtuValue(values.ptu_count) !== isFilledPtuValue(values.cost_per_ptu_per_hour)) {
|
||||
const message = "PTU Count and Cost per PTU / Hour must be set together";
|
||||
reject("ptu_count", message);
|
||||
reject("cost_per_ptu_per_hour", message);
|
||||
}
|
||||
if (isFilledPtuValue(values.ptu_count) && !isFilledPtuValue(values.ptu_effective_from)) {
|
||||
reject("ptu_effective_from", "PTU Effective From is required when PTU Count is set");
|
||||
}
|
||||
if (!ptuWindowIsOrdered(values.ptu_effective_from, values.ptu_effective_to)) {
|
||||
const message = "PTU Effective To must be after PTU Effective From";
|
||||
reject("ptu_effective_from", message);
|
||||
reject("ptu_effective_to", message);
|
||||
}
|
||||
|
||||
for (const field of PRICING_FIELDS) {
|
||||
const value = values[field];
|
||||
if (
|
||||
isFieldTouched(field) &&
|
||||
isFilledPtuValue(values.ptu_count) &&
|
||||
isFilledPtuValue(value) &&
|
||||
Number(value) !== 0
|
||||
) {
|
||||
reject(field, "A PTU deployment bills by reserved capacity, so this cost must be 0 or blank");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const perMillionTokens = (...rates: (number | null | undefined)[]): number | null => {
|
||||
const rate = rates.find((candidate) => candidate != null);
|
||||
return rate == null ? null : rate * 1_000_000;
|
||||
};
|
||||
|
||||
export const toModelEditFormValues = (localModelData: any, isWildcardModel: boolean): ModelEditFormValues => ({
|
||||
model_name: localModelData.model_name,
|
||||
litellm_model_name: localModelData.litellm_model_name,
|
||||
api_base: localModelData.litellm_params.api_base,
|
||||
custom_llm_provider: localModelData.litellm_params.custom_llm_provider,
|
||||
organization: localModelData.litellm_params.organization,
|
||||
tpm: localModelData.litellm_params.tpm,
|
||||
rpm: localModelData.litellm_params.rpm,
|
||||
max_retries: localModelData.litellm_params.max_retries,
|
||||
timeout: localModelData.litellm_params.timeout,
|
||||
stream_timeout: localModelData.litellm_params.stream_timeout,
|
||||
input_cost: perMillionTokens(
|
||||
localModelData.litellm_params.input_cost_per_token,
|
||||
localModelData.model_info?.input_cost_per_token,
|
||||
),
|
||||
output_cost: perMillionTokens(
|
||||
localModelData.litellm_params?.output_cost_per_token,
|
||||
localModelData.model_info?.output_cost_per_token,
|
||||
),
|
||||
ptu_count: localModelData.model_info?.ptu_count ?? null,
|
||||
cost_per_ptu_per_hour: localModelData.model_info?.cost_per_ptu_per_hour ?? null,
|
||||
ptu_effective_from: utcIsoToPickerValue(localModelData.model_info?.ptu_effective_from),
|
||||
ptu_effective_to: utcIsoToPickerValue(localModelData.model_info?.ptu_effective_to),
|
||||
cache_read_cost: perMillionTokens(
|
||||
localModelData.litellm_params?.cache_read_input_token_cost,
|
||||
localModelData.model_info?.cache_read_input_token_cost,
|
||||
),
|
||||
cache_write_cost: perMillionTokens(
|
||||
localModelData.litellm_params?.cache_creation_input_token_cost,
|
||||
localModelData.model_info?.cache_creation_input_token_cost,
|
||||
),
|
||||
cache_control: localModelData.litellm_params?.cache_control_injection_points ? true : false,
|
||||
cache_control_injection_points: localModelData.litellm_params?.cache_control_injection_points || [],
|
||||
model_access_group: Array.isArray(localModelData.model_info?.access_groups)
|
||||
? localModelData.model_info.access_groups
|
||||
: [],
|
||||
guardrails: Array.isArray(localModelData.litellm_params?.guardrails) ? localModelData.litellm_params.guardrails : [],
|
||||
vector_store_ids:
|
||||
Array.isArray(localModelData.litellm_params?.vector_store_ids) &&
|
||||
localModelData.litellm_params.vector_store_ids.length > 0
|
||||
? localModelData.litellm_params.vector_store_ids
|
||||
: undefined,
|
||||
tags: Array.isArray(localModelData.litellm_params?.tags) ? localModelData.litellm_params.tags : [],
|
||||
// antd never mounted this field for a non-wildcard model, so the key must be absent, not null.
|
||||
...(isWildcardModel ? { health_check_model: localModelData.model_info?.health_check_model } : {}),
|
||||
litellm_credential_name: localModelData.litellm_params?.litellm_credential_name || "",
|
||||
litellm_extra_params: JSON.stringify(
|
||||
Object.fromEntries(
|
||||
Object.entries(localModelData.litellm_params || {}).filter(
|
||||
([key, value]) => key !== "litellm_credential_name" && !isMaskedSecret(value),
|
||||
),
|
||||
),
|
||||
null,
|
||||
2,
|
||||
),
|
||||
});
|
||||
|
||||
const displayCost = (localModelData: any, field: TouchedPricingField): string => {
|
||||
const { param, info } = COST_SOURCES[field];
|
||||
const rate = localModelData?.litellm_params?.[param] ?? localModelData?.model_info?.[info];
|
||||
return rate != null ? (Number(rate) * 1_000_000).toFixed(4) : "Not Set";
|
||||
};
|
||||
|
||||
interface ModelInfoEditFormProps {
|
||||
localModelData: any;
|
||||
modelData: { model_info: { team_id?: string | null } & Record<string, unknown> };
|
||||
accessToken: string | null;
|
||||
isEditing: boolean;
|
||||
isSaving: boolean;
|
||||
isWildcardModel: boolean;
|
||||
ptuCostAttributionEnabled: boolean;
|
||||
showCacheControl: boolean;
|
||||
setShowCacheControl: (checked: boolean) => void;
|
||||
onCancel: () => void;
|
||||
onSubmit: (values: ModelEditFormValues, isFieldTouched: (field: TouchedPricingField) => boolean) => Promise<void>;
|
||||
modelAccessGroups: string[] | null;
|
||||
guardrailsList: string[];
|
||||
tagsList: Record<string, Tag>;
|
||||
credentialsList: CredentialItem[];
|
||||
healthCheckModelOptions: { value: string; label: string }[];
|
||||
}
|
||||
|
||||
const Display: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||
<div className="mt-1 rounded-sm bg-muted p-2">{children}</div>
|
||||
);
|
||||
|
||||
const FieldLabel: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||
<p className="text-sm font-medium text-foreground">{children}</p>
|
||||
);
|
||||
|
||||
const Hint: React.FC<{ text: string }> = ({ text }) => (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={<CircleHelp className="ml-1 inline size-3.5 shrink-0 cursor-help text-muted-foreground" />}
|
||||
/>
|
||||
<TooltipContent className="max-w-xs">{text}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const DocsHint: React.FC<{ text: string; href: string }> = ({ text, href }) => (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer" onClick={(event) => event.stopPropagation()}>
|
||||
<Hint text={text} />
|
||||
</a>
|
||||
);
|
||||
|
||||
const ChipList: React.FC<{ values: unknown; emptyLabel: string }> = ({ values, emptyLabel }) => {
|
||||
if (!values) {
|
||||
return <>Not Set</>;
|
||||
}
|
||||
if (!Array.isArray(values)) {
|
||||
return <>{String(values)}</>;
|
||||
}
|
||||
if (values.length === 0) {
|
||||
return <>{emptyLabel}</>;
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{values.map((entry: string, index: number) => (
|
||||
<Badge key={index} variant="secondary">
|
||||
{entry}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ModelInfoEditForm: React.FC<ModelInfoEditFormProps> = ({
|
||||
localModelData,
|
||||
modelData,
|
||||
accessToken,
|
||||
isEditing,
|
||||
isSaving,
|
||||
isWildcardModel,
|
||||
ptuCostAttributionEnabled,
|
||||
showCacheControl,
|
||||
setShowCacheControl,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
modelAccessGroups,
|
||||
guardrailsList,
|
||||
tagsList,
|
||||
credentialsList,
|
||||
healthCheckModelOptions,
|
||||
}) => {
|
||||
// Neither RHF's blur-based touchedFields nor its resettable dirtyFields matches antd's touched-on-change.
|
||||
const touchedRef = React.useRef<ReadonlySet<string>>(new Set<string>());
|
||||
const isFieldTouched = React.useCallback((field: TouchedPricingField) => touchedRef.current.has(field), []);
|
||||
const markTouched = (field: string) => {
|
||||
touchedRef.current = new Set([...touchedRef.current, field]);
|
||||
};
|
||||
|
||||
// react-hook-form refreshes control._options every render, so this rebuild is what the next submit runs.
|
||||
const resolver: Resolver<ModelEditFormValues> = (values, context, options) =>
|
||||
zodResolver(buildSchema(ptuCostAttributionEnabled, isFieldTouched))(values, context, options);
|
||||
|
||||
const form = useForm<ModelEditFormValues>({
|
||||
resolver,
|
||||
defaultValues: toModelEditFormValues(localModelData, isWildcardModel),
|
||||
});
|
||||
|
||||
const submit = (event: React.FormEvent<HTMLFormElement>) =>
|
||||
form.handleSubmit(async (values) => {
|
||||
await onSubmit(values, isFieldTouched);
|
||||
})(event);
|
||||
|
||||
const cancel = () => {
|
||||
form.reset(toModelEditFormValues(localModelData, isWildcardModel));
|
||||
touchedRef.current = new Set<string>();
|
||||
onCancel();
|
||||
};
|
||||
|
||||
const textField = (name: ModelEditFieldName, label: string, placeholder: string, stored: unknown) => (
|
||||
<div>
|
||||
<FieldLabel>{label}</FieldLabel>
|
||||
{isEditing ? (
|
||||
<FormField control={form.control} name={name}>
|
||||
{({ value, ...control }) => <Input {...control} value={(value as string) ?? ""} placeholder={placeholder} />}
|
||||
</FormField>
|
||||
) : (
|
||||
<Display>{(stored as string) || "Not Set"}</Display>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const numberField = (name: ModelEditFieldName, label: string, placeholder: string, stored: unknown) => (
|
||||
<div>
|
||||
<FieldLabel>{label}</FieldLabel>
|
||||
{isEditing ? (
|
||||
<FormField control={form.control} name={name}>
|
||||
{({ value, ...control }) => <NumericalInput {...control} value={value ?? ""} placeholder={placeholder} />}
|
||||
</FormField>
|
||||
) : (
|
||||
<Display>{(stored as string) || "Not Set"}</Display>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const pricingField = (name: TouchedPricingField, label: string, placeholder: string, description?: string) => (
|
||||
<div>
|
||||
<FieldLabel>{label}</FieldLabel>
|
||||
{isEditing ? (
|
||||
<FormField control={form.control} name={name} description={description}>
|
||||
{({ value, onChange, ...control }) => (
|
||||
<NumericalInput
|
||||
{...control}
|
||||
value={value ?? ""}
|
||||
placeholder={placeholder}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
markTouched(name);
|
||||
onChange(event);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
) : (
|
||||
<Display>{displayCost(localModelData, name)}</Display>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const tagsField = (
|
||||
name: "model_access_group" | "guardrails" | "tags",
|
||||
options: { value: string; label: string }[],
|
||||
placeholder: string,
|
||||
) => (
|
||||
<FormField control={form.control} name={name}>
|
||||
{({ id, value, onChange }) => (
|
||||
<TagsInput
|
||||
id={id}
|
||||
value={(value as string[]) ?? []}
|
||||
onValueChange={onChange}
|
||||
options={options}
|
||||
placeholder={placeholder}
|
||||
tokenSeparators={[","]}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
);
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<form onSubmit={submit}>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4">
|
||||
{textField("model_name", "Model Name", "Enter model name", localModelData.model_name)}
|
||||
{textField(
|
||||
"litellm_model_name",
|
||||
"LiteLLM Model Name",
|
||||
"Enter LiteLLM model name",
|
||||
localModelData.litellm_model_name,
|
||||
)}
|
||||
|
||||
{pricingField("input_cost", "Input Cost (per 1M tokens)", "Enter input cost")}
|
||||
{pricingField("output_cost", "Output Cost (per 1M tokens)", "Enter output cost")}
|
||||
|
||||
{ptuCostAttributionEnabled &&
|
||||
PTU_EDIT_FIELDS.map((ptuField) => (
|
||||
<div key={ptuField.name}>
|
||||
<FieldLabel>{ptuField.label}</FieldLabel>
|
||||
{isEditing ? (
|
||||
<FormField control={form.control} name={ptuField.name as ModelEditFieldName}>
|
||||
{({ value, onChange, ...control }) =>
|
||||
ptuField.input === "number" ? (
|
||||
<NumericalInput
|
||||
{...control}
|
||||
onChange={onChange}
|
||||
value={value ?? ""}
|
||||
placeholder={ptuField.placeholder}
|
||||
step={ptuField.isCount ? 1 : undefined}
|
||||
min={ptuField.isCount ? 1 : 0}
|
||||
/>
|
||||
) : (
|
||||
<DatePicker
|
||||
showTime
|
||||
style={{ width: "100%" }}
|
||||
value={(value as Dayjs | null) ?? null}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</FormField>
|
||||
) : (
|
||||
<Display>
|
||||
{(ptuField.input === "datetime"
|
||||
? formatPtuUtcDisplay(localModelData?.model_info?.[ptuField.name])
|
||||
: localModelData?.model_info?.[ptuField.name]) ?? "Not Set"}
|
||||
</Display>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{pricingField(
|
||||
"cache_read_cost",
|
||||
"Cache Read Cost (per 1M tokens)",
|
||||
"Defaults to Input Cost if blank",
|
||||
"If left blank on save, defaults to Input Cost.",
|
||||
)}
|
||||
{pricingField(
|
||||
"cache_write_cost",
|
||||
"Cache Write Cost (per 1M tokens)",
|
||||
"Defaults to Input Cost if blank",
|
||||
"If left blank on save, defaults to Input Cost (backend falls back to input_cost_per_token).",
|
||||
)}
|
||||
|
||||
{textField("api_base", "API Base", "Enter API base", localModelData.litellm_params?.api_base)}
|
||||
{textField(
|
||||
"custom_llm_provider",
|
||||
"Custom LLM Provider",
|
||||
"Enter custom LLM provider",
|
||||
localModelData.litellm_params?.custom_llm_provider,
|
||||
)}
|
||||
{textField(
|
||||
"organization",
|
||||
"Organization",
|
||||
"Enter organization",
|
||||
localModelData.litellm_params?.organization,
|
||||
)}
|
||||
|
||||
{numberField("tpm", "TPM (Tokens per Minute)", "Enter TPM", localModelData.litellm_params?.tpm)}
|
||||
{numberField("rpm", "RPM (Requests per Minute)", "Enter RPM", localModelData.litellm_params?.rpm)}
|
||||
{numberField("max_retries", "Max Retries", "Enter max retries", localModelData.litellm_params?.max_retries)}
|
||||
{numberField("timeout", "Timeout (seconds)", "Enter timeout", localModelData.litellm_params?.timeout)}
|
||||
{numberField(
|
||||
"stream_timeout",
|
||||
"Stream Timeout (seconds)",
|
||||
"Enter stream timeout",
|
||||
localModelData.litellm_params?.stream_timeout,
|
||||
)}
|
||||
|
||||
<div>
|
||||
<FieldLabel>Model Access Groups</FieldLabel>
|
||||
{isEditing ? (
|
||||
tagsField(
|
||||
"model_access_group",
|
||||
(modelAccessGroups ?? []).map((group) => ({ value: group, label: group })),
|
||||
"Select existing groups or type to create new ones",
|
||||
)
|
||||
) : (
|
||||
<Display>
|
||||
<ChipList values={localModelData.model_info?.access_groups} emptyLabel="No groups assigned" />
|
||||
</Display>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>
|
||||
Guardrails
|
||||
<DocsHint
|
||||
text="Apply safety guardrails to this model to filter content or enforce policies"
|
||||
href="https://docs.litellm.ai/docs/proxy/guardrails/quick_start"
|
||||
/>
|
||||
</FieldLabel>
|
||||
{isEditing ? (
|
||||
tagsField(
|
||||
"guardrails",
|
||||
guardrailsList.map((name) => ({ value: name, label: name })),
|
||||
"Select existing guardrails or type to create new ones",
|
||||
)
|
||||
) : (
|
||||
<Display>
|
||||
<ChipList values={localModelData.litellm_params?.guardrails} emptyLabel="No guardrails assigned" />
|
||||
</Display>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>
|
||||
Attached Knowledge Bases (RAG)
|
||||
<DocsHint
|
||||
text="Vector stores used for RAG. Every request to this model will automatically retrieve context from these knowledge bases."
|
||||
href="https://docs.litellm.ai/docs/completion/knowledgebase"
|
||||
/>
|
||||
</FieldLabel>
|
||||
{isEditing ? (
|
||||
<FormField control={form.control} name="vector_store_ids">
|
||||
{({ value, onChange }) => (
|
||||
<VectorStoreSelector
|
||||
value={value as string[] | undefined}
|
||||
onChange={onChange}
|
||||
accessToken={accessToken || ""}
|
||||
placeholder="Select knowledge bases (optional)"
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
) : (
|
||||
<Display>
|
||||
<ChipList
|
||||
values={localModelData.litellm_params?.vector_store_ids}
|
||||
emptyLabel="No knowledge bases attached"
|
||||
/>
|
||||
</Display>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Tags</FieldLabel>
|
||||
{isEditing ? (
|
||||
tagsField(
|
||||
"tags",
|
||||
Object.values(tagsList).map((tag: Tag) => ({ value: tag.name, label: tag.name })),
|
||||
"Select existing tags or type to create new ones",
|
||||
)
|
||||
) : (
|
||||
<Display>
|
||||
<ChipList values={localModelData.litellm_params?.tags} emptyLabel="No tags assigned" />
|
||||
</Display>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Existing Credentials</FieldLabel>
|
||||
{isEditing ? (
|
||||
<FormField control={form.control} name="litellm_credential_name">
|
||||
{({ id, value, onChange, onBlur }) => {
|
||||
const items = [
|
||||
{ value: "", label: "None" },
|
||||
...credentialsList.map((credential) => ({
|
||||
value: credential.credential_name,
|
||||
label: credential.credential_name,
|
||||
})),
|
||||
];
|
||||
return (
|
||||
<Select
|
||||
items={items}
|
||||
value={(value as string) ?? ""}
|
||||
onValueChange={(selected: string | null) => onChange(selected ?? "")}
|
||||
>
|
||||
<SelectTrigger id={id} className="w-full" onBlur={onBlur}>
|
||||
<SelectValue placeholder="Select or search for existing credentials" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{items.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}}
|
||||
</FormField>
|
||||
) : (
|
||||
<Display>{localModelData.litellm_params?.litellm_credential_name || "Manual"}</Display>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isWildcardModel && (
|
||||
<div>
|
||||
<FieldLabel>Health Check Model</FieldLabel>
|
||||
{isEditing ? (
|
||||
<FormField control={form.control} name="health_check_model">
|
||||
{({ id, value, onChange, onBlur }) => (
|
||||
<Select
|
||||
items={healthCheckModelOptions}
|
||||
value={(value as string | null) ?? null}
|
||||
onValueChange={onChange}
|
||||
>
|
||||
<SelectTrigger id={id} className="w-full" onBlur={onBlur}>
|
||||
<SelectValue placeholder="Select existing health check model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={null}>None</SelectItem>
|
||||
{healthCheckModelOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</FormField>
|
||||
) : (
|
||||
<Display>{localModelData.model_info?.health_check_model || "Not Set"}</Display>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEditing ? (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="cache_control"
|
||||
label={
|
||||
<>
|
||||
{CACHE_CONTROL_LABEL}
|
||||
<Hint text={CACHE_CONTROL_TOOLTIP} />
|
||||
</>
|
||||
}
|
||||
orientation="horizontal"
|
||||
>
|
||||
{({ id, value, onChange, onBlur }) => (
|
||||
<Switch
|
||||
id={id}
|
||||
onBlur={onBlur}
|
||||
checked={Boolean(value)}
|
||||
onCheckedChange={(checked: boolean) => {
|
||||
onChange(checked);
|
||||
setShowCacheControl(checked);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
{showCacheControl && (
|
||||
<FormField control={form.control} name="cache_control_injection_points">
|
||||
{({ value, onChange }) => (
|
||||
<CacheControlInjectionPoints
|
||||
value={(value as CacheControlInjectionPoint[]) ?? []}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div>
|
||||
<FieldLabel>Cache Control</FieldLabel>
|
||||
<Display>
|
||||
{localModelData.litellm_params?.cache_control_injection_points ? (
|
||||
<div>
|
||||
<p>Enabled</p>
|
||||
<div className="mt-2">
|
||||
{localModelData.litellm_params.cache_control_injection_points.map((point: any, i: number) => (
|
||||
<div key={i} className="mb-1 text-sm text-muted-foreground">
|
||||
Location: {point.location},{point.role && <span> Role: {point.role}</span>}
|
||||
{point.index !== undefined && <span> Index: {point.index}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
"Disabled"
|
||||
)}
|
||||
</Display>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<FieldLabel>Model Info</FieldLabel>
|
||||
{isEditing ? (
|
||||
<FormField control={form.control} name="model_info">
|
||||
{({ value, ...control }) => (
|
||||
<Textarea
|
||||
{...control}
|
||||
rows={4}
|
||||
placeholder={'{"gpt-4": 100, "claude-v1": 200}'}
|
||||
defaultValue={JSON.stringify(modelData.model_info, null, 2)}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
) : (
|
||||
<Display>
|
||||
<pre className="mt-1 overflow-auto rounded-sm bg-muted p-2 text-xs">
|
||||
{JSON.stringify(localModelData.model_info, null, 2)}
|
||||
</pre>
|
||||
</Display>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>
|
||||
LiteLLM Params
|
||||
<DocsHint
|
||||
text="Optional litellm params used for making a litellm.completion() call. Some params are automatically added by LiteLLM."
|
||||
href="https://docs.litellm.ai/docs/completion/input"
|
||||
/>
|
||||
</FieldLabel>
|
||||
{isEditing ? (
|
||||
<FormField control={form.control} name="litellm_extra_params">
|
||||
{({ value, ...control }) => (
|
||||
<Textarea
|
||||
{...control}
|
||||
value={(value as string) ?? ""}
|
||||
rows={4}
|
||||
placeholder={'{\n "rpm": 100,\n "timeout": 0,\n "stream_timeout": 0\n}'}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
) : (
|
||||
<Display>
|
||||
<pre className="mt-1 overflow-auto rounded-sm bg-muted p-2 text-xs">
|
||||
{JSON.stringify(localModelData.litellm_params, null, 2)}
|
||||
</pre>
|
||||
</Display>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Team ID</FieldLabel>
|
||||
<Display>{modelData.model_info.team_id || "Not Set"}</Display>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isEditing && (
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<Button type="submit" variant="secondary" onClick={cancel} disabled={isSaving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSaving} aria-busy={isSaving}>
|
||||
{isSaving && <UiLoadingSpinner className="size-4" />}
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelInfoEditForm;
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { renderHook, screen, waitFor, renderWithProviders } from "../../../tests/test-utils";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import userEvent, { PointerEventsCheckLevel } from "@testing-library/user-event";
|
||||
import { Form } from "antd";
|
||||
import type { UploadProps } from "antd/es/upload";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
|
@ -310,4 +310,90 @@ describe("AddModelForm", () => {
|
|||
expect((await screen.findAllByRole("button", { name: "Test Connect" })).length).toBeGreaterThan(0);
|
||||
expect(await screen.findByRole("button", { name: "Add Model" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("cache control bindings reach the parent form store", () => {
|
||||
const renderWithForm = async () => {
|
||||
const mockUseAuthorized = vi.mocked(await import("@/app/(dashboard)/hooks/useAuthorized"));
|
||||
mockUseAuthorized.default.mockReturnValue(mockAuthorizedUser("proxy_admin", "user-1", true));
|
||||
const props = createTestProps();
|
||||
const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
|
||||
renderWithProviders(<AddModelForm {...props} />);
|
||||
await screen.findByText("Provider");
|
||||
|
||||
return {
|
||||
user,
|
||||
openCacheControl: async () => {
|
||||
await user.click(await screen.findByText("Advanced Settings"));
|
||||
await user.click(screen.getByLabelText("Cache Control Injection Points"));
|
||||
await screen.findByText("Add Injection Point");
|
||||
},
|
||||
closeCacheControl: async () => {
|
||||
await user.click(screen.getByLabelText("Cache Control Injection Points"));
|
||||
await waitFor(() => expect(screen.queryByText("Add Injection Point")).not.toBeInTheDocument());
|
||||
},
|
||||
// AddModelPanel builds the wire payload from form.validateFields(), which reports exactly
|
||||
// the mounted registered set. Reading the same instance the same way keeps this on the
|
||||
// real payload path; a rejection still carries the same `values` object.
|
||||
mountedValues: async (): Promise<Record<string, unknown>> => {
|
||||
try {
|
||||
return await props.form.validateFields();
|
||||
} catch (error) {
|
||||
return (error as { values: Record<string, unknown> }).values;
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
it("omits both cache control keys while the section is untouched", async () => {
|
||||
const { mountedValues } = await renderWithForm();
|
||||
const values = await mountedValues();
|
||||
expect(values).not.toHaveProperty("cache_control_injection_points");
|
||||
expect(values.cache_control).toBeUndefined();
|
||||
});
|
||||
|
||||
it("sends the seeded injection point once the toggle is on", async () => {
|
||||
const { openCacheControl, mountedValues } = await renderWithForm();
|
||||
await openCacheControl();
|
||||
const values = await mountedValues();
|
||||
expect(values.cache_control).toBe(true);
|
||||
expect(values.cache_control_injection_points).toEqual([{ location: "message" }]);
|
||||
});
|
||||
|
||||
it("carries an edited role and keeps the index a string, as the antd control did", async () => {
|
||||
const { user, openCacheControl, mountedValues } = await renderWithForm();
|
||||
await openCacheControl();
|
||||
|
||||
await user.click(screen.getByText("Select a role"));
|
||||
await user.click(await screen.findByText("System"));
|
||||
await user.type(screen.getByPlaceholderText("Optional"), "3");
|
||||
|
||||
const values = await mountedValues();
|
||||
expect(values.cache_control_injection_points).toEqual([{ location: "message", role: "system", index: "3" }]);
|
||||
});
|
||||
|
||||
it("adds a second injection point row", async () => {
|
||||
const { user, openCacheControl, mountedValues } = await renderWithForm();
|
||||
await openCacheControl();
|
||||
|
||||
await user.click(screen.getByText("Add Injection Point"));
|
||||
await waitFor(() => expect(screen.getAllByPlaceholderText("Optional")).toHaveLength(2));
|
||||
await user.type(screen.getAllByPlaceholderText("Optional")[1], "7");
|
||||
|
||||
const values = await mountedValues();
|
||||
expect(values.cache_control_injection_points).toEqual([
|
||||
{ location: "message" },
|
||||
{ location: "message", index: "7" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops the injection points again when the toggle goes back off", async () => {
|
||||
const { openCacheControl, closeCacheControl, mountedValues } = await renderWithForm();
|
||||
await openCacheControl();
|
||||
await closeCacheControl();
|
||||
|
||||
const values = await mountedValues();
|
||||
expect(values.cache_control).toBe(false);
|
||||
expect(values).not.toHaveProperty("cache_control_injection_points");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,7 +7,11 @@ import { Row, Col, Typography } from "antd";
|
|||
import TextArea from "antd/es/input/TextArea";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { Team } from "../key_team_helpers/key_list";
|
||||
import CacheControlSettings from "./cache_control_settings";
|
||||
import CacheControlInjectionPoints, {
|
||||
CACHE_CONTROL_LABEL,
|
||||
CACHE_CONTROL_TOOLTIP,
|
||||
NEW_CACHE_CONTROL_POINT,
|
||||
} from "./cache_control_settings";
|
||||
import VectorStoreSelector from "../vector_store_management/VectorStoreSelector";
|
||||
import { Tag } from "../tag_management/types";
|
||||
import { formItemValidateJSON } from "../../utils/textUtils";
|
||||
|
|
@ -332,11 +336,21 @@ const AdvancedSettings: React.FC<AdvancedSettingsProps> = ({
|
|||
<Switch onChange={handlePassThroughChange} className="bg-gray-600" />
|
||||
</Form.Item>
|
||||
|
||||
<CacheControlSettings
|
||||
form={form}
|
||||
showCacheControl={showCacheControl}
|
||||
onCacheControlChange={handleCacheControlChange}
|
||||
/>
|
||||
<Form.Item
|
||||
label={CACHE_CONTROL_LABEL}
|
||||
name="cache_control"
|
||||
valuePropName="checked"
|
||||
className="mb-4"
|
||||
tooltip={CACHE_CONTROL_TOOLTIP}
|
||||
>
|
||||
<Switch onChange={handleCacheControlChange} className="bg-gray-600" />
|
||||
</Form.Item>
|
||||
|
||||
{showCacheControl && (
|
||||
<Form.Item name="cache_control_injection_points" initialValue={[NEW_CACHE_CONTROL_POINT]} noStyle>
|
||||
<CacheControlInjectionPoints />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item
|
||||
label="LiteLLM Params"
|
||||
name="litellm_extra_params"
|
||||
|
|
|
|||
|
|
@ -1,155 +1,141 @@
|
|||
import { Minus, Plus } from "lucide-react";
|
||||
import React from "react";
|
||||
import { Form, Switch, Select, Typography } from "antd";
|
||||
import { PlusOutlined, MinusCircleOutlined } from "@ant-design/icons";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
||||
import NumericalInput from "../shared/numerical_input";
|
||||
|
||||
const { Text } = Typography;
|
||||
export const CACHE_CONTROL_LABEL = "Cache Control Injection Points";
|
||||
|
||||
interface CacheControlInjectionPoint {
|
||||
export const CACHE_CONTROL_TOOLTIP =
|
||||
"Tell litellm where to inject cache control checkpoints. You can specify either by role (to apply to all messages of that role) or by specific message index.";
|
||||
|
||||
export const CACHE_CONTROL_DESCRIPTION =
|
||||
"Providers like Anthropic, Bedrock API require users to specify where to inject cache control checkpoints, litellm can automatically add them for you as a cost saving feature.";
|
||||
|
||||
export type CacheControlRole = "user" | "system" | "assistant";
|
||||
|
||||
export interface CacheControlInjectionPoint {
|
||||
location: "message";
|
||||
role?: "user" | "system" | "assistant";
|
||||
index?: number;
|
||||
role?: CacheControlRole;
|
||||
index?: string | number;
|
||||
}
|
||||
|
||||
interface CacheControlSettingsProps {
|
||||
form: any; // Form instance from parent
|
||||
showCacheControl: boolean;
|
||||
onCacheControlChange: (checked: boolean) => void;
|
||||
export const NEW_CACHE_CONTROL_POINT: CacheControlInjectionPoint = { location: "message" };
|
||||
|
||||
const LOCATION_ITEMS = [{ value: "message", label: "Message" }] as const;
|
||||
|
||||
const ROLE_ITEMS = [
|
||||
{ value: "user", label: "User" },
|
||||
{ value: "system", label: "System" },
|
||||
{ value: "assistant", label: "Assistant" },
|
||||
] as const;
|
||||
|
||||
interface CacheControlInjectionPointsProps {
|
||||
value?: CacheControlInjectionPoint[];
|
||||
onChange?: (points: CacheControlInjectionPoint[]) => void;
|
||||
}
|
||||
|
||||
const CacheControlSettings: React.FC<CacheControlSettingsProps> = ({
|
||||
form,
|
||||
showCacheControl,
|
||||
onCacheControlChange,
|
||||
}) => {
|
||||
const updateCacheControlPoints = (injectionPoints: CacheControlInjectionPoint[]) => {
|
||||
const currentParams = form.getFieldValue("litellm_extra_params");
|
||||
try {
|
||||
let paramsObj = currentParams ? JSON.parse(currentParams) : {};
|
||||
if (injectionPoints.length > 0) {
|
||||
paramsObj.cache_control_injection_points = injectionPoints;
|
||||
} else {
|
||||
delete paramsObj.cache_control_injection_points;
|
||||
}
|
||||
if (Object.keys(paramsObj).length > 0) {
|
||||
form.setFieldValue("litellm_extra_params", JSON.stringify(paramsObj, null, 2));
|
||||
} else {
|
||||
form.setFieldValue("litellm_extra_params", "");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error updating cache control points:", error);
|
||||
}
|
||||
};
|
||||
/**
|
||||
* Editor for `cache_control_injection_points`. It holds no form state of its own so that an antd
|
||||
* `Form.Item` and a react-hook-form `FormField` can each host it while their pages migrate
|
||||
* independently; both hand a child exactly `value` and `onChange`.
|
||||
*/
|
||||
const CacheControlInjectionPoints: React.FC<CacheControlInjectionPointsProps> = ({ value, onChange }) => {
|
||||
const points = value ?? [];
|
||||
|
||||
const replaceAt = (index: number, point: CacheControlInjectionPoint) =>
|
||||
onChange?.(points.map((existing, position) => (position === index ? point : existing)));
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form.Item
|
||||
label="Cache Control Injection Points"
|
||||
name="cache_control"
|
||||
valuePropName="checked"
|
||||
className="mb-4"
|
||||
tooltip="Tell litellm where to inject cache control checkpoints. You can specify either by role (to apply to all messages of that role) or by specific message index."
|
||||
>
|
||||
<Switch onChange={onCacheControlChange} className="bg-gray-600" />
|
||||
</Form.Item>
|
||||
<div className="ml-6 border-l-2 border-border pl-4">
|
||||
<p className="mb-4 block text-sm text-muted-foreground">{CACHE_CONTROL_DESCRIPTION}</p>
|
||||
|
||||
{showCacheControl && (
|
||||
<div className="ml-6 pl-4 border-l-2 border-gray-200">
|
||||
<Text className="text-sm text-gray-500 block mb-4">
|
||||
Providers like Anthropic, Bedrock API require users to specify where to inject cache control checkpoints,
|
||||
litellm can automatically add them for you as a cost saving feature.
|
||||
</Text>
|
||||
|
||||
<Form.List name="cache_control_injection_points" initialValue={[{ location: "message" }]}>
|
||||
{(fields, { add, remove }) => (
|
||||
<>
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.key} className="flex items-center mb-4 gap-4">
|
||||
<Form.Item
|
||||
{...field}
|
||||
label="Type"
|
||||
name={[field.name, "location"]}
|
||||
initialValue="message"
|
||||
className="mb-0"
|
||||
style={{ width: "180px" }}
|
||||
>
|
||||
<Select disabled options={[{ value: "message", label: "Message" }]} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
{...field}
|
||||
label="Role"
|
||||
name={[field.name, "role"]}
|
||||
className="mb-0"
|
||||
style={{ width: "180px" }}
|
||||
tooltip="LiteLLM will mark all messages of this role as cacheable"
|
||||
>
|
||||
<Select
|
||||
placeholder="Select a role"
|
||||
allowClear
|
||||
options={[
|
||||
{ value: "user", label: "User" },
|
||||
{ value: "system", label: "System" },
|
||||
{ value: "assistant", label: "Assistant" },
|
||||
]}
|
||||
onChange={() => {
|
||||
const values = form.getFieldValue("cache_control_points");
|
||||
updateCacheControlPoints(values);
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
{...field}
|
||||
label="Index"
|
||||
name={[field.name, "index"]}
|
||||
className="mb-0"
|
||||
style={{ width: "180px" }}
|
||||
tooltip="(Optional) If set litellm will mark the message at this index as cacheable"
|
||||
>
|
||||
<NumericalInput
|
||||
type="number"
|
||||
placeholder="Optional"
|
||||
step={1}
|
||||
onChange={() => {
|
||||
const values = form.getFieldValue("cache_control_points");
|
||||
updateCacheControlPoints(values);
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{fields.length > 1 && (
|
||||
<MinusCircleOutlined
|
||||
className="text-red-500 cursor-pointer text-lg ml-12"
|
||||
onClick={() => {
|
||||
remove(field.name);
|
||||
setTimeout(() => {
|
||||
const values = form.getFieldValue("cache_control_points");
|
||||
updateCacheControlPoints(values);
|
||||
}, 0);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{points.map((point, index) => (
|
||||
<div key={index} className="mb-4 flex items-end gap-4">
|
||||
<div className="w-[180px] space-y-1">
|
||||
<Label>Type</Label>
|
||||
<Select items={LOCATION_ITEMS} value={point.location} disabled>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LOCATION_ITEMS.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Form.Item>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-center w-full border border-dashed border-gray-300 py-2 px-4 text-gray-600 hover:text-blue-600 hover:border-blue-300 transition-all rounded-sm"
|
||||
onClick={() => add()}
|
||||
>
|
||||
<PlusOutlined className="mr-2" />
|
||||
Add Injection Point
|
||||
</button>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
<div className="w-[180px] space-y-1">
|
||||
<Label>Role</Label>
|
||||
<Select
|
||||
items={ROLE_ITEMS}
|
||||
value={point.role ?? null}
|
||||
onValueChange={(selected) =>
|
||||
replaceAt(index, { ...point, role: (selected as CacheControlRole | null) ?? undefined })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select a role" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={null}>None</SelectItem>
|
||||
{ROLE_ITEMS.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="w-[180px] space-y-1">
|
||||
<Label>Index</Label>
|
||||
<NumericalInput
|
||||
type="number"
|
||||
placeholder="Optional"
|
||||
step={1}
|
||||
value={point.index ?? ""}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
|
||||
replaceAt(index, {
|
||||
...point,
|
||||
index: event.target.value === "" ? undefined : event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{points.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={`Remove injection point ${index + 1}`}
|
||||
className="text-destructive"
|
||||
onClick={() => onChange?.(points.filter((_, position) => position !== index))}
|
||||
>
|
||||
<Minus className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full border-dashed"
|
||||
onClick={() => onChange?.([...points, NEW_CACHE_CONTROL_POINT])}
|
||||
>
|
||||
<Plus className="mr-2 size-4" />
|
||||
Add Injection Point
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CacheControlSettings;
|
||||
export default CacheControlInjectionPoints;
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ vi.mock("./networking", () => ({
|
|||
modelPatchUpdateCall: vi.fn(),
|
||||
modelDeleteCall: vi.fn(),
|
||||
credentialCreateCall: vi.fn(),
|
||||
vectorStoreListCall: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockUseModelsInfo = vi.fn();
|
||||
|
|
@ -57,6 +58,7 @@ const mockTestModelGroupConnection = vi.mocked(networking.testModelGroupConnecti
|
|||
const mockModelPatchUpdateCall = vi.mocked(networking.modelPatchUpdateCall);
|
||||
const mockModelDeleteCall = vi.mocked(networking.modelDeleteCall);
|
||||
const mockCredentialCreateCall = vi.mocked(networking.credentialCreateCall);
|
||||
const mockVectorStoreListCall = vi.mocked(networking.vectorStoreListCall);
|
||||
|
||||
describe("ModelInfoView", () => {
|
||||
let queryClient: QueryClient;
|
||||
|
|
@ -166,6 +168,12 @@ describe("ModelInfoView", () => {
|
|||
status: "success",
|
||||
});
|
||||
|
||||
mockVectorStoreListCall.mockResolvedValue({
|
||||
data: [
|
||||
{ vector_store_id: "vs-alpha", vector_store_name: "Alpha" },
|
||||
{ vector_store_id: "vs-beta", vector_store_name: "Beta" },
|
||||
],
|
||||
} as never);
|
||||
mockModelPatchUpdateCall.mockResolvedValue({});
|
||||
mockModelDeleteCall.mockResolvedValue({});
|
||||
mockCredentialCreateCall.mockResolvedValue({});
|
||||
|
|
@ -768,6 +776,99 @@ describe("ModelInfoView", () => {
|
|||
});
|
||||
});
|
||||
|
||||
const enterPtuEdit = async (user: ReturnType<typeof userEvent.setup>) => {
|
||||
mockUsePtuCostAttributionEnabled.mockReturnValue(true);
|
||||
renderWithPtuModel();
|
||||
expect(await screen.findByRole("button", { name: /edit settings/i })).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: /edit settings/i }));
|
||||
expect(await screen.findByPlaceholderText("e.g. 15")).toBeInTheDocument();
|
||||
};
|
||||
|
||||
const expectBlocked = async (user: ReturnType<typeof userEvent.setup>, message: RegExp) => {
|
||||
await user.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
expect(await screen.findAllByText(message)).not.toHaveLength(0);
|
||||
expect(mockModelPatchUpdateCall).not.toHaveBeenCalled();
|
||||
};
|
||||
|
||||
it("skips PTU validation entirely when the feature is disabled, so a half-set stored record still saves", async () => {
|
||||
mockUsePtuCostAttributionEnabled.mockReturnValue(false);
|
||||
const halfSetPtuModel = {
|
||||
...ptuModelData,
|
||||
model_info: { ...ptuModelData.model_info, cost_per_ptu_per_hour: null, ptu_effective_from: null },
|
||||
};
|
||||
mockUseModelsInfo.mockReturnValue({ data: { data: [halfSetPtuModel] }, isLoading: false, error: null });
|
||||
mockModelInfoV1Call.mockResolvedValue({ data: [halfSetPtuModel] });
|
||||
const user = userEvent.setup();
|
||||
render(<ModelInfoView {...DEFAULT_ADMIN_PROPS} />, { wrapper });
|
||||
|
||||
expect(await screen.findByRole("button", { name: /edit settings/i })).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: /edit settings/i }));
|
||||
expect(await screen.findByRole("button", { name: /save changes/i })).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
|
||||
await waitFor(() => expect(mockModelPatchUpdateCall).toHaveBeenCalled());
|
||||
expect(screen.queryByText(/must be set together/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("blocks a PTU count above the backend ceiling", async () => {
|
||||
const user = userEvent.setup();
|
||||
await enterPtuEdit(user);
|
||||
|
||||
await user.clear(screen.getByPlaceholderText("e.g. 15"));
|
||||
await user.type(screen.getByPlaceholderText("e.g. 15"), "1000001");
|
||||
|
||||
await expectBlocked(user, /PTU Count must be a whole number between 1 and 1,000,000/i);
|
||||
});
|
||||
|
||||
it("blocks a cost per PTU hour above the backend ceiling", async () => {
|
||||
const user = userEvent.setup();
|
||||
await enterPtuEdit(user);
|
||||
|
||||
await user.clear(screen.getByPlaceholderText("e.g. 2.00"));
|
||||
await user.type(screen.getByPlaceholderText("e.g. 2.00"), "2000000");
|
||||
|
||||
await expectBlocked(user, /Cost per PTU \/ Hour must be between 0 and 1,000,000/i);
|
||||
});
|
||||
|
||||
it("blocks a half-set PTU count and rate pair", async () => {
|
||||
const user = userEvent.setup();
|
||||
await enterPtuEdit(user);
|
||||
|
||||
await user.clear(screen.getByPlaceholderText("e.g. 2.00"));
|
||||
|
||||
await expectBlocked(user, /PTU Count and Cost per PTU \/ Hour must be set together/i);
|
||||
});
|
||||
|
||||
it("blocks PTU config with no effective start", async () => {
|
||||
mockUsePtuCostAttributionEnabled.mockReturnValue(true);
|
||||
const undatedPtuModel = {
|
||||
...ptuModelData,
|
||||
model_info: { ...ptuModelData.model_info, ptu_effective_from: null, ptu_effective_to: null },
|
||||
};
|
||||
mockUseModelsInfo.mockReturnValue({ data: { data: [undatedPtuModel] }, isLoading: false, error: null });
|
||||
mockModelInfoV1Call.mockResolvedValue({ data: [undatedPtuModel] });
|
||||
const user = userEvent.setup();
|
||||
render(<ModelInfoView {...DEFAULT_ADMIN_PROPS} />, { wrapper });
|
||||
|
||||
expect(await screen.findByRole("button", { name: /edit settings/i })).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: /edit settings/i }));
|
||||
expect(await screen.findByPlaceholderText("e.g. 15")).toBeInTheDocument();
|
||||
|
||||
await expectBlocked(user, /PTU Effective From is required when PTU Count is set/i);
|
||||
});
|
||||
|
||||
it("blocks a PTU window whose end is not after its start", async () => {
|
||||
const user = userEvent.setup();
|
||||
await enterPtuEdit(user);
|
||||
|
||||
const to = screen.getAllByPlaceholderText("Select date")[1];
|
||||
await user.clear(to);
|
||||
await user.type(to, "2026-06-01 00:00:00");
|
||||
await user.tab();
|
||||
|
||||
await expectBlocked(user, /PTU Effective To must be after PTU Effective From/i);
|
||||
});
|
||||
|
||||
it("sends the PTU fields on save when enabled", async () => {
|
||||
mockUsePtuCostAttributionEnabled.mockReturnValue(true);
|
||||
const user = userEvent.setup();
|
||||
|
|
@ -791,6 +892,64 @@ describe("ModelInfoView", () => {
|
|||
expect(modelInfo.ptu_count).toBe(15);
|
||||
expect(modelInfo.cost_per_ptu_per_hour).toBe(2);
|
||||
});
|
||||
|
||||
it("routes each edited PTU field into its own model_info key", async () => {
|
||||
mockUsePtuCostAttributionEnabled.mockReturnValue(true);
|
||||
const user = userEvent.setup();
|
||||
renderWithPtuModel();
|
||||
|
||||
expect(await screen.findByRole("button", { name: /edit settings/i })).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: /edit settings/i }));
|
||||
expect(await screen.findByRole("button", { name: /save changes/i })).toBeInTheDocument();
|
||||
|
||||
await user.clear(screen.getByPlaceholderText("e.g. 15"));
|
||||
await user.type(screen.getByPlaceholderText("e.g. 15"), "20");
|
||||
await user.clear(screen.getByPlaceholderText("e.g. 2.00"));
|
||||
await user.type(screen.getByPlaceholderText("e.g. 2.00"), "3.5");
|
||||
|
||||
const dates = () => screen.getAllByPlaceholderText("Select date");
|
||||
expect(dates()[0]).toHaveValue("2026-07-01 00:00:00");
|
||||
expect(dates()[1]).toHaveValue("2026-08-01 00:00:00");
|
||||
|
||||
const setDate = async (index: number, value: string) => {
|
||||
await user.clear(dates()[index]);
|
||||
await user.type(dates()[index], value);
|
||||
await user.tab();
|
||||
};
|
||||
await setDate(1, "2026-10-03 02:00:00");
|
||||
await setDate(0, "2026-09-02 01:00:00");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
await waitFor(() => expect(mockModelPatchUpdateCall).toHaveBeenCalled());
|
||||
|
||||
const modelInfo = mockModelPatchUpdateCall.mock.calls[0][1].model_info;
|
||||
expect(modelInfo.ptu_count).toBe(20);
|
||||
expect(modelInfo.cost_per_ptu_per_hour).toBe(3.5);
|
||||
expect(modelInfo.ptu_effective_from).toBe("2026-09-02T01:00:00.000Z");
|
||||
expect(modelInfo.ptu_effective_to).toBe("2026-10-03T02:00:00.000Z");
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks the save when the LiteLLM Params box does not hold valid JSON", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ModelInfoView {...DEFAULT_ADMIN_PROPS} />, { wrapper });
|
||||
|
||||
expect(await screen.findByRole("button", { name: /edit settings/i })).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: /edit settings/i }));
|
||||
|
||||
const extraParams = screen
|
||||
.getAllByRole("textbox")
|
||||
.find(
|
||||
(input) =>
|
||||
input.tagName === "TEXTAREA" && (input as HTMLTextAreaElement).value.includes('"custom_llm_provider"'),
|
||||
) as HTMLTextAreaElement;
|
||||
await user.clear(extraParams);
|
||||
await user.paste("{not json");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
|
||||
expect(await screen.findByText("Please enter valid JSON")).toBeInTheDocument();
|
||||
expect(mockModelPatchUpdateCall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not include input_cost_per_token or output_cost_per_token in update payload when user does not touch cost fields", async () => {
|
||||
|
|
@ -1257,4 +1416,312 @@ describe("ModelInfoView", () => {
|
|||
expect(await screen.findByTestId("test-connection-button")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("payload parity pins", () => {
|
||||
const enterEditMode = async (user: ReturnType<typeof userEvent.setup>) => {
|
||||
render(<ModelInfoView {...DEFAULT_ADMIN_PROPS} />, { wrapper });
|
||||
expect(await screen.findByRole("button", { name: /edit settings/i })).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: /edit settings/i }));
|
||||
expect(await screen.findByRole("button", { name: /save changes/i })).toBeInTheDocument();
|
||||
};
|
||||
|
||||
const save = async (user: ReturnType<typeof userEvent.setup>) => {
|
||||
await user.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
await waitFor(() => expect(mockModelPatchUpdateCall).toHaveBeenCalled());
|
||||
return mockModelPatchUpdateCall.mock.calls[0][1] as {
|
||||
model_name: string;
|
||||
litellm_params: Record<string, unknown>;
|
||||
model_info: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
it("sends the whole edit payload for an untouched save", async () => {
|
||||
const user = userEvent.setup();
|
||||
await enterEditMode(user);
|
||||
const payload = await save(user);
|
||||
|
||||
expect(payload).toEqual({
|
||||
model_name: "GPT-4",
|
||||
litellm_params: {
|
||||
model: "gpt-4",
|
||||
api_base: "https://api.openai.com/v1",
|
||||
custom_llm_provider: "openai",
|
||||
litellm_credential_name: "selected-credential",
|
||||
tags: [],
|
||||
guardrails: [],
|
||||
},
|
||||
model_info: {
|
||||
id: "123",
|
||||
created_by: "123",
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
db_model: true,
|
||||
input_cost_per_token: 0.00003,
|
||||
output_cost_per_token: 0.00006,
|
||||
access_groups: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("omits health_check_model for a model that is not a wildcard, whose field never renders", async () => {
|
||||
const user = userEvent.setup();
|
||||
await enterEditMode(user);
|
||||
const payload = await save(user);
|
||||
|
||||
expect(payload.model_info).not.toHaveProperty("health_check_model");
|
||||
});
|
||||
|
||||
it("routes each edited field into its own payload key", async () => {
|
||||
const user = userEvent.setup();
|
||||
await enterEditMode(user);
|
||||
|
||||
await user.clear(screen.getByPlaceholderText("Enter model name"));
|
||||
await user.type(screen.getByPlaceholderText("Enter model name"), "renamed-model");
|
||||
await user.clear(screen.getByPlaceholderText("Enter LiteLLM model name"));
|
||||
await user.type(screen.getByPlaceholderText("Enter LiteLLM model name"), "gpt-4o");
|
||||
await user.clear(screen.getByPlaceholderText("Enter API base"));
|
||||
await user.type(screen.getByPlaceholderText("Enter API base"), "https://example.test/v1");
|
||||
await user.clear(screen.getByPlaceholderText("Enter custom LLM provider"));
|
||||
await user.type(screen.getByPlaceholderText("Enter custom LLM provider"), "azure");
|
||||
await user.type(screen.getByPlaceholderText("Enter organization"), "org-9");
|
||||
await user.type(screen.getByPlaceholderText("Enter TPM"), "111");
|
||||
await user.type(screen.getByPlaceholderText("Enter RPM"), "222");
|
||||
await user.type(screen.getByPlaceholderText("Enter max retries"), "4");
|
||||
await user.type(screen.getByPlaceholderText("Enter timeout"), "33");
|
||||
await user.type(screen.getByPlaceholderText("Enter stream timeout"), "44");
|
||||
|
||||
const payload = await save(user);
|
||||
|
||||
expect(payload.model_name).toBe("renamed-model");
|
||||
expect(payload.litellm_params).toMatchObject({
|
||||
model: "gpt-4o",
|
||||
api_base: "https://example.test/v1",
|
||||
custom_llm_provider: "azure",
|
||||
organization: "org-9",
|
||||
tpm: "111",
|
||||
rpm: "222",
|
||||
max_retries: "4",
|
||||
timeout: "33",
|
||||
stream_timeout: "44",
|
||||
});
|
||||
});
|
||||
|
||||
it("routes each edited pricing field into its own payload key", async () => {
|
||||
const user = userEvent.setup();
|
||||
await enterEditMode(user);
|
||||
|
||||
await user.clear(screen.getByPlaceholderText("Enter output cost"));
|
||||
await user.type(screen.getByPlaceholderText("Enter output cost"), "12");
|
||||
const [cacheRead, cacheWrite] = screen.getAllByPlaceholderText("Defaults to Input Cost if blank");
|
||||
await user.type(cacheRead, "5");
|
||||
await user.type(cacheWrite, "9");
|
||||
|
||||
const payload = await save(user);
|
||||
|
||||
expect(payload.litellm_params).toMatchObject({
|
||||
output_cost_per_token: 0.000012,
|
||||
cache_read_input_token_cost: 0.000005,
|
||||
cache_creation_input_token_cost: 0.000009,
|
||||
});
|
||||
});
|
||||
|
||||
const addTag = async (user: ReturnType<typeof userEvent.setup>, placeholder: string, tag: string) => {
|
||||
const input = screen.getByPlaceholderText(placeholder);
|
||||
await user.type(input, tag);
|
||||
await user.keyboard("{Enter}");
|
||||
};
|
||||
|
||||
it("routes each typed collection field into its own payload key", async () => {
|
||||
const user = userEvent.setup();
|
||||
await enterEditMode(user);
|
||||
|
||||
await addTag(user, "Select existing groups or type to create new ones", "beta-testers");
|
||||
await addTag(user, "Select existing guardrails or type to create new ones", "content_filter");
|
||||
await addTag(user, "Select existing tags or type to create new ones", "production_tag");
|
||||
|
||||
const payload = await save(user);
|
||||
|
||||
expect(payload.model_info.access_groups).toEqual(["beta-testers"]);
|
||||
expect(payload.litellm_params.guardrails).toEqual(["content_filter"]);
|
||||
expect(payload.litellm_params.tags).toEqual(["production_tag"]);
|
||||
});
|
||||
|
||||
it("sends the edited model info JSON", async () => {
|
||||
const user = userEvent.setup();
|
||||
await enterEditMode(user);
|
||||
|
||||
const modelInfo = screen.getByPlaceholderText('{"gpt-4": 100, "claude-v1": 200}');
|
||||
await user.clear(modelInfo);
|
||||
await user.paste('{"id":"123","team_id":"team-7"}');
|
||||
|
||||
const payload = await save(user);
|
||||
|
||||
expect(payload.model_info).toMatchObject({ team_id: "team-7" });
|
||||
});
|
||||
|
||||
it("sends the edited LiteLLM extra params", async () => {
|
||||
const user = userEvent.setup();
|
||||
await enterEditMode(user);
|
||||
|
||||
const extraParams = screen
|
||||
.getAllByRole("textbox")
|
||||
.find(
|
||||
(input) =>
|
||||
input.tagName === "TEXTAREA" && (input as HTMLTextAreaElement).value.includes('"custom_llm_provider"'),
|
||||
) as HTMLTextAreaElement;
|
||||
await user.clear(extraParams);
|
||||
await user.paste('{"drop_params":true}');
|
||||
|
||||
const payload = await save(user);
|
||||
|
||||
expect(payload.litellm_params.drop_params).toBe(true);
|
||||
});
|
||||
|
||||
it("sends the credential picked in the selector", async () => {
|
||||
mockCredentialListCall.mockResolvedValue({
|
||||
credentials: [
|
||||
{ credential_name: "selected-credential", credential_values: {}, credential_info: {} },
|
||||
{ credential_name: "other-credential", credential_values: {}, credential_info: {} },
|
||||
],
|
||||
} as never);
|
||||
const user = userEvent.setup();
|
||||
await enterEditMode(user);
|
||||
|
||||
await user.click(await screen.findByText("selected-credential"));
|
||||
await user.click(await screen.findByText("other-credential"));
|
||||
|
||||
const payload = await save(user);
|
||||
|
||||
expect(payload.litellm_params.litellm_credential_name).toBe("other-credential");
|
||||
});
|
||||
|
||||
it("sends the vector stores picked in the knowledge base selector", async () => {
|
||||
const user = userEvent.setup();
|
||||
await enterEditMode(user);
|
||||
|
||||
await user.click(screen.getByPlaceholderText("Select knowledge bases (optional)"));
|
||||
await user.click(await screen.findByText("Beta (vs-beta)"));
|
||||
await user.keyboard("{Escape}");
|
||||
|
||||
const payload = await save(user);
|
||||
|
||||
expect(payload.litellm_params.vector_store_ids).toEqual(["vs-beta"]);
|
||||
});
|
||||
|
||||
it("sends the health check model picked for a wildcard deployment", async () => {
|
||||
const wildcard = {
|
||||
...defaultModelData,
|
||||
litellm_params: { ...defaultModelData.litellm_params, model: "openai/gpt-4*" },
|
||||
};
|
||||
mockUseModelsInfo.mockReturnValue({ data: { data: [wildcard] }, isLoading: false, error: null });
|
||||
mockModelInfoV1Call.mockResolvedValue({ data: [wildcard] });
|
||||
mockUseModelHub.mockReturnValue({
|
||||
data: { data: [{ model_group: "openai/gpt-4o", providers: ["openai"] }] },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
await enterEditMode(user);
|
||||
|
||||
await user.click(screen.getByText("Select existing health check model"));
|
||||
await user.click(await screen.findByText("openai/gpt-4o"));
|
||||
|
||||
const payload = await save(user);
|
||||
|
||||
expect(payload.model_info.health_check_model).toBe("openai/gpt-4o");
|
||||
});
|
||||
|
||||
it("keeps a pricing field in the payload after the operator types a value and restores the original", async () => {
|
||||
// antd marks a field touched on change and never clears it, so retyping the seeded value
|
||||
// still ships the key. RHF's dirtyFields resets on a value returning to its default, which
|
||||
// would silently drop input_cost_per_token here.
|
||||
const user = userEvent.setup();
|
||||
await enterEditMode(user);
|
||||
|
||||
const inputCost = screen.getByPlaceholderText("Enter input cost") as HTMLInputElement;
|
||||
const seeded = inputCost.value;
|
||||
expect(seeded).toBe("30");
|
||||
|
||||
await user.clear(inputCost);
|
||||
await user.type(inputCost, "7");
|
||||
await user.clear(inputCost);
|
||||
await user.type(inputCost, seeded);
|
||||
|
||||
const payload = await save(user);
|
||||
|
||||
expect(payload.litellm_params.input_cost_per_token).toBe(0.00003);
|
||||
expect(payload.litellm_params.cache_read_input_token_cost).toBe(0.00003);
|
||||
});
|
||||
|
||||
it("clears a pricing override with an explicit null once the field is emptied", async () => {
|
||||
const user = userEvent.setup();
|
||||
await enterEditMode(user);
|
||||
|
||||
await user.clear(screen.getByPlaceholderText("Enter input cost"));
|
||||
const payload = await save(user);
|
||||
|
||||
expect(payload.litellm_params.input_cost_per_token).toBeNull();
|
||||
expect(payload.litellm_params).not.toHaveProperty("cache_read_input_token_cost");
|
||||
});
|
||||
|
||||
describe("cache control injection points", () => {
|
||||
const withCachePoints = (points: unknown) => {
|
||||
const data = {
|
||||
...defaultModelData,
|
||||
litellm_params: { ...defaultModelData.litellm_params, cache_control_injection_points: points },
|
||||
};
|
||||
mockUseModelsInfo.mockReturnValue({ data: { data: [data] }, isLoading: false, error: null });
|
||||
mockModelInfoV1Call.mockResolvedValue({ data: [data] });
|
||||
};
|
||||
|
||||
it("omits the key when the deployment has none and the operator leaves the toggle alone", async () => {
|
||||
const user = userEvent.setup();
|
||||
await enterEditMode(user);
|
||||
const payload = await save(user);
|
||||
|
||||
expect(payload.litellm_params).not.toHaveProperty("cache_control_injection_points");
|
||||
});
|
||||
|
||||
it("hides the injection point rows until the toggle is on", async () => {
|
||||
const user = userEvent.setup();
|
||||
await enterEditMode(user);
|
||||
|
||||
expect(screen.queryByRole("button", { name: /add injection point/i })).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("switch"));
|
||||
|
||||
expect(await screen.findByRole("button", { name: /add injection point/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("round-trips the stored injection points on an untouched save", async () => {
|
||||
withCachePoints([{ location: "message", role: "user" }]);
|
||||
const user = userEvent.setup();
|
||||
await enterEditMode(user);
|
||||
const payload = await save(user);
|
||||
|
||||
expect(payload.litellm_params.cache_control_injection_points).toEqual([{ location: "message", role: "user" }]);
|
||||
});
|
||||
|
||||
it("drops the stored injection points when the operator turns the toggle off", async () => {
|
||||
withCachePoints([{ location: "message", role: "user" }]);
|
||||
const user = userEvent.setup();
|
||||
await enterEditMode(user);
|
||||
|
||||
await user.click(screen.getByRole("switch"));
|
||||
const payload = await save(user);
|
||||
|
||||
expect(payload.litellm_params).not.toHaveProperty("cache_control_injection_points");
|
||||
});
|
||||
|
||||
it("adds a typed index as a string, matching what the deployment already stores", async () => {
|
||||
withCachePoints([{ location: "message" }]);
|
||||
const user = userEvent.setup();
|
||||
await enterEditMode(user);
|
||||
|
||||
await user.type(screen.getByPlaceholderText("Optional"), "2");
|
||||
const payload = await save(user);
|
||||
|
||||
expect(payload.litellm_params.cache_control_injection_points).toEqual([{ location: "message", index: "2" }]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -22,17 +22,13 @@ interface NumericalInputProps {
|
|||
* @param {Function} [props.onChange] - On change handler
|
||||
* @param {any} props.rest - Additional props passed to Input
|
||||
*/
|
||||
const NumericalInput: React.FC<NumericalInputProps> = ({
|
||||
step = 0.01,
|
||||
style = { width: "100%" },
|
||||
placeholder = "Enter a numerical value",
|
||||
min,
|
||||
max,
|
||||
onChange,
|
||||
...rest
|
||||
}) => {
|
||||
return (
|
||||
const NumericalInput = React.forwardRef<HTMLInputElement, NumericalInputProps>(
|
||||
(
|
||||
{ step = 0.01, style = { width: "100%" }, placeholder = "Enter a numerical value", min, max, onChange, ...rest },
|
||||
ref,
|
||||
) => (
|
||||
<Input
|
||||
ref={ref}
|
||||
type="number"
|
||||
onWheel={(event) => event.currentTarget.blur()}
|
||||
step={step}
|
||||
|
|
@ -43,7 +39,8 @@ const NumericalInput: React.FC<NumericalInputProps> = ({
|
|||
onChange={onChange}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
};
|
||||
),
|
||||
);
|
||||
NumericalInput.displayName = "NumericalInput";
|
||||
|
||||
export default NumericalInput;
|
||||
|
|
|
|||
|
|
@ -18,10 +18,10 @@ export const PTU_END_FIELD = "ptu_effective_to";
|
|||
export const MAX_PTU_COUNT = 1_000_000;
|
||||
export const MAX_COST_PER_PTU_PER_HOUR = 1_000_000;
|
||||
|
||||
const isFilled = (value: unknown): boolean => value !== undefined && value !== null && value !== "";
|
||||
export const isFilledPtuValue = (value: unknown): boolean => value !== undefined && value !== null && value !== "";
|
||||
|
||||
const isPositiveWholeNumber = (value: unknown): boolean => {
|
||||
if (!isFilled(value)) {
|
||||
export const isPositiveWholePtuCount = (value: unknown): boolean => {
|
||||
if (!isFilledPtuValue(value)) {
|
||||
return true;
|
||||
}
|
||||
const parsed = Number(value);
|
||||
|
|
@ -32,14 +32,14 @@ const isPositiveWholeNumber = (value: unknown): boolean => {
|
|||
export const ptuCountRules: ValidatorRule[] = [
|
||||
{
|
||||
validator: (_, value) =>
|
||||
isPositiveWholeNumber(value)
|
||||
isPositiveWholePtuCount(value)
|
||||
? Promise.resolve()
|
||||
: Promise.reject(new Error(`PTU Count must be a whole number between 1 and ${MAX_PTU_COUNT.toLocaleString()}`)),
|
||||
},
|
||||
];
|
||||
|
||||
const isNonNegativeNumber = (value: unknown): boolean => {
|
||||
if (!isFilled(value)) {
|
||||
export const isNonNegativePtuRate = (value: unknown): boolean => {
|
||||
if (!isFilledPtuValue(value)) {
|
||||
return true;
|
||||
}
|
||||
const parsed = Number(value);
|
||||
|
|
@ -50,7 +50,7 @@ const isNonNegativeNumber = (value: unknown): boolean => {
|
|||
export const ptuRateRules: ValidatorRule[] = [
|
||||
{
|
||||
validator: (_, value) =>
|
||||
isNonNegativeNumber(value)
|
||||
isNonNegativePtuRate(value)
|
||||
? Promise.resolve()
|
||||
: Promise.reject(
|
||||
new Error(`Cost per PTU / Hour must be between 0 and ${MAX_COST_PER_PTU_PER_HOUR.toLocaleString()}`),
|
||||
|
|
@ -67,7 +67,7 @@ export const ptuPairRule =
|
|||
(siblingField: string) =>
|
||||
({ getFieldValue }: FormInstance): ValidatorRule => ({
|
||||
validator: (_, value) =>
|
||||
isFilled(value) === isFilled(getFieldValue(siblingField))
|
||||
isFilledPtuValue(value) === isFilledPtuValue(getFieldValue(siblingField))
|
||||
? Promise.resolve()
|
||||
: Promise.reject(new Error("PTU Count and Cost per PTU / Hour must be set together")),
|
||||
});
|
||||
|
|
@ -85,7 +85,7 @@ export const ptuNoUsageCostRule =
|
|||
// for an unpriced deployment is the public cost map. Refusing it would block every
|
||||
// attempt to put an existing deployment on PTU, and the save omits it anyway.
|
||||
const echoed = thisField !== undefined && isFieldTouched !== undefined && !isFieldTouched(thisField);
|
||||
return echoed || !isFilled(getFieldValue(countField)) || !isFilled(value) || Number(value) === 0
|
||||
return echoed || !isFilledPtuValue(getFieldValue(countField)) || !isFilledPtuValue(value) || Number(value) === 0
|
||||
? Promise.resolve()
|
||||
: Promise.reject(new Error("A PTU deployment bills by reserved capacity, so this cost must be 0 or blank"));
|
||||
},
|
||||
|
|
@ -100,7 +100,7 @@ export const ptuStartRequiredRule =
|
|||
(countField: string) =>
|
||||
({ getFieldValue }: FormInstance): ValidatorRule => ({
|
||||
validator: (_, value) =>
|
||||
isFilled(value) || !isFilled(getFieldValue(countField))
|
||||
isFilledPtuValue(value) || !isFilledPtuValue(getFieldValue(countField))
|
||||
? Promise.resolve()
|
||||
: Promise.reject(new Error("PTU Effective From is required when PTU Count is set")),
|
||||
});
|
||||
|
|
@ -118,19 +118,24 @@ const toEpochMs = (value: unknown): number => {
|
|||
* cannot anticipate. Pair this with `dependencies` on the sibling bound so the error clears
|
||||
* once the pair is ordered.
|
||||
*/
|
||||
export const ptuWindowIsOrdered = (start: unknown, end: unknown): boolean => {
|
||||
if (!isFilledPtuValue(start) || !isFilledPtuValue(end)) {
|
||||
return true;
|
||||
}
|
||||
const startMs = toEpochMs(start);
|
||||
const endMs = toEpochMs(end);
|
||||
return Number.isNaN(startMs) || Number.isNaN(endMs) || endMs > startMs;
|
||||
};
|
||||
|
||||
export const ptuWindowOrderRule =
|
||||
(siblingField: string, thisBound: "start" | "end") =>
|
||||
({ getFieldValue }: FormInstance): ValidatorRule => ({
|
||||
validator: (_, value) => {
|
||||
const sibling = getFieldValue(siblingField);
|
||||
if (!isFilled(value) || !isFilled(sibling)) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
const startMs = toEpochMs(thisBound === "start" ? value : sibling);
|
||||
const endMs = toEpochMs(thisBound === "start" ? sibling : value);
|
||||
if (Number.isNaN(startMs) || Number.isNaN(endMs) || endMs > startMs) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(new Error("PTU Effective To must be after PTU Effective From"));
|
||||
const start = thisBound === "start" ? value : sibling;
|
||||
const end = thisBound === "start" ? sibling : value;
|
||||
return ptuWindowIsOrdered(start, end)
|
||||
? Promise.resolve()
|
||||
: Promise.reject(new Error("PTU Effective To must be after PTU Effective From"));
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue