mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
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.
This commit is contained in:
parent
193d5078f8
commit
5b7ecede4e
9 changed files with 1362 additions and 1017 deletions
|
|
@ -1913,12 +1913,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": {
|
||||
|
|
@ -2405,15 +2399,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": 2
|
||||
},
|
||||
|
|
|
|||
835
ui/litellm-dashboard/src/components/ModelInfoEditForm.tsx
Normal file
835
ui/litellm-dashboard/src/components/ModelInfoEditForm.tsx
Normal file
|
|
@ -0,0 +1,835 @@
|
|||
"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" },
|
||||
];
|
||||
|
||||
/** The four names whose payload branch antd gated on `form.isFieldTouched`. */
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Mirrors the antd rules `Form.Item` applied to the same fields. The predicates stay on
|
||||
* `utils/ptuValidation` because `advanced_settings` still feeds the antd wrappers to its own
|
||||
* form, so the two surfaces cannot drift apart on what the backend accepts.
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
// A cost the operator never typed was echoed back from /model/info, which for an unpriced
|
||||
// deployment is the public cost map. Refusing it would block putting an existing deployment
|
||||
// on PTU, and the save omits an untouched cost anyway.
|
||||
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;
|
||||
};
|
||||
|
||||
/**
|
||||
* The named counterpart of the antd `initialValues` block: it lists every bound field rather than
|
||||
* spreading the loaded record, so server-only keys never reach the payload.
|
||||
*/
|
||||
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 its key never reached the
|
||||
// payload. RHF submits the whole store, so the key has to be absent rather than 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: any;
|
||||
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,
|
||||
}) => {
|
||||
// antd marks a field touched on CHANGE and never clears it. RHF's touchedFields is blur-based
|
||||
// and dirtyFields resets when a value returns to its default, so neither reproduces the branch
|
||||
// `handleModelUpdate` takes; this tracks first change the way rc-field-form does.
|
||||
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 on every render, so a resolver rebuilt here is the
|
||||
// one that runs on the next submit; the PTU flag needs no ref to stay current.
|
||||
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}>
|
||||
{isSaving && <UiLoadingSpinner className="size-4" />}
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelInfoEditForm;
|
||||
|
|
@ -312,15 +312,6 @@ describe("AddModelForm", () => {
|
|||
});
|
||||
|
||||
describe("cache control bindings reach the parent form store", () => {
|
||||
const controlFor = (labelText: string, role: string): HTMLElement => {
|
||||
const item = screen.getByText(labelText).closest(".ant-form-item");
|
||||
const control = item?.querySelector(`[role="${role}"]`);
|
||||
if (!(control instanceof HTMLElement)) {
|
||||
throw new Error(`no ${role} control found for "${labelText}"`);
|
||||
}
|
||||
return control;
|
||||
};
|
||||
|
||||
const renderWithForm = async () => {
|
||||
const mockUseAuthorized = vi.mocked(await import("@/app/(dashboard)/hooks/useAuthorized"));
|
||||
mockUseAuthorized.default.mockReturnValue(mockAuthorizedUser("proxy_admin", "user-1", true));
|
||||
|
|
@ -333,11 +324,11 @@ describe("AddModelForm", () => {
|
|||
user,
|
||||
openCacheControl: async () => {
|
||||
await user.click(await screen.findByText("Advanced Settings"));
|
||||
await user.click(controlFor("Cache Control Injection Points", "switch"));
|
||||
await user.click(screen.getByLabelText("Cache Control Injection Points"));
|
||||
await screen.findByText("Add Injection Point");
|
||||
},
|
||||
closeCacheControl: async () => {
|
||||
await user.click(controlFor("Cache Control Injection Points", "switch"));
|
||||
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
|
||||
|
|
@ -373,7 +364,7 @@ describe("AddModelForm", () => {
|
|||
await openCacheControl();
|
||||
|
||||
await user.click(screen.getByText("Select a role"));
|
||||
await user.click(await screen.findByTitle("System"));
|
||||
await user.click(await screen.findByText("System"));
|
||||
await user.type(screen.getByPlaceholderText("Optional"), "3");
|
||||
|
||||
const values = await mountedValues();
|
||||
|
|
|
|||
|
|
@ -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({});
|
||||
|
|
@ -746,6 +754,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();
|
||||
|
|
@ -769,6 +870,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 () => {
|
||||
|
|
@ -1239,9 +1398,9 @@ describe("ModelInfoView", () => {
|
|||
describe("payload parity pins", () => {
|
||||
const enterEditMode = async (user: ReturnType<typeof userEvent.setup>) => {
|
||||
render(<ModelInfoView {...DEFAULT_ADMIN_PROPS} />, { wrapper });
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: /edit settings/i })).toBeInTheDocument());
|
||||
expect(await screen.findByRole("button", { name: /edit settings/i })).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: /edit settings/i }));
|
||||
await waitFor(() => expect(screen.getByRole("button", { name: /save changes/i })).toBeInTheDocument());
|
||||
expect(await screen.findByRole("button", { name: /save changes/i })).toBeInTheDocument();
|
||||
};
|
||||
|
||||
const save = async (user: ReturnType<typeof userEvent.setup>) => {
|
||||
|
|
@ -1299,9 +1458,14 @@ describe("ModelInfoView", () => {
|
|||
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);
|
||||
|
||||
|
|
@ -1309,12 +1473,141 @@ describe("ModelInfoView", () => {
|
|||
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
|
||||
|
|
@ -1366,6 +1659,17 @@ describe("ModelInfoView", () => {
|
|||
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();
|
||||
|
|
|
|||
|
|
@ -13,32 +13,17 @@ import {
|
|||
TabPanel,
|
||||
TabPanels,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
Button as TremorButton,
|
||||
} from "@tremor/react";
|
||||
import { Button, DatePicker, Form, Input, Modal, Select, Tooltip } from "antd";
|
||||
import { formatPtuUtcDisplay, utcIsoToPickerValue } from "../utils/ptuDatetime";
|
||||
import { Button, Modal, Tooltip } from "antd";
|
||||
import { applyPtuModelInfo } from "../utils/ptuModelInfo";
|
||||
import { usePtuCostAttributionEnabled } from "@/app/(dashboard)/hooks/uiSettings/usePtuCostAttributionEnabled";
|
||||
import {
|
||||
PTU_COUNT_FIELD,
|
||||
PTU_RATE_FIELD,
|
||||
ptuCountRules,
|
||||
ptuNoUsageCostRule,
|
||||
ptuPairRule,
|
||||
ptuRateRules,
|
||||
ptuStartRequiredRule,
|
||||
ptuWindowOrderRule,
|
||||
PTU_END_FIELD,
|
||||
PTU_START_FIELD,
|
||||
} from "../utils/ptuValidation";
|
||||
import VectorStoreSelector from "./vector_store_management/VectorStoreSelector";
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { copyToClipboard as utilCopyToClipboard } from "../utils/dataUtils";
|
||||
import { isMaskedSecret, stripMaskedSecrets } from "../utils/maskedSecretUtils";
|
||||
import { formItemValidateJSON, truncateString } from "../utils/textUtils";
|
||||
import { stripMaskedSecrets } from "../utils/maskedSecretUtils";
|
||||
import { truncateString } from "../utils/textUtils";
|
||||
import AutoRouterConnectionTest from "./add_model/auto_router_connection_test";
|
||||
import { AutoRouterTestTarget, buildAutoRouterTestTargets } from "./add_model/build_auto_router_test_targets";
|
||||
import { normalizeTierModels, resolveComplexityDefaultModel } from "./add_model/complexity_router_tiers";
|
||||
|
|
@ -49,7 +34,6 @@ import {
|
|||
} from "./add_model/auto_router_strategies";
|
||||
import { canModifyModel } from "@/utils/modelPermissions";
|
||||
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
|
||||
import CacheControlSettings from "./add_model/cache_control_settings";
|
||||
import DeleteResourceModal from "./common_components/DeleteResourceModal";
|
||||
import EditAutoRouterModal from "./edit_auto_router/edit_auto_router_modal";
|
||||
import ReuseCredentialsModal from "./model_add/reuse_credentials";
|
||||
|
|
@ -68,7 +52,7 @@ import {
|
|||
} from "./networking";
|
||||
import { Logo } from "@/components/molecules/logo/Logo";
|
||||
import UpdateModelCredentialsModal from "./update_model_credentials_modal";
|
||||
import NumericalInput from "./shared/numerical_input";
|
||||
import ModelInfoEditForm, { type ModelEditFormValues, type TouchedPricingField } from "./ModelInfoEditForm";
|
||||
import { Tag } from "./tag_management/types";
|
||||
import { getDisplayModelName } from "./view_model/model_name_display";
|
||||
|
||||
|
|
@ -82,68 +66,6 @@ interface ModelInfoViewProps {
|
|||
modelAccessGroups: string[] | null;
|
||||
}
|
||||
|
||||
interface PtuEditField {
|
||||
name: string;
|
||||
label: string;
|
||||
input: "number" | "datetime";
|
||||
placeholder?: string;
|
||||
isCount?: boolean;
|
||||
isRate?: boolean;
|
||||
isStart?: boolean;
|
||||
pairedWith?: string;
|
||||
windowPeer?: string;
|
||||
bound?: "start" | "end";
|
||||
}
|
||||
|
||||
const PTU_EDIT_FIELDS: PtuEditField[] = [
|
||||
{
|
||||
name: PTU_COUNT_FIELD,
|
||||
label: "PTU Count",
|
||||
input: "number",
|
||||
placeholder: "e.g. 15",
|
||||
isCount: true,
|
||||
pairedWith: PTU_RATE_FIELD,
|
||||
},
|
||||
{
|
||||
name: PTU_RATE_FIELD,
|
||||
label: "Cost per PTU / Hour (USD)",
|
||||
input: "number",
|
||||
placeholder: "e.g. 2.00",
|
||||
isRate: true,
|
||||
pairedWith: PTU_COUNT_FIELD,
|
||||
},
|
||||
{
|
||||
name: PTU_START_FIELD,
|
||||
label: "PTU Effective From (UTC)",
|
||||
input: "datetime",
|
||||
isStart: true,
|
||||
windowPeer: PTU_END_FIELD,
|
||||
bound: "start",
|
||||
},
|
||||
{
|
||||
name: PTU_END_FIELD,
|
||||
label: "PTU Effective To (UTC)",
|
||||
input: "datetime",
|
||||
windowPeer: PTU_START_FIELD,
|
||||
bound: "end",
|
||||
},
|
||||
];
|
||||
|
||||
/** Per-1M-token rate for the first rate that is set, so a deliberate 0 seeds the form as 0. */
|
||||
const perMillionTokens = (...rates: (number | null | undefined)[]): number | null => {
|
||||
const rate = rates.find((candidate) => candidate != null);
|
||||
return rate == null ? null : rate * 1_000_000;
|
||||
};
|
||||
|
||||
const ptuFieldDependencies = ({ isStart, pairedWith, windowPeer }: PtuEditField): string[] | undefined => {
|
||||
const deps = [
|
||||
...(isStart ? [PTU_COUNT_FIELD] : []),
|
||||
...(pairedWith ? [pairedWith] : []),
|
||||
...(windowPeer ? [windowPeer] : []),
|
||||
];
|
||||
return deps.length ? deps : undefined;
|
||||
};
|
||||
|
||||
interface ComplexityRouterTierConfig {
|
||||
tiers?: {
|
||||
SIMPLE?: unknown;
|
||||
|
|
@ -209,14 +131,12 @@ export default function ModelInfoView({
|
|||
onModelUpdate,
|
||||
modelAccessGroups,
|
||||
}: ModelInfoViewProps) {
|
||||
const [form] = Form.useForm();
|
||||
const queryClient = useQueryClient();
|
||||
const [localModelData, setLocalModelData] = useState<any>(null);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
const [isCredentialModalOpen, setIsCredentialModalOpen] = useState(false);
|
||||
const [isUpdateCredentialsModalOpen, setIsUpdateCredentialsModalOpen] = useState(false);
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [existingCredential, setExistingCredential] = useState<CredentialItem | null>(null);
|
||||
|
|
@ -236,8 +156,6 @@ export default function ModelInfoView({
|
|||
const { data: modelHubData } = useModelHub();
|
||||
const { data: teams } = useTeams();
|
||||
const ptuCostAttributionEnabled = usePtuCostAttributionEnabled();
|
||||
const ptuCostRule = (field: string) =>
|
||||
ptuCostAttributionEnabled ? [ptuNoUsageCostRule(PTU_COUNT_FIELD, field)] : [];
|
||||
|
||||
// Transform the model data
|
||||
const getProviderFromModel = (model: string) => {
|
||||
|
|
@ -388,7 +306,10 @@ export default function ModelInfoView({
|
|||
toast.success("Credential stored successfully");
|
||||
};
|
||||
|
||||
const handleModelUpdate = async (values: any) => {
|
||||
const handleModelUpdate = async (
|
||||
values: ModelEditFormValues,
|
||||
isFieldTouched: (field: TouchedPricingField) => boolean,
|
||||
) => {
|
||||
try {
|
||||
if (!accessToken) return;
|
||||
setIsSaving(true);
|
||||
|
|
@ -404,8 +325,7 @@ export default function ModelInfoView({
|
|||
return;
|
||||
}
|
||||
|
||||
let updatedLitellmParams = {
|
||||
...values.litellm_params,
|
||||
let updatedLitellmParams: Record<string, any> = {
|
||||
...parsedExtraParams,
|
||||
model: values.litellm_model_name,
|
||||
api_base: values.api_base,
|
||||
|
|
@ -419,7 +339,7 @@ export default function ModelInfoView({
|
|||
tags: values.tags,
|
||||
};
|
||||
|
||||
if (form.isFieldTouched("input_cost")) {
|
||||
if (isFieldTouched("input_cost")) {
|
||||
if (values.input_cost !== undefined && values.input_cost !== null && values.input_cost !== "") {
|
||||
updatedLitellmParams.input_cost_per_token = Number(values.input_cost) / 1_000_000;
|
||||
} else {
|
||||
|
|
@ -427,7 +347,7 @@ export default function ModelInfoView({
|
|||
updatedLitellmParams.input_cost_per_token = null;
|
||||
}
|
||||
}
|
||||
if (form.isFieldTouched("output_cost")) {
|
||||
if (isFieldTouched("output_cost")) {
|
||||
if (values.output_cost !== undefined && values.output_cost !== null && values.output_cost !== "") {
|
||||
updatedLitellmParams.output_cost_per_token = Number(values.output_cost) / 1_000_000;
|
||||
} else {
|
||||
|
|
@ -439,10 +359,10 @@ export default function ModelInfoView({
|
|||
// - explicit value provided → use it
|
||||
// - field touched but empty → explicit null (signals backend to remove override)
|
||||
// - only input_cost touched → fall back to input_cost (guarded against null)
|
||||
if (form.isFieldTouched("cache_read_cost") || form.isFieldTouched("input_cost")) {
|
||||
if (isFieldTouched("cache_read_cost") || isFieldTouched("input_cost")) {
|
||||
if (values.cache_read_cost !== undefined && values.cache_read_cost !== null && values.cache_read_cost !== "") {
|
||||
updatedLitellmParams.cache_read_input_token_cost = Number(values.cache_read_cost) / 1_000_000;
|
||||
} else if (form.isFieldTouched("cache_read_cost")) {
|
||||
} else if (isFieldTouched("cache_read_cost")) {
|
||||
updatedLitellmParams.cache_read_input_token_cost = null;
|
||||
} else if (
|
||||
updatedLitellmParams.input_cost_per_token !== undefined &&
|
||||
|
|
@ -455,7 +375,7 @@ export default function ModelInfoView({
|
|||
// Cache Write Cost: explicit value if provided, else explicit null so the
|
||||
// backend removes the override and falls back to the model-level default.
|
||||
// Sending 0 here would persist a zero rate even when the user intended to unset it.
|
||||
if (form.isFieldTouched("cache_write_cost")) {
|
||||
if (isFieldTouched("cache_write_cost")) {
|
||||
if (
|
||||
values.cache_write_cost !== undefined &&
|
||||
values.cache_write_cost !== null &&
|
||||
|
|
@ -475,7 +395,7 @@ export default function ModelInfoView({
|
|||
if (values.guardrails) {
|
||||
updatedLitellmParams.guardrails = values.guardrails;
|
||||
}
|
||||
if (values.vector_store_ids?.length > 0) {
|
||||
if ((values.vector_store_ids?.length ?? 0) > 0) {
|
||||
updatedLitellmParams.vector_store_ids = values.vector_store_ids;
|
||||
} else if (values.vector_store_ids !== undefined) {
|
||||
// User explicitly cleared previously-set vector stores — send [] to clear on backend
|
||||
|
|
@ -485,7 +405,7 @@ export default function ModelInfoView({
|
|||
}
|
||||
|
||||
// Handle cache control settings
|
||||
if (values.cache_control && values.cache_control_injection_points?.length > 0) {
|
||||
if (values.cache_control && (values.cache_control_injection_points?.length ?? 0) > 0) {
|
||||
updatedLitellmParams.cache_control_injection_points = values.cache_control_injection_points;
|
||||
} else {
|
||||
delete updatedLitellmParams.cache_control_injection_points;
|
||||
|
|
@ -544,7 +464,6 @@ export default function ModelInfoView({
|
|||
}
|
||||
|
||||
toast.success("Model settings updated successfully");
|
||||
setIsDirty(false);
|
||||
setIsEditing(false);
|
||||
} catch (error) {
|
||||
console.error("Error updating model:", error);
|
||||
|
|
@ -667,6 +586,14 @@ export default function ModelInfoView({
|
|||
}
|
||||
};
|
||||
const isWildcardModel = modelData.litellm_model_name.includes("*");
|
||||
const wildcardProvider = modelData.litellm_model_name.split("/")[0];
|
||||
const healthCheckModelOptions =
|
||||
modelHubData?.data
|
||||
?.filter(
|
||||
(model: any) =>
|
||||
model.providers?.includes(wildcardProvider) && model.model_group !== modelData.litellm_model_name,
|
||||
)
|
||||
.map((model: any) => ({ value: model.model_group, label: model.model_group })) || [];
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
|
|
@ -832,723 +759,24 @@ export default function ModelInfoView({
|
|||
</div>
|
||||
</div>
|
||||
{localModelData ? (
|
||||
<Form
|
||||
form={form}
|
||||
onFinish={handleModelUpdate}
|
||||
initialValues={{
|
||||
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:
|
||||
localModelData.litellm_params?.cache_read_input_token_cost !== undefined &&
|
||||
localModelData.litellm_params?.cache_read_input_token_cost !== null
|
||||
? localModelData.litellm_params.cache_read_input_token_cost * 1_000_000
|
||||
: localModelData.model_info?.cache_read_input_token_cost !== undefined &&
|
||||
localModelData.model_info?.cache_read_input_token_cost !== null
|
||||
? localModelData.model_info.cache_read_input_token_cost * 1_000_000
|
||||
: null,
|
||||
cache_write_cost:
|
||||
localModelData.litellm_params?.cache_creation_input_token_cost !== undefined &&
|
||||
localModelData.litellm_params?.cache_creation_input_token_cost !== null
|
||||
? localModelData.litellm_params.cache_creation_input_token_cost * 1_000_000
|
||||
: localModelData.model_info?.cache_creation_input_token_cost !== undefined &&
|
||||
localModelData.model_info?.cache_creation_input_token_cost !== null
|
||||
? localModelData.model_info.cache_creation_input_token_cost * 1_000_000
|
||||
: null,
|
||||
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 : [],
|
||||
health_check_model: isWildcardModel ? localModelData.model_info?.health_check_model : null,
|
||||
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,
|
||||
),
|
||||
}}
|
||||
layout="vertical"
|
||||
onValuesChange={() => setIsDirty(true)}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Text className="font-medium">Model Name</Text>
|
||||
{isEditing ? (
|
||||
<Form.Item name="model_name" className="mb-0">
|
||||
<TextInput placeholder="Enter model name" />
|
||||
</Form.Item>
|
||||
) : (
|
||||
<div className="mt-1 p-2 bg-gray-50 rounded-sm">{localModelData.model_name}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text className="font-medium">LiteLLM Model Name</Text>
|
||||
{isEditing ? (
|
||||
<Form.Item name="litellm_model_name" className="mb-0">
|
||||
<TextInput placeholder="Enter LiteLLM model name" />
|
||||
</Form.Item>
|
||||
) : (
|
||||
<div className="mt-1 p-2 bg-gray-50 rounded-sm">{localModelData.litellm_model_name}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text className="font-medium">Input Cost (per 1M tokens)</Text>
|
||||
{isEditing ? (
|
||||
<Form.Item
|
||||
name="input_cost"
|
||||
className="mb-0"
|
||||
dependencies={[PTU_COUNT_FIELD]}
|
||||
rules={ptuCostRule("input_cost")}
|
||||
>
|
||||
<NumericalInput placeholder="Enter input cost" />
|
||||
</Form.Item>
|
||||
) : (
|
||||
<div className="mt-1 p-2 bg-gray-50 rounded-sm">
|
||||
{localModelData?.litellm_params?.input_cost_per_token != null
|
||||
? (localModelData.litellm_params.input_cost_per_token * 1_000_000).toFixed(4)
|
||||
: localModelData?.model_info?.input_cost_per_token != null
|
||||
? (localModelData.model_info.input_cost_per_token * 1_000_000).toFixed(4)
|
||||
: "Not Set"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text className="font-medium">Output Cost (per 1M tokens)</Text>
|
||||
{isEditing ? (
|
||||
<Form.Item
|
||||
name="output_cost"
|
||||
className="mb-0"
|
||||
dependencies={[PTU_COUNT_FIELD]}
|
||||
rules={ptuCostRule("output_cost")}
|
||||
>
|
||||
<NumericalInput placeholder="Enter output cost" />
|
||||
</Form.Item>
|
||||
) : (
|
||||
<div className="mt-1 p-2 bg-gray-50 rounded-sm">
|
||||
{localModelData?.litellm_params?.output_cost_per_token != null
|
||||
? (localModelData.litellm_params.output_cost_per_token * 1_000_000).toFixed(4)
|
||||
: localModelData?.model_info?.output_cost_per_token != null
|
||||
? (localModelData.model_info.output_cost_per_token * 1_000_000).toFixed(4)
|
||||
: "Not Set"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{ptuCostAttributionEnabled &&
|
||||
PTU_EDIT_FIELDS.map((ptuField) => {
|
||||
const { name, label, input, placeholder, isCount, isRate, isStart, pairedWith } = ptuField;
|
||||
const { windowPeer, bound } = ptuField;
|
||||
return (
|
||||
<div key={name}>
|
||||
<Text className="font-medium">{label}</Text>
|
||||
{isEditing ? (
|
||||
<Form.Item
|
||||
name={name}
|
||||
className="mb-0"
|
||||
dependencies={ptuFieldDependencies(ptuField)}
|
||||
rules={[
|
||||
...(isCount ? ptuCountRules : []),
|
||||
...(isRate ? ptuRateRules : []),
|
||||
...(isStart ? [ptuStartRequiredRule(PTU_COUNT_FIELD)] : []),
|
||||
...(pairedWith ? [ptuPairRule(pairedWith)] : []),
|
||||
...(windowPeer && bound ? [ptuWindowOrderRule(windowPeer, bound)] : []),
|
||||
]}
|
||||
>
|
||||
{input === "number" ? (
|
||||
<NumericalInput
|
||||
placeholder={placeholder}
|
||||
step={isCount ? 1 : undefined}
|
||||
min={isCount ? 1 : 0}
|
||||
/>
|
||||
) : (
|
||||
<DatePicker showTime style={{ width: "100%" }} />
|
||||
)}
|
||||
</Form.Item>
|
||||
) : (
|
||||
<div className="mt-1 p-2 bg-gray-50 rounded-sm">
|
||||
{(input === "datetime"
|
||||
? formatPtuUtcDisplay(localModelData?.model_info?.[name])
|
||||
: localModelData?.model_info?.[name]) ?? "Not Set"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div>
|
||||
<Text className="font-medium">Cache Read Cost (per 1M tokens)</Text>
|
||||
{isEditing ? (
|
||||
<Form.Item
|
||||
name="cache_read_cost"
|
||||
className="mb-0"
|
||||
dependencies={[PTU_COUNT_FIELD]}
|
||||
rules={ptuCostRule("cache_read_cost")}
|
||||
tooltip="If left blank on save, defaults to Input Cost."
|
||||
>
|
||||
<NumericalInput placeholder="Defaults to Input Cost if blank" />
|
||||
</Form.Item>
|
||||
) : (
|
||||
<div className="mt-1 p-2 bg-gray-50 rounded-sm">
|
||||
{localModelData?.litellm_params?.cache_read_input_token_cost !== undefined &&
|
||||
localModelData?.litellm_params?.cache_read_input_token_cost !== null
|
||||
? (localModelData.litellm_params.cache_read_input_token_cost * 1_000_000).toFixed(4)
|
||||
: localModelData?.model_info?.cache_read_input_token_cost !== undefined &&
|
||||
localModelData?.model_info?.cache_read_input_token_cost !== null
|
||||
? (localModelData.model_info.cache_read_input_token_cost * 1_000_000).toFixed(4)
|
||||
: "Not Set"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text className="font-medium">Cache Write Cost (per 1M tokens)</Text>
|
||||
{isEditing ? (
|
||||
<Form.Item
|
||||
name="cache_write_cost"
|
||||
className="mb-0"
|
||||
dependencies={[PTU_COUNT_FIELD]}
|
||||
rules={ptuCostRule("cache_write_cost")}
|
||||
tooltip="If left blank on save, defaults to Input Cost (backend falls back to input_cost_per_token)."
|
||||
>
|
||||
<NumericalInput placeholder="Defaults to Input Cost if blank" />
|
||||
</Form.Item>
|
||||
) : (
|
||||
<div className="mt-1 p-2 bg-gray-50 rounded-sm">
|
||||
{localModelData?.litellm_params?.cache_creation_input_token_cost !== undefined &&
|
||||
localModelData?.litellm_params?.cache_creation_input_token_cost !== null
|
||||
? (localModelData.litellm_params.cache_creation_input_token_cost * 1_000_000).toFixed(4)
|
||||
: localModelData?.model_info?.cache_creation_input_token_cost !== undefined &&
|
||||
localModelData?.model_info?.cache_creation_input_token_cost !== null
|
||||
? (localModelData.model_info.cache_creation_input_token_cost * 1_000_000).toFixed(4)
|
||||
: "Not Set"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text className="font-medium">API Base</Text>
|
||||
{isEditing ? (
|
||||
<Form.Item name="api_base" className="mb-0">
|
||||
<TextInput placeholder="Enter API base" />
|
||||
</Form.Item>
|
||||
) : (
|
||||
<div className="mt-1 p-2 bg-gray-50 rounded-sm">
|
||||
{localModelData.litellm_params?.api_base || "Not Set"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text className="font-medium">Custom LLM Provider</Text>
|
||||
{isEditing ? (
|
||||
<Form.Item name="custom_llm_provider" className="mb-0">
|
||||
<TextInput placeholder="Enter custom LLM provider" />
|
||||
</Form.Item>
|
||||
) : (
|
||||
<div className="mt-1 p-2 bg-gray-50 rounded-sm">
|
||||
{localModelData.litellm_params?.custom_llm_provider || "Not Set"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text className="font-medium">Organization</Text>
|
||||
{isEditing ? (
|
||||
<Form.Item name="organization" className="mb-0">
|
||||
<TextInput placeholder="Enter organization" />
|
||||
</Form.Item>
|
||||
) : (
|
||||
<div className="mt-1 p-2 bg-gray-50 rounded-sm">
|
||||
{localModelData.litellm_params?.organization || "Not Set"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text className="font-medium">TPM (Tokens per Minute)</Text>
|
||||
{isEditing ? (
|
||||
<Form.Item name="tpm" className="mb-0">
|
||||
<NumericalInput placeholder="Enter TPM" />
|
||||
</Form.Item>
|
||||
) : (
|
||||
<div className="mt-1 p-2 bg-gray-50 rounded-sm">
|
||||
{localModelData.litellm_params?.tpm || "Not Set"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text className="font-medium">RPM (Requests per Minute)</Text>
|
||||
{isEditing ? (
|
||||
<Form.Item name="rpm" className="mb-0">
|
||||
<NumericalInput placeholder="Enter RPM" />
|
||||
</Form.Item>
|
||||
) : (
|
||||
<div className="mt-1 p-2 bg-gray-50 rounded-sm">
|
||||
{localModelData.litellm_params?.rpm || "Not Set"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text className="font-medium">Max Retries</Text>
|
||||
{isEditing ? (
|
||||
<Form.Item name="max_retries" className="mb-0">
|
||||
<NumericalInput placeholder="Enter max retries" />
|
||||
</Form.Item>
|
||||
) : (
|
||||
<div className="mt-1 p-2 bg-gray-50 rounded-sm">
|
||||
{localModelData.litellm_params?.max_retries || "Not Set"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text className="font-medium">Timeout (seconds)</Text>
|
||||
{isEditing ? (
|
||||
<Form.Item name="timeout" className="mb-0">
|
||||
<NumericalInput placeholder="Enter timeout" />
|
||||
</Form.Item>
|
||||
) : (
|
||||
<div className="mt-1 p-2 bg-gray-50 rounded-sm">
|
||||
{localModelData.litellm_params?.timeout || "Not Set"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text className="font-medium">Stream Timeout (seconds)</Text>
|
||||
{isEditing ? (
|
||||
<Form.Item name="stream_timeout" className="mb-0">
|
||||
<NumericalInput placeholder="Enter stream timeout" />
|
||||
</Form.Item>
|
||||
) : (
|
||||
<div className="mt-1 p-2 bg-gray-50 rounded-sm">
|
||||
{localModelData.litellm_params?.stream_timeout || "Not Set"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text className="font-medium">Model Access Groups</Text>
|
||||
{isEditing ? (
|
||||
<Form.Item name="model_access_group" className="mb-0">
|
||||
<Select
|
||||
mode="tags"
|
||||
showSearch
|
||||
placeholder="Select existing groups or type to create new ones"
|
||||
optionFilterProp="children"
|
||||
tokenSeparators={[","]}
|
||||
maxTagCount="responsive"
|
||||
allowClear
|
||||
style={{ width: "100%" }}
|
||||
options={modelAccessGroups?.map((group) => ({
|
||||
value: group,
|
||||
label: group,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : (
|
||||
<div className="mt-1 p-2 bg-gray-50 rounded-sm">
|
||||
{localModelData.model_info?.access_groups ? (
|
||||
Array.isArray(localModelData.model_info.access_groups) ? (
|
||||
localModelData.model_info.access_groups.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{localModelData.model_info.access_groups.map((group: string, index: number) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800"
|
||||
>
|
||||
{group}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
"No groups assigned"
|
||||
)
|
||||
) : (
|
||||
localModelData.model_info.access_groups
|
||||
)
|
||||
) : (
|
||||
"Not Set"
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text className="font-medium">
|
||||
Guardrails
|
||||
<Tooltip title="Apply safety guardrails to this model to filter content or enforce policies">
|
||||
<a
|
||||
href="https://docs.litellm.ai/docs/proxy/guardrails/quick_start"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</a>
|
||||
</Tooltip>
|
||||
</Text>
|
||||
{isEditing ? (
|
||||
<Form.Item name="guardrails" className="mb-0">
|
||||
<Select
|
||||
mode="tags"
|
||||
showSearch
|
||||
placeholder="Select existing guardrails or type to create new ones"
|
||||
optionFilterProp="children"
|
||||
tokenSeparators={[","]}
|
||||
maxTagCount="responsive"
|
||||
allowClear
|
||||
style={{ width: "100%" }}
|
||||
options={guardrailsList.map((name) => ({
|
||||
value: name,
|
||||
label: name,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : (
|
||||
<div className="mt-1 p-2 bg-gray-50 rounded-sm">
|
||||
{localModelData.litellm_params?.guardrails ? (
|
||||
Array.isArray(localModelData.litellm_params.guardrails) ? (
|
||||
localModelData.litellm_params.guardrails.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{localModelData.litellm_params.guardrails.map(
|
||||
(guardrail: string, index: number) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800"
|
||||
>
|
||||
{guardrail}
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
"No guardrails assigned"
|
||||
)
|
||||
) : (
|
||||
localModelData.litellm_params.guardrails
|
||||
)
|
||||
) : (
|
||||
"Not Set"
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text className="font-medium">
|
||||
Attached Knowledge Bases (RAG)
|
||||
<Tooltip title="Vector stores used for RAG. Every request to this model will automatically retrieve context from these knowledge bases.">
|
||||
<a
|
||||
href="https://docs.litellm.ai/docs/completion/knowledgebase"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</a>
|
||||
</Tooltip>
|
||||
</Text>
|
||||
{isEditing ? (
|
||||
<Form.Item name="vector_store_ids" className="mb-0">
|
||||
<VectorStoreSelector
|
||||
onChange={() => {}}
|
||||
accessToken={accessToken || ""}
|
||||
placeholder="Select knowledge bases (optional)"
|
||||
/>
|
||||
</Form.Item>
|
||||
) : (
|
||||
<div className="mt-1 p-2 bg-gray-50 rounded-sm">
|
||||
{localModelData.litellm_params?.vector_store_ids ? (
|
||||
Array.isArray(localModelData.litellm_params.vector_store_ids) ? (
|
||||
localModelData.litellm_params.vector_store_ids.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{localModelData.litellm_params.vector_store_ids.map(
|
||||
(vsId: string, index: number) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800"
|
||||
>
|
||||
{vsId}
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
"No knowledge bases attached"
|
||||
)
|
||||
) : (
|
||||
String(localModelData.litellm_params.vector_store_ids)
|
||||
)
|
||||
) : (
|
||||
"Not Set"
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text className="font-medium">Tags</Text>
|
||||
{isEditing ? (
|
||||
<Form.Item name="tags" className="mb-0">
|
||||
<Select
|
||||
mode="tags"
|
||||
showSearch
|
||||
placeholder="Select existing tags or type to create new ones"
|
||||
optionFilterProp="children"
|
||||
tokenSeparators={[","]}
|
||||
maxTagCount="responsive"
|
||||
allowClear
|
||||
style={{ width: "100%" }}
|
||||
options={Object.values(tagsList).map((tag: Tag) => ({
|
||||
value: tag.name,
|
||||
label: tag.name,
|
||||
title: tag.description || tag.name,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : (
|
||||
<div className="mt-1 p-2 bg-gray-50 rounded-sm">
|
||||
{localModelData.litellm_params?.tags ? (
|
||||
Array.isArray(localModelData.litellm_params.tags) ? (
|
||||
localModelData.litellm_params.tags.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{localModelData.litellm_params.tags.map((tag: string, index: number) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-purple-100 text-purple-800"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
"No tags assigned"
|
||||
)
|
||||
) : (
|
||||
localModelData.litellm_params.tags
|
||||
)
|
||||
) : (
|
||||
"Not Set"
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Text className="font-medium">Existing Credentials</Text>
|
||||
{isEditing ? (
|
||||
<Form.Item name="litellm_credential_name" className="mb-0">
|
||||
<Select
|
||||
showSearch
|
||||
placeholder="Select or search for existing credentials"
|
||||
optionFilterProp="children"
|
||||
filterOption={(input, option) =>
|
||||
(option?.label ?? "").toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
options={[
|
||||
{ value: "", label: "None" },
|
||||
...credentialsList.map((credential) => ({
|
||||
value: credential.credential_name,
|
||||
label: credential.credential_name,
|
||||
})),
|
||||
]}
|
||||
allowClear
|
||||
/>
|
||||
</Form.Item>
|
||||
) : (
|
||||
<div className="mt-1 p-2 bg-gray-50 rounded-sm">
|
||||
{localModelData.litellm_params?.litellm_credential_name || "Manual"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isWildcardModel && (
|
||||
<div>
|
||||
<Text className="font-medium">Health Check Model</Text>
|
||||
{isEditing ? (
|
||||
<Form.Item name="health_check_model" className="mb-0">
|
||||
<Select
|
||||
showSearch
|
||||
placeholder="Select existing health check model"
|
||||
optionFilterProp="children"
|
||||
allowClear
|
||||
options={(() => {
|
||||
const wildcardProvider = modelData.litellm_model_name.split("/")[0];
|
||||
return (
|
||||
modelHubData?.data
|
||||
?.filter((model: any) => {
|
||||
// Filter by provider to match the wildcard provider
|
||||
return (
|
||||
model.providers?.includes(wildcardProvider) &&
|
||||
model.model_group !== modelData.litellm_model_name
|
||||
);
|
||||
})
|
||||
.map((model: any) => ({
|
||||
value: model.model_group,
|
||||
label: model.model_group,
|
||||
})) || []
|
||||
);
|
||||
})()}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : (
|
||||
<div className="mt-1 p-2 bg-gray-50 rounded-sm">
|
||||
{localModelData.model_info?.health_check_model || "Not Set"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cache Control Section */}
|
||||
{isEditing ? (
|
||||
<CacheControlSettings
|
||||
form={form}
|
||||
showCacheControl={showCacheControl}
|
||||
onCacheControlChange={(checked) => setShowCacheControl(checked)}
|
||||
/>
|
||||
) : (
|
||||
<div>
|
||||
<Text className="font-medium">Cache Control</Text>
|
||||
<div className="mt-1 p-2 bg-gray-50 rounded-sm">
|
||||
{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="text-sm text-gray-600 mb-1">
|
||||
Location: {point.location},{point.role && <span> Role: {point.role}</span>}
|
||||
{point.index !== undefined && <span> Index: {point.index}</span>}
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
"Disabled"
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Text className="font-medium">Model Info</Text>
|
||||
{isEditing ? (
|
||||
<Form.Item name="model_info" className="mb-0">
|
||||
<Input.TextArea
|
||||
rows={4}
|
||||
placeholder='{"gpt-4": 100, "claude-v1": 200}'
|
||||
defaultValue={JSON.stringify(modelData.model_info, null, 2)}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : (
|
||||
<div className="mt-1 p-2 bg-gray-50 rounded-sm">
|
||||
<pre className="bg-gray-100 p-2 rounded-sm text-xs overflow-auto mt-1">
|
||||
{JSON.stringify(localModelData.model_info, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Text className="font-medium">
|
||||
LiteLLM Params
|
||||
<Tooltip title="Optional litellm params used for making a litellm.completion() call. Some params are automatically added by LiteLLM.">
|
||||
<a
|
||||
href="https://docs.litellm.ai/docs/completion/input"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</a>
|
||||
</Tooltip>
|
||||
</Text>
|
||||
{isEditing ? (
|
||||
<Form.Item name="litellm_extra_params" rules={[{ validator: formItemValidateJSON }]}>
|
||||
<Input.TextArea
|
||||
rows={4}
|
||||
placeholder='{
|
||||
"rpm": 100,
|
||||
"timeout": 0,
|
||||
"stream_timeout": 0
|
||||
}'
|
||||
/>
|
||||
</Form.Item>
|
||||
) : (
|
||||
<div className="mt-1 p-2 bg-gray-50 rounded-sm">
|
||||
<pre className="bg-gray-100 p-2 rounded-sm text-xs overflow-auto mt-1">
|
||||
{JSON.stringify(localModelData.litellm_params, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Text className="font-medium">Team ID</Text>
|
||||
<div className="mt-1 p-2 bg-gray-50 rounded-sm">
|
||||
{modelData.model_info.team_id || "Not Set"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isEditing && (
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<TremorButton
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
form.resetFields();
|
||||
setIsDirty(false);
|
||||
setIsEditing(false);
|
||||
}}
|
||||
disabled={isSaving}
|
||||
>
|
||||
Cancel
|
||||
</TremorButton>
|
||||
<TremorButton variant="primary" onClick={() => form.submit()} loading={isSaving}>
|
||||
Save Changes
|
||||
</TremorButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Form>
|
||||
<ModelInfoEditForm
|
||||
localModelData={localModelData}
|
||||
modelData={modelData}
|
||||
accessToken={accessToken}
|
||||
isEditing={isEditing}
|
||||
isSaving={isSaving}
|
||||
isWildcardModel={isWildcardModel}
|
||||
ptuCostAttributionEnabled={ptuCostAttributionEnabled}
|
||||
showCacheControl={showCacheControl}
|
||||
setShowCacheControl={setShowCacheControl}
|
||||
onCancel={() => setIsEditing(false)}
|
||||
onSubmit={handleModelUpdate}
|
||||
modelAccessGroups={modelAccessGroups}
|
||||
guardrailsList={guardrailsList}
|
||||
tagsList={tagsList}
|
||||
credentialsList={credentialsList}
|
||||
healthCheckModelOptions={healthCheckModelOptions}
|
||||
/>
|
||||
) : (
|
||||
<Text>Loading...</Text>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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