This commit is contained in:
devin-ai-integration[bot] 2026-08-26 21:05:45 -04:00 committed by GitHub
commit 40fa3e2cd0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 227 additions and 42 deletions

View file

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

View file

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

View file

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

View file

@ -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 () => {

View file

@ -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"] : []),
]}
/>