mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
Merge e7322cc9bd into 1df25e26cf
This commit is contained in:
commit
40fa3e2cd0
5 changed files with 227 additions and 42 deletions
|
|
@ -164,10 +164,10 @@ export const MountedFormField: React.FC<MountedFormFieldProps> = ({
|
|||
<Field data-invalid={invalid || undefined} className={className}>
|
||||
{label !== undefined && <FieldLabel htmlFor={path}>{label}</FieldLabel>}
|
||||
{children(controlProps)}
|
||||
{hasHelp ? (
|
||||
<FieldDescription id={helpId}>{help}</FieldDescription>
|
||||
) : (
|
||||
{invalid ? (
|
||||
<FieldError id={helpId} errors={[fieldState.error]} />
|
||||
) : (
|
||||
hasHelp && <FieldDescription id={helpId}>{help}</FieldDescription>
|
||||
)}
|
||||
</Field>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,20 +2,27 @@ import React, { useState, useEffect } from "react";
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Info } from "lucide-react";
|
||||
import { SimpleTooltip } from "@/components/ui/tooltip";
|
||||
import type { UseFormSetValue } from "react-hook-form";
|
||||
import { getOpenAPISchema } from "../networking";
|
||||
import { formatLabel } from "@/utils/textUtils";
|
||||
import { MultiSelect } from "@/components/shared/MultiSelect";
|
||||
import { MountedFormField, type MountedFormValues } from "./MountedFormField";
|
||||
|
||||
interface SchemaProperty {
|
||||
interface SchemaVariant {
|
||||
type?: string;
|
||||
enum?: string[];
|
||||
items?: { type?: string; $ref?: string };
|
||||
$ref?: string;
|
||||
format?: string;
|
||||
}
|
||||
|
||||
interface SchemaProperty extends SchemaVariant {
|
||||
title?: string;
|
||||
description?: string;
|
||||
anyOf?: Array<{ type: string }>;
|
||||
enum?: string[];
|
||||
format?: string;
|
||||
anyOf?: SchemaVariant[];
|
||||
}
|
||||
|
||||
interface OpenAPISchema {
|
||||
|
|
@ -38,13 +45,38 @@ interface SchemaFormFieldsProps {
|
|||
}
|
||||
|
||||
// Define which fields should be parsed as JSON
|
||||
export const jsonFields = ["metadata", "config", "enforced_params", "aliases"];
|
||||
export const jsonFields = [
|
||||
"metadata",
|
||||
"config",
|
||||
"aliases",
|
||||
"permissions",
|
||||
"model_rpm_limit",
|
||||
"model_tpm_limit",
|
||||
"mcp_rpm_limit",
|
||||
"default_estimated_output_tokens_per_model",
|
||||
"allowed_vector_store_indexes",
|
||||
];
|
||||
|
||||
const resolveVariant = (property: SchemaProperty): SchemaVariant => {
|
||||
if (property.type || !property.anyOf) return property;
|
||||
return (
|
||||
property.anyOf.find((variant) => variant.type !== undefined && variant.type !== "null") ??
|
||||
property.anyOf.find((variant) => variant.$ref !== undefined) ??
|
||||
property
|
||||
);
|
||||
};
|
||||
|
||||
// Helper function to determine if a field should be treated as JSON
|
||||
const isJSONField = (key: string, property: SchemaProperty): boolean => {
|
||||
return jsonFields.includes(key) || property.format === "json";
|
||||
if (jsonFields.includes(key) || property.format === "json") return true;
|
||||
const variant = resolveVariant(property);
|
||||
if (variant.type === "object" || (variant.type === undefined && variant.$ref !== undefined)) return true;
|
||||
return variant.type === "array" && (variant.items?.$ref !== undefined || variant.items?.type === "object");
|
||||
};
|
||||
|
||||
const isStringListField = (key: string, property: SchemaProperty): boolean =>
|
||||
!isJSONField(key, property) && resolveVariant(property).type === "array";
|
||||
|
||||
// Helper function to validate JSON input
|
||||
const validateJSON = (value: string): boolean => {
|
||||
if (!value) return true;
|
||||
|
|
@ -67,6 +99,53 @@ const toSchemaNumber = (raw: string, isInteger: boolean): number | null => {
|
|||
|
||||
const messageOf = (error: unknown): string => (error instanceof Error ? error.message : String(error));
|
||||
|
||||
const fieldLabels: { [key: string]: string } = {
|
||||
key: "Custom Key",
|
||||
budget_id: "Budget ID",
|
||||
model_rpm_limit: "Model RPM Limits",
|
||||
model_tpm_limit: "Model TPM Limits",
|
||||
mcp_rpm_limit: "MCP Server RPM Limits",
|
||||
};
|
||||
|
||||
const fieldTooltips: { [key: string]: string } = {
|
||||
key: "Bring your own key value instead of an auto-generated one. Must start with 'sk-' and be at least 16 characters",
|
||||
budget_id: "Attach an existing budget (created via /budget/new) to this key",
|
||||
soft_budget: "Spend threshold that triggers an alert without blocking the key",
|
||||
spend: "Starting spend in USD recorded for this key. Counts toward its budget",
|
||||
send_invite_email: "Send an invite email to this key's user",
|
||||
max_parallel_requests: "Maximum number of concurrent requests. Requests beyond this limit receive a 429 error",
|
||||
allowed_cache_controls: "Cache control values requests with this key may use, e.g. no-cache, no-store",
|
||||
config: "Key-specific configuration that overrides values in config.yaml",
|
||||
permissions: 'Key-specific permissions, e.g. {"allow_pii_controls": true}',
|
||||
model_rpm_limit: "Requests-per-minute limit per model",
|
||||
model_tpm_limit: "Tokens-per-minute limit per model",
|
||||
mcp_rpm_limit: "Requests-per-minute limit per MCP server, keyed by server name",
|
||||
default_estimated_output_tokens:
|
||||
"Output tokens reserved for TPM limiting when a request omits max_tokens (proxy admin only)",
|
||||
default_estimated_output_tokens_per_model:
|
||||
"Per-model override of the default estimated output tokens (proxy admin only)",
|
||||
blocked: "Block this key from making any requests",
|
||||
enforced_params: "Request parameters every call made with this key must include (Enterprise)",
|
||||
allowed_routes: "Proxy routes this key may call. Supports wildcards, e.g. /keys/*",
|
||||
allowed_vector_store_indexes: "Vector store indexes this key may access, with per-index permissions",
|
||||
};
|
||||
|
||||
const jsonPlaceholders: { [key: string]: string } = {
|
||||
metadata: '{"team": "research"}',
|
||||
config: '{"setting": "value"}',
|
||||
aliases: '{"my-alias": "gpt-4o"}',
|
||||
permissions: '{"allow_pii_controls": true}',
|
||||
model_rpm_limit: '{"gpt-4o": 100}',
|
||||
model_tpm_limit: '{"gpt-4o": 100000}',
|
||||
mcp_rpm_limit: '{"github": 100}',
|
||||
default_estimated_output_tokens_per_model: '{"gpt-4o": 4096}',
|
||||
allowed_vector_store_indexes: '[{"index_name": "my-index", "index_permissions": ["read"]}]',
|
||||
};
|
||||
|
||||
const textPlaceholders: { [key: string]: string } = {
|
||||
key: "sk-...",
|
||||
};
|
||||
|
||||
const getFieldHelp = (key: string, property: SchemaProperty, type: string): string => {
|
||||
// Default help text based on type
|
||||
const defaultHelp =
|
||||
|
|
@ -74,21 +153,34 @@ const getFieldHelp = (key: string, property: SchemaProperty, type: string): stri
|
|||
string: "Text input",
|
||||
number: "Numeric input",
|
||||
integer: "Whole number input",
|
||||
boolean: "True/False value",
|
||||
boolean: "Toggle on/off",
|
||||
array: "Press Enter to add each value",
|
||||
}[type] || "Text input";
|
||||
|
||||
// Specific field help text
|
||||
const specificHelp: { [key: string]: string } = {
|
||||
max_budget: "Enter maximum budget in USD (e.g., 100.50)",
|
||||
soft_budget: "Enter alert threshold in USD (e.g., 50)",
|
||||
spend: "Enter starting spend in USD (e.g., 0)",
|
||||
budget_duration: "Select a time period for budget reset",
|
||||
budget_id: "Enter the id of an existing budget",
|
||||
tpm_limit: "Enter maximum tokens per minute (whole number)",
|
||||
rpm_limit: "Enter maximum requests per minute (whole number)",
|
||||
max_parallel_requests: "Enter maximum concurrent requests (whole number)",
|
||||
duration: "Enter duration (e.g., 30s, 24h, 7d)",
|
||||
key: "Must start with 'sk-' and be at least 16 characters",
|
||||
metadata: 'Enter JSON object with key-value pairs\nExample: {"team": "research", "project": "nlp"}',
|
||||
config: 'Enter configuration as JSON object\nExample: {"setting": "value"}',
|
||||
permissions: "Enter comma-separated permission strings",
|
||||
enforced_params: 'Enter parameters as JSON object\nExample: {"param": "value"}',
|
||||
blocked: "Enter true/false or specific block conditions",
|
||||
permissions: 'Enter permissions as JSON object\nExample: {"allow_pii_controls": true}',
|
||||
enforced_params: "Press Enter to add each required parameter (e.g., user, metadata.generation_name)",
|
||||
allowed_cache_controls: "Press Enter to add each cache control value (e.g., no-cache, no-store)",
|
||||
allowed_routes: "Press Enter to add each route or wildcard pattern (e.g., /chat/completions, /keys/*)",
|
||||
model_rpm_limit: 'Enter JSON mapping model to requests per minute\nExample: {"gpt-4o": 100}',
|
||||
model_tpm_limit: 'Enter JSON mapping model to tokens per minute\nExample: {"gpt-4o": 100000}',
|
||||
mcp_rpm_limit: 'Enter JSON mapping MCP server to requests per minute\nExample: {"github": 100}',
|
||||
default_estimated_output_tokens_per_model: 'Enter JSON mapping model to tokens\nExample: {"gpt-4o": 4096}',
|
||||
allowed_vector_store_indexes:
|
||||
'Enter JSON array of indexes\nExample: [{"index_name": "my-index", "index_permissions": ["read"]}]',
|
||||
aliases: 'Enter aliases as JSON object\nExample: {"alias1": "value1", "alias2": "value2"}',
|
||||
models: "Select one or more model names",
|
||||
key_alias: "Enter a unique identifier for this key",
|
||||
|
|
@ -103,8 +195,9 @@ const getFieldHelp = (key: string, property: SchemaProperty, type: string): stri
|
|||
return `${helpText}\nMust be valid JSON format`;
|
||||
}
|
||||
|
||||
if (property.enum) {
|
||||
return `Select from available options\nAllowed values: ${property.enum.join(", ")}`;
|
||||
const enumValues = property.enum ?? resolveVariant(property).enum;
|
||||
if (enumValues) {
|
||||
return `Select from available options\nAllowed values: ${enumValues.join(", ")}`;
|
||||
}
|
||||
|
||||
return helpText;
|
||||
|
|
@ -148,24 +241,14 @@ const SchemaFormFields: React.FC<SchemaFormFieldsProps> = ({
|
|||
fetchOpenAPISchema();
|
||||
}, [schemaComponent, setValue, excludedFields]);
|
||||
|
||||
const getPropertyType = (property: SchemaProperty): string => {
|
||||
if (property.type) {
|
||||
return property.type;
|
||||
}
|
||||
if (property.anyOf) {
|
||||
const types = property.anyOf.map((t) => t.type);
|
||||
if (types.includes("number") || types.includes("integer")) return "number";
|
||||
if (types.includes("string")) return "string";
|
||||
}
|
||||
return "string";
|
||||
};
|
||||
const getPropertyType = (property: SchemaProperty): string => resolveVariant(property).type ?? "string";
|
||||
|
||||
const renderFormItem = (key: string, property: SchemaProperty) => {
|
||||
const type = getPropertyType(property);
|
||||
const isRequired = schemaProperties?.required?.includes(key);
|
||||
|
||||
const label = overrideLabels[key] || property.title || formatLabel(key);
|
||||
const tooltip = overrideTooltips[key] || property.description;
|
||||
const label = overrideLabels[key] || fieldLabels[key] || property.title || formatLabel(key);
|
||||
const tooltip = overrideTooltips[key] || property.description || fieldTooltips[key];
|
||||
|
||||
const validate = {
|
||||
...(isRequired && {
|
||||
|
|
@ -207,21 +290,52 @@ const SchemaFormFields: React.FC<SchemaFormFieldsProps> = ({
|
|||
required={isRequired}
|
||||
rules={Object.keys(validate).length > 0 ? { validate } : undefined}
|
||||
defaultValue={defaultValues[key]}
|
||||
help={<div className="text-xs text-muted-foreground">{getFieldHelp(key, property, type)}</div>}
|
||||
help={
|
||||
<span className="block text-xs whitespace-pre-line text-muted-foreground">
|
||||
{getFieldHelp(key, property, type)}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{(control) => {
|
||||
if (isJSONField(key, property)) {
|
||||
return (
|
||||
<Textarea
|
||||
{...control}
|
||||
value={control.value as string | undefined}
|
||||
value={(control.value as string | undefined) ?? ""}
|
||||
onChange={(event) => control.onChange(event.target.value === "" ? undefined : event.target.value)}
|
||||
rows={4}
|
||||
placeholder="Enter as JSON"
|
||||
placeholder={jsonPlaceholders[key] ?? "Enter as JSON"}
|
||||
className="font-mono"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (property.enum) {
|
||||
if (isStringListField(key, property)) {
|
||||
return (
|
||||
<MultiSelect
|
||||
id={control.id}
|
||||
options={[]}
|
||||
value={(control.value as string[] | undefined) ?? []}
|
||||
onValueChange={(next) => control.onChange(next.length > 0 ? next : undefined)}
|
||||
placeholder="Type a value and press Enter"
|
||||
allowCustomValues
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (type === "boolean") {
|
||||
return (
|
||||
<Switch
|
||||
id={control.id}
|
||||
name={control.name}
|
||||
checked={control.value === true}
|
||||
onCheckedChange={(checked) => control.onChange(checked)}
|
||||
aria-required={control["aria-required"]}
|
||||
aria-invalid={control["aria-invalid"]}
|
||||
aria-describedby={control["aria-describedby"]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const enumValues = property.enum ?? resolveVariant(property).enum;
|
||||
if (enumValues) {
|
||||
return (
|
||||
<Select value={(control.value as string | undefined) ?? null} onValueChange={control.onChange}>
|
||||
<SelectTrigger
|
||||
|
|
@ -233,7 +347,7 @@ const SchemaFormFields: React.FC<SchemaFormFieldsProps> = ({
|
|||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{property.enum.map((value) => (
|
||||
{enumValues.map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{value}
|
||||
</SelectItem>
|
||||
|
|
@ -259,7 +373,13 @@ const SchemaFormFields: React.FC<SchemaFormFieldsProps> = ({
|
|||
<Input {...control} value={(control.value as string | undefined) ?? ""} placeholder="eg: 30s, 30h, 30d" />
|
||||
);
|
||||
}
|
||||
return <Input {...control} value={(control.value as string | undefined) ?? ""} placeholder={tooltip || ""} />;
|
||||
return (
|
||||
<Input
|
||||
{...control}
|
||||
value={(control.value as string | undefined) ?? ""}
|
||||
placeholder={textPlaceholders[key] ?? ""}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</MountedFormField>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -743,7 +743,7 @@ export const keyCreateServiceAccountCall = async (
|
|||
}
|
||||
// Parse JSON fields if they exist
|
||||
for (const field of jsonFields) {
|
||||
if (formValues[field]) {
|
||||
if (typeof formValues[field] === "string" && formValues[field]) {
|
||||
// if there's an exception JSON.parse, show it in the message
|
||||
try {
|
||||
formValues[field] = JSON.parse(formValues[field]);
|
||||
|
|
@ -801,7 +801,7 @@ export const keyCreateCall = async (
|
|||
}
|
||||
// Parse JSON fields if they exist
|
||||
for (const field of jsonFields) {
|
||||
if (formValues[field]) {
|
||||
if (typeof formValues[field] === "string" && formValues[field]) {
|
||||
// if there's an exception JSON.parse, show it in the message
|
||||
try {
|
||||
formValues[field] = JSON.parse(formValues[field]);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, renderWithProviders, screen, testQueryClient, waitFor } from "../../../tests/test-utils";
|
||||
import { act, fireEvent, renderWithProviders, screen, testQueryClient, waitFor } from "../../../tests/test-utils";
|
||||
import type { Team } from "../key_team_helpers/key_list";
|
||||
import { keyCreateCall, keyCreateServiceAccountCall, modelAvailableCall, userFilterUICall } from "../networking";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
|
@ -98,8 +98,11 @@ const OPENAPI_SCHEMA = {
|
|||
properties: {
|
||||
key: { type: "string", title: "Key" },
|
||||
soft_budget: { type: "number", title: "Soft Budget" },
|
||||
blocked: { type: "boolean", title: "Blocked" },
|
||||
spend: { anyOf: [{ type: "number" }, { type: "null" }], title: "Spend" },
|
||||
blocked: { anyOf: [{ type: "boolean" }, { type: "null" }], title: "Blocked" },
|
||||
max_budget: { type: "number", title: "Max Budget" },
|
||||
allowed_cache_controls: { anyOf: [{ items: {}, type: "array" }, { type: "null" }] },
|
||||
model_rpm_limit: { anyOf: [{ type: "object" }, { type: "null" }] },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -168,7 +171,14 @@ const SECTION_PAYLOAD_ADDITIONS: Record<keyof typeof SECTIONS, Record<string, un
|
|||
router: { router_settings: ROUTER_SETTINGS_DEFAULT },
|
||||
aliases: {},
|
||||
lifecycle: {},
|
||||
advanced: { key: undefined, soft_budget: undefined, blocked: undefined },
|
||||
advanced: {
|
||||
key: undefined,
|
||||
soft_budget: undefined,
|
||||
spend: undefined,
|
||||
blocked: undefined,
|
||||
allowed_cache_controls: undefined,
|
||||
model_rpm_limit: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
const ALL_OPEN_PAYLOAD = {
|
||||
|
|
@ -472,13 +482,46 @@ describe("CreateKey", () => {
|
|||
await openSection(/Optional Settings/i);
|
||||
await openSection(SECTIONS.advanced);
|
||||
await userEvent.type(await screen.findByLabelText("Soft Budget"), "12");
|
||||
await userEvent.type(await screen.findByLabelText("Spend"), "5");
|
||||
await submit();
|
||||
|
||||
const payload = await createdPayload();
|
||||
expect(payload.soft_budget).toBe(12);
|
||||
expect(payload.spend).toBe(5);
|
||||
expect(payload).toHaveProperty("key");
|
||||
});
|
||||
|
||||
it("routes the Advanced Settings boolean switch, tags input, and JSON textarea into their payload keys", async () => {
|
||||
await openModal();
|
||||
await nameTheKey();
|
||||
await openSection(/Optional Settings/i);
|
||||
await openSection(SECTIONS.advanced);
|
||||
|
||||
await userEvent.click(await screen.findByRole("switch", { name: /Blocked/ }));
|
||||
await userEvent.type(await screen.findByLabelText(/Allowed Cache Controls/), "no-cache");
|
||||
await userEvent.click(await screen.findByText('Create "no-cache"'));
|
||||
fireEvent.change(await screen.findByLabelText(/Model RPM Limits/), { target: { value: '{"gpt-4o": 100}' } });
|
||||
await submit();
|
||||
|
||||
const payload = await createdPayload();
|
||||
expect(payload.blocked).toBe(true);
|
||||
expect(payload.allowed_cache_controls).toStrictEqual(["no-cache"]);
|
||||
expect(payload.model_rpm_limit).toBe('{"gpt-4o": 100}');
|
||||
});
|
||||
|
||||
it("rejects invalid JSON typed into an Advanced Settings JSON field instead of submitting", async () => {
|
||||
await openModal();
|
||||
await nameTheKey();
|
||||
await openSection(/Optional Settings/i);
|
||||
await openSection(SECTIONS.advanced);
|
||||
|
||||
fireEvent.change(await screen.findByLabelText(/Model RPM Limits/), { target: { value: "not json" } });
|
||||
await submit();
|
||||
|
||||
expect(await screen.findByText("Please enter valid JSON")).toBeInTheDocument();
|
||||
expect(vi.mocked(keyCreateCall)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("drops the schema-driven custom key field when the proxy disables custom API keys", async () => {
|
||||
state.uiSettings = { disable_custom_api_keys: true };
|
||||
await openModal();
|
||||
|
|
@ -603,15 +646,15 @@ describe("CreateKey", () => {
|
|||
expect(vi.mocked(keyCreateCall)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("suppresses the required message behind the always-visible help text", async () => {
|
||||
it("replaces the help text with the required message while the field is invalid", async () => {
|
||||
await openModal();
|
||||
await submit();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/Key Name/)).toHaveAttribute("aria-invalid", "true");
|
||||
});
|
||||
expect(screen.queryByText("Please input a key name")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("required")).toBeInTheDocument();
|
||||
expect(screen.getByText("Please input a key name")).toBeInTheDocument();
|
||||
expect(screen.queryByText("required")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("blocks a service account submit until a team is chosen, then lets it through", async () => {
|
||||
|
|
|
|||
|
|
@ -1760,6 +1760,28 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
"budget_duration",
|
||||
"tpm_limit",
|
||||
"rpm_limit",
|
||||
"user_id",
|
||||
"agent_id",
|
||||
"project_id",
|
||||
"key_type",
|
||||
"tpm_limit_type",
|
||||
"rpm_limit_type",
|
||||
"throttle_on_budget_exceeded",
|
||||
"enable_prompt_caching",
|
||||
"disable_global_guardrails",
|
||||
"policies",
|
||||
"prompts",
|
||||
"access_group_ids",
|
||||
"allowed_passthrough_routes",
|
||||
"object_permission",
|
||||
"aliases",
|
||||
"router_settings",
|
||||
"budget_limits",
|
||||
"model_max_budget",
|
||||
"budget_fallbacks",
|
||||
"tag_rpm_limit",
|
||||
"auto_rotate",
|
||||
"rotation_interval",
|
||||
...(disableCustomApiKeys ? ["key"] : []),
|
||||
]}
|
||||
/>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue