refactor(ui): move remaining keys, teams and agents text onto i18n (#50238)

Constants and option lists on the keys, teams, agents, budgets, cost
tracking, router settings and vector store pages now hold message keys,
and the code that renders them translates. Values, ids, sort keys and
payloads are unchanged, and lookups keyed by server values check
Object.hasOwn first

Five tests now resolve the expected English through i18n.t instead of
reading the constant directly
This commit is contained in:
yuneng-jiang 2026-11-05 12:42:48 -08:00 • committed by GitHub
parent 4cd169f3bd
commit 69ffe4279e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 553 additions and 273 deletions

View file

@ -15,11 +15,12 @@ import {
export interface FieldConfig {
name: string;
label: string;
labelKey: ParseKeys;
type: "text" | "textarea" | "url" | "switch" | "list" | "select";
required?: boolean;
tooltip?: string;
tooltipKey?: ParseKeys;
placeholder?: string;
placeholderKey?: ParseKeys;
defaultValue?: any;
rows?: number;
validation?: any[];
@ -50,42 +51,41 @@ export const AGENT_FORM_CONFIG: {
fields: [
{
name: "name",
label: "Display Name",
labelKey: "agents.formConfig.fields.name.label",
type: "text",
required: true,
placeholder: "e.g., Customer Support Agent",
placeholderKey: "agents.formConfig.fields.name.placeholder",
},
{
name: "description",
label: "Description",
labelKey: "agents.formConfig.fields.description.label",
type: "textarea",
required: false,
placeholder: "Describe what this agent does...",
placeholderKey: "agents.formConfig.fields.description.placeholder",
rows: 3,
},
{
name: "url",
label: "URL",
labelKey: "agents.formConfig.fields.url.label",
type: "url",
required: false,
placeholder: "http://localhost:9999/",
tooltip: "Base URL where the agent is hosted (optional)",
tooltipKey: "agents.formConfig.fields.url.tooltip",
},
{
name: "version",
label: "Version",
labelKey: "agents.formConfig.fields.version.label",
type: "text",
placeholder: "1.0.0",
defaultValue: "1.0.0",
},
{
name: "protocolVersion",
label: "Protocol Version",
labelKey: "agents.formConfig.fields.protocolVersion.label",
type: "select",
options: ["1.0", "0.3"],
defaultValue: "1.0",
tooltip:
"The A2A protocol version LiteLLM serves to clients for this agent. LiteLLM converts the upstream agent's responses to this version, so clients always see the version you pick here regardless of the original agent's version.",
tooltipKey: "agents.formConfig.fields.protocolVersion.tooltip",
helpTextKey: "agents.formConfig.basic.protocolVersionHelp",
},
],
@ -96,7 +96,7 @@ export const AGENT_FORM_CONFIG: {
fields: [
{
name: "skills",
label: "Skills",
labelKey: "agents.formConfig.fields.skills.label",
type: "list",
defaultValue: [],
},
@ -108,18 +108,18 @@ export const AGENT_FORM_CONFIG: {
fields: [
{
name: "streaming",
label: "Streaming",
labelKey: "agents.formConfig.fields.streaming.label",
type: "switch",
defaultValue: false,
},
{
name: "pushNotifications",
label: "Push Notifications",
labelKey: "agents.formConfig.fields.pushNotifications.label",
type: "switch",
},
{
name: "stateTransitionHistory",
label: "State Transition History",
labelKey: "agents.formConfig.fields.stateTransitionHistory.label",
type: "switch",
},
],
@ -130,19 +130,19 @@ export const AGENT_FORM_CONFIG: {
fields: [
{
name: "iconUrl",
label: "Icon URL",
labelKey: "agents.formConfig.fields.iconUrl.label",
type: "url",
placeholder: "https://example.com/icon.png",
},
{
name: "documentationUrl",
label: "Documentation URL",
labelKey: "agents.formConfig.fields.documentationUrl.label",
type: "url",
placeholder: "https://docs.example.com",
},
{
name: "supportsAuthenticatedExtendedCard",
label: "Supports Authenticated Extended Card",
labelKey: "agents.formConfig.fields.supportsAuthenticatedExtendedCard.label",
type: "switch",
},
],
@ -153,12 +153,12 @@ export const AGENT_FORM_CONFIG: {
fields: [
{
name: "model",
label: "Model (Optional)",
labelKey: "agents.formConfig.fields.model.label",
type: "text",
},
{
name: "make_public",
label: "Make Public",
labelKey: "agents.formConfig.fields.makePublic.label",
type: "switch",
},
],
@ -169,24 +169,24 @@ export const AGENT_FORM_CONFIG: {
fields: [
{
name: "cost_per_query",
label: "Cost Per Query ($)",
labelKey: "agents.formConfig.fields.costPerQuery.label",
type: "text",
placeholder: "0.0",
tooltip: "Fixed cost per query",
tooltipKey: "agents.formConfig.fields.costPerQuery.tooltip",
},
{
name: "input_cost_per_token",
label: "Input Cost Per Token ($)",
labelKey: "agents.formConfig.fields.inputCostPerToken.label",
type: "text",
placeholder: "0.000001",
tooltip: "Cost per input token",
tooltipKey: "agents.formConfig.fields.inputCostPerToken.tooltip",
},
{
name: "output_cost_per_token",
label: "Output Cost Per Token ($)",
labelKey: "agents.formConfig.fields.outputCostPerToken.label",
type: "text",
placeholder: "0.000002",
tooltip: "Cost per output token",
tooltipKey: "agents.formConfig.fields.outputCostPerToken.tooltip",
},
],
},
@ -196,10 +196,10 @@ export const AGENT_FORM_CONFIG: {
fields: [
{
name: "enable_tracing",
label: "Enable Tracing",
labelKey: "agents.formConfig.fields.enableTracing.label",
type: "switch",
defaultValue: false,
tooltip: "Enable request tracing for this agent",
tooltipKey: "agents.formConfig.fields.enableTracing.tooltip",
},
],
},

View file

@ -256,23 +256,24 @@ const AgentFormFields: React.FC<AgentFormFieldsProps> = ({ panels, showAgentName
<AgentFormField
key={field.name}
name={field.name}
label={field.tooltip ? labelWithHint(field.label, field.tooltip) : field.label}
label={field.tooltipKey ? labelWithHint(t(field.labelKey), t(field.tooltipKey)) : t(field.labelKey)}
description={field.helpTextKey ? t(field.helpTextKey) : undefined}
rules={
field.required
? { required: t("agents.formFields.errors.enterField", { field: field.label.toLowerCase() }) }
? { required: t("agents.formFields.errors.enterField", { field: t(field.labelKey).toLowerCase() }) }
: undefined
}
>
{({ value, onChange, ref, ...control }) => {
const text = typeof value === "string" ? value : "";
const placeholder = field.placeholderKey ? t(field.placeholderKey) : field.placeholder;
if (field.type === "textarea") {
return (
<Textarea
{...control}
ref={ref}
rows={field.rows}
placeholder={field.placeholder}
placeholder={placeholder}
value={text}
onChange={onChange}
/>
@ -282,7 +283,7 @@ const AgentFormFields: React.FC<AgentFormFieldsProps> = ({ panels, showAgentName
return (
<Select value={text || null} onValueChange={onChange}>
<SelectTrigger {...control} className="w-full">
<SelectValue placeholder={field.placeholder} />
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{(field.options ?? []).map((option) => (
@ -294,9 +295,7 @@ const AgentFormFields: React.FC<AgentFormFieldsProps> = ({ panels, showAgentName
</Select>
);
}
return (
<Input {...control} ref={ref} placeholder={field.placeholder} value={text} onChange={onChange} />
);
return <Input {...control} ref={ref} placeholder={placeholder} value={text} onChange={onChange} />;
}}
</AgentFormField>
))}
@ -320,7 +319,7 @@ const AgentFormFields: React.FC<AgentFormFieldsProps> = ({ panels, showAgentName
panels={panels}
>
{AGENT_FORM_CONFIG.capabilities.fields.map((field) => (
<AgentFormField key={field.name} name={field.name} label={field.label}>
<AgentFormField key={field.name} name={field.name} label={t(field.labelKey)}>
{({ value, onChange, ref, ...control }) => (
<Switch {...control} inputRef={ref} checked={value === true} onCheckedChange={onChange} />
)}
@ -336,7 +335,7 @@ const AgentFormFields: React.FC<AgentFormFieldsProps> = ({ panels, showAgentName
panels={panels}
>
{AGENT_FORM_CONFIG.optional.fields.map((field) => (
<AgentFormField key={field.name} name={field.name} label={field.label}>
<AgentFormField key={field.name} name={field.name} label={t(field.labelKey)}>
{({ value, onChange, ref, ...control }) =>
field.type === "switch" ? (
<Switch {...control} inputRef={ref} checked={value === true} onCheckedChange={onChange} />
@ -372,7 +371,7 @@ const AgentFormFields: React.FC<AgentFormFieldsProps> = ({ panels, showAgentName
panels={panels}
>
{AGENT_FORM_CONFIG.litellm.fields.map((field) => (
<AgentFormField key={field.name} name={field.name} label={field.label}>
<AgentFormField key={field.name} name={field.name} label={t(field.labelKey)}>
{({ value, onChange, ref, ...control }) =>
field.type === "switch" ? (
<Switch {...control} inputRef={ref} checked={value === true} onCheckedChange={onChange} />

View file

@ -1,32 +1,36 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Input } from "@/components/ui/input";
import { AGENT_FORM_CONFIG } from "./agent_config";
import { AgentFormField, labelWithHint } from "./AgentFormKit";
export const COST_FIELD_NAMES: readonly string[] = AGENT_FORM_CONFIG.cost.fields.map((field) => field.name);
const CostConfigFields: React.FC = () => (
<>
{AGENT_FORM_CONFIG.cost.fields.map((field) => (
<AgentFormField
key={field.name}
name={field.name}
label={field.tooltip ? labelWithHint(field.label, field.tooltip) : field.label}
>
{({ value, onChange, ref, ...control }) => (
<Input
{...control}
ref={ref}
type="number"
step="0.000001"
placeholder={field.placeholder}
value={typeof value === "string" || typeof value === "number" ? value : ""}
onChange={onChange}
/>
)}
</AgentFormField>
))}
</>
);
const CostConfigFields: React.FC = () => {
const { t } = useTranslation();
return (
<>
{AGENT_FORM_CONFIG.cost.fields.map((field) => (
<AgentFormField
key={field.name}
name={field.name}
label={field.tooltipKey ? labelWithHint(t(field.labelKey), t(field.tooltipKey)) : t(field.labelKey)}
>
{({ value, onChange, ref, ...control }) => (
<Input
{...control}
ref={ref}
type="number"
step="0.000001"
placeholder={field.placeholder}
value={typeof value === "string" || typeof value === "number" ? value : ""}
onChange={onChange}
/>
)}
</AgentFormField>
))}
</>
);
};
export default CostConfigFields;

View file

@ -1,4 +1,5 @@
import React, { useId } from "react";
import type { ParseKeys } from "i18next";
import { useTranslation } from "react-i18next";
import { Controller } from "react-hook-form";
import { z } from "zod";
@ -41,11 +42,11 @@ type NumberFieldName = "tpm_limit" | "rpm_limit" | "tpd_limit" | "max_budget";
const NO_RESET = "never";
const RESET_OPTIONS = [
{ value: NO_RESET, label: "in total" },
{ value: "24h", label: "per day" },
{ value: "7d", label: "per week" },
{ value: "30d", label: "per month" },
const RESET_OPTIONS: readonly { value: string; labelKey: ParseKeys }[] = [
{ value: NO_RESET, labelKey: "budgets.form.resetOptions.inTotal" },
{ value: "24h", labelKey: "budgets.form.resetOptions.perDay" },
{ value: "7d", labelKey: "budgets.form.resetOptions.perWeek" },
{ value: "30d", labelKey: "budgets.form.resetOptions.perMonth" },
];
const numberProps = <T extends { value?: number | null; onChange: (value: number | null) => void }>({
@ -69,6 +70,7 @@ const OptionalBadge = () => {
function SpendLimitField({ form }: { form: BudgetForm }) {
const { t } = useTranslation();
const amountId = useId();
const resetItems = RESET_OPTIONS.map(({ value, labelKey }) => ({ value, label: t(labelKey) }));
return (
<Field className="gap-2">
<FieldLabel htmlFor={amountId}>
@ -93,7 +95,7 @@ function SpendLimitField({ form }: { form: BudgetForm }) {
name="budget_duration"
render={({ field }) => (
<Select
items={RESET_OPTIONS}
items={resetItems}
value={field.value ?? NO_RESET}
onValueChange={(next) => field.onChange(next === NO_RESET ? null : next)}
>
@ -101,7 +103,7 @@ function SpendLimitField({ form }: { form: BudgetForm }) {
<SelectValue />
</SelectTrigger>
<SelectContent>
{RESET_OPTIONS.map((option) => (
{resetItems.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>

View file

@ -2,6 +2,7 @@
import { Inbox, ShieldAlert } from "lucide-react";
import React, { useMemo, useState } from "react";
import type { ParseKeys } from "i18next";
import { useTranslation } from "react-i18next";
import {
@ -36,14 +37,16 @@ interface BudgetTableProps {
const PAGE_SIZE_OPTIONS = [25, 50, 100];
const FILTER_LABELS: Record<string, string> = {
budget_duration: "Reset",
max_budget: "Max Budget",
created_at: "Created",
const FILTER_LABELS: Record<string, ParseKeys> = {
budget_duration: "budgets.table.filterLabels.reset",
max_budget: "budgets.table.filterLabels.maxBudget",
created_at: "budgets.table.filterLabels.created",
};
const durationLabel = (value: string): string =>
BUDGET_DURATION_FILTER_OPTIONS.find((option) => option.value === value)?.label ?? value;
const durationLabel = (value: string): string => {
const labelKey = BUDGET_DURATION_FILTER_OPTIONS.find((option) => option.value === value)?.labelKey;
return labelKey ? i18n.t(labelKey) : value;
};
const formatFilterValue = (columnId: string, value: unknown): string => {
if (columnId === "budget_duration") {
@ -127,6 +130,7 @@ function ErrorState({ error }: { error: Error }) {
/** "Not set" and the concrete durations are exclusive; see serializeBudgetFilters for why. */
function DurationFilter({ selected, onChange }: { selected: string[]; onChange: (selected: string[]) => void }) {
const { t } = useTranslation();
const toggle = (value: string, checked: boolean): void => {
if (!checked) {
onChange(selected.filter((entry) => entry !== value));
@ -145,7 +149,7 @@ function DurationFilter({ selected, onChange }: { selected: string[]; onChange:
onCheckedChange={(checked) => toggle(option.value, checked === true)}
data-testid={`budget-filter-duration-${option.value}`}
/>
{option.label}
{t(option.labelKey)}
</Label>
))}
</div>
@ -231,6 +235,7 @@ const BudgetTable: React.FC<BudgetTableProps> = ({ list, canModify, onEditClick,
[canModify, onEditClick, onDeleteClick, t],
);
const filterLabels = Object.fromEntries(Object.entries(FILTER_LABELS).map(([id, labelKey]) => [id, t(labelKey)]));
const hasQuery = list.searchValue.trim() !== "" || list.columnFilters.length > 0;
const emptyMessage = list.error === null ? <EmptyState hasQuery={hasQuery} /> : <ErrorState error={list.error} />;
@ -266,7 +271,7 @@ const BudgetTable: React.FC<BudgetTableProps> = ({ list, canModify, onEditClick,
onOpenFilters={() => setFiltersOpen(true)}
onRefresh={list.refetch}
isRefreshing={list.isFetching}
filterLabels={FILTER_LABELS}
filterLabels={filterLabels}
formatFilterValue={formatFilterValue}
/>
<DataTableFilterDrawer

View file

@ -33,9 +33,9 @@ const MAX_ROUTERS = 4;
const MAX_MODELS = 100;
const RECOMMENDED_JUDGE_MODELS = ["anthropic/claude-sonnet-5", "openai/gpt-4o", "gemini/gemini-2.5-pro"] as const;
const DIRECTION_OPTIONS: readonly { value: ShadowEvalDirection; label: string }[] = [
{ value: "forward", label: "Adoption check: key's traffic vs the router" },
{ value: "reverse", label: "Regression check: router's picks vs a baseline" },
const DIRECTION_OPTIONS: readonly { value: ShadowEvalDirection; labelKey: ParseKeys }[] = [
{ value: "forward", labelKey: "costOptimization.shadowEval.start.directionOptions.forward" },
{ value: "reverse", labelKey: "costOptimization.shadowEval.start.directionOptions.reverse" },
] as const;
const START_FORM_DESCRIPTION: Record<ShadowEvalDirection, ParseKeys> = {
@ -44,11 +44,11 @@ const START_FORM_DESCRIPTION: Record<ShadowEvalDirection, ParseKeys> = {
};
const DURATION_OPTIONS = [
{ value: "1", label: "1 day" },
{ value: "3", label: "3 days" },
{ value: "7", label: "7 days" },
{ value: "14", label: "14 days" },
{ value: "30", label: "30 days" },
{ value: "1", days: 1 },
{ value: "3", days: 3 },
{ value: "7", days: 7 },
{ value: "14", days: 14 },
{ value: "30", days: 30 },
] as const;
const Field: React.FC<{ label: string; htmlFor?: string; className?: string; children: React.ReactNode }> = ({
@ -241,6 +241,8 @@ export const StartForm: React.FC = () => {
const [durationDays, setDurationDays] = useState("7");
const [judgeModel, setJudgeModel] = useState<string | null>(null);
const [maxBudget, setMaxBudget] = useState("10");
const directionOption = DIRECTION_OPTIONS.find((o) => o.value === direction);
const durationOption = DURATION_OPTIONS.find((o) => o.value === durationDays);
const { data: autoRouters } = useAutoRouters();
const configuredGroups = usePlainModelGroups();
const chatGroups = usePlainChatModelGroups();
@ -327,12 +329,12 @@ export const StartForm: React.FC = () => {
onValueChange={(v: string | null) => setDirection(v === "reverse" ? "reverse" : "forward")}
>
<SelectTrigger className="w-full">
<SelectValue>{DIRECTION_OPTIONS.find((o) => o.value === direction)?.label}</SelectValue>
<SelectValue>{directionOption && t(directionOption.labelKey)}</SelectValue>
</SelectTrigger>
<SelectContent>
{DIRECTION_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
{t(option.labelKey)}
</SelectItem>
))}
</SelectContent>
@ -400,12 +402,15 @@ export const StartForm: React.FC = () => {
<Field label={t("costOptimization.shadowEval.start.duration")}>
<Select value={durationDays} onValueChange={(v: string | null) => setDurationDays(v ?? "7")}>
<SelectTrigger className="w-full">
<SelectValue>{DURATION_OPTIONS.find((o) => o.value === durationDays)?.label}</SelectValue>
<SelectValue>
{durationOption &&
t("costOptimization.shadowEval.start.durationDays", { count: durationOption.days })}
</SelectValue>
</SelectTrigger>
<SelectContent>
{DURATION_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
{t("costOptimization.shadowEval.start.durationDays", { count: option.days })}
</SelectItem>
))}
</SelectContent>

View file

@ -1,4 +1,5 @@
import React from "react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { CircleHelp } from "lucide-react";
import { Providers, provider_map } from "@/components/provider_info_helpers";
@ -37,14 +38,14 @@ interface ProviderOption {
providerEnum: string | null;
}
const GLOBAL_OPTION: ProviderOption = {
const GLOBAL_OPTION = {
value: "global",
label: "Global (All Providers)",
labelKey: "costTracking.addMarginForm.provider.global",
providerEnum: null,
};
} as const;
const buildProviderOptions = (marginConfig: MarginConfig): ProviderOption[] => [
GLOBAL_OPTION,
const buildProviderOptions = (marginConfig: MarginConfig, t: TFunction): ProviderOption[] => [
{ value: GLOBAL_OPTION.value, label: t(GLOBAL_OPTION.labelKey), providerEnum: GLOBAL_OPTION.providerEnum },
...Object.entries(Providers).flatMap(([providerEnum, providerDisplayName]) => {
const providerValue = provider_map[providerEnum as keyof typeof provider_map];
if (providerValue && marginConfig[providerValue]) {
@ -77,7 +78,7 @@ const AddMarginForm: React.FC<AddMarginFormProps> = ({
onAddProvider,
}) => {
const { t } = useTranslation();
const providerOptions = buildProviderOptions(marginConfig);
const providerOptions = buildProviderOptions(marginConfig, t);
const selectedOption = providerOptions.find((option) => option.value === selectedProvider) ?? null;
return (

View file

@ -1,4 +1,5 @@
import React, { useState, useEffect } from "react";
import type { ParseKeys } from "i18next";
import { useTranslation } from "react-i18next";
import { ChevronDown } from "lucide-react";
import {
@ -28,9 +29,15 @@ import { useBlockUnpricedConfig } from "./use_block_unpriced_config";
import { fetchAvailableModels, ModelGroup } from "@/components/llm_calls/fetch_models";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
const DOCS_LINKS = [
{ label: "Custom pricing for models", href: "https://docs.litellm.ai/docs/proxy/custom_pricing" },
{ label: "Spend tracking", href: "https://docs.litellm.ai/docs/proxy/cost_tracking" },
const DOCS_LINKS: readonly { labelKey: ParseKeys; href: string }[] = [
{
labelKey: "costTracking.settings.docsLinks.customPricing",
href: "https://docs.litellm.ai/docs/proxy/custom_pricing",
},
{
labelKey: "costTracking.settings.docsLinks.spendTracking",
href: "https://docs.litellm.ai/docs/proxy/cost_tracking",
},
];
const REMOVAL_COPY = {
@ -195,7 +202,7 @@ const CostTrackingSettings: React.FC<CostTrackingSettingsProps> = ({ userID, use
<div>
<div className="flex items-center gap-2">
<p className="text-xl font-medium text-foreground">{t("costTracking.settings.title")}</p>
<DocsMenu items={DOCS_LINKS} />
<DocsMenu items={DOCS_LINKS.map(({ labelKey, href }) => ({ label: t(labelKey), href }))} />
</div>
<p className="text-muted-foreground mt-1">{t("costTracking.settings.description")}</p>
</div>

View file

@ -1,13 +1,14 @@
import type { ColumnFilter, ColumnFiltersState } from "@tanstack/react-table";
import type { ParseKeys } from "i18next";
export const BUDGET_DURATION_UNSET = "__unset__";
export const BUDGET_DURATION_FILTER_OPTIONS: readonly { value: string; label: string }[] = [
{ value: "1h", label: "hourly" },
{ value: "24h", label: "daily" },
{ value: "7d", label: "weekly" },
{ value: "30d", label: "monthly" },
{ value: BUDGET_DURATION_UNSET, label: "Not set" },
export const BUDGET_DURATION_FILTER_OPTIONS: readonly { value: string; labelKey: ParseKeys }[] = [
{ value: "1h", labelKey: "budgets.durationFilterOptions.hourly" },
{ value: "24h", labelKey: "budgets.durationFilterOptions.daily" },
{ value: "7d", labelKey: "budgets.durationFilterOptions.weekly" },
{ value: "30d", labelKey: "budgets.durationFilterOptions.monthly" },
{ value: BUDGET_DURATION_UNSET, labelKey: "budgets.durationFilterOptions.notSet" },
];
export interface MaxBudgetFilterValue {

View file

@ -401,7 +401,7 @@ const CreateVectorStore: React.FC<CreateVectorStoreProps> = ({ accessToken, onSu
type={field.type === "password" ? "password" : "text"}
value={asText(providerParams[field.name])}
onChange={(e) => setProviderParams((prev) => ({ ...prev, [field.name]: e.target.value }))}
placeholder={field.placeholder}
placeholder={field.placeholderKey ? t(field.placeholderKey) : field.placeholder}
/>
</Field>
))}

View file

@ -1,7 +1,7 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import type { TFunction } from "i18next";
import type { ParseKeys, TFunction } from "i18next";
import { Copy, MoreHorizontal, Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
@ -17,11 +17,11 @@ import { DocumentUpload } from "@/components/vector_store_management/types";
import { cn } from "@/lib/cva.config";
import { copyToClipboard } from "@/utils/dataUtils";
const STATUS_CONFIG: Record<DocumentUpload["status"], { tone: StatusTone; label: string }> = {
uploading: { tone: "info", label: "Uploading" },
done: { tone: "success", label: "Ready" },
error: { tone: "error", label: "Error" },
removed: { tone: "neutral", label: "Removed" },
const STATUS_CONFIG: Record<DocumentUpload["status"], { tone: StatusTone; labelKey: ParseKeys }> = {
uploading: { tone: "info", labelKey: "vectorStores.documents.statuses.uploading" },
done: { tone: "success", labelKey: "vectorStores.documents.statuses.ready" },
error: { tone: "error", labelKey: "vectorStores.documents.statuses.error" },
removed: { tone: "neutral", labelKey: "vectorStores.documents.statuses.removed" },
};
function formatFileSize(bytes?: number): string {
@ -96,8 +96,9 @@ export const getDocumentsTableColumns = (
size: 150,
enableSorting: false,
cell: ({ row }) => {
const config = STATUS_CONFIG[row.original.status] ?? { tone: "neutral", label: row.original.status };
return <StatusBadge tone={config.tone} label={config.label} />;
const { status } = row.original;
const config = Object.hasOwn(STATUS_CONFIG, status) ? STATUS_CONFIG[status] : undefined;
return <StatusBadge tone={config?.tone ?? "neutral"} label={config ? t(config.labelKey) : status} />;
},
},
{

View file

@ -553,6 +553,7 @@ interface ProviderFieldProps {
const ProviderField: React.FC<ProviderFieldProps> = ({ field, control, modelInfo }) => {
const { t } = useTranslation();
const label = labelWithHint(t(field.label), t(field.tooltip));
const placeholder = field.placeholderKey ? t(field.placeholderKey) : field.placeholder;
if (field.type === "select") {
const selectOptions =
@ -581,7 +582,7 @@ const ProviderField: React.FC<ProviderFieldProps> = ({ field, control, modelInfo
id={id}
aria-invalid={ariaInvalid}
aria-describedby={ariaDescribedBy}
placeholder={field.placeholder}
placeholder={placeholder}
className="w-full"
/>
<ComboboxContent>
@ -604,9 +605,9 @@ const ProviderField: React.FC<ProviderFieldProps> = ({ field, control, modelInfo
<FormField control={control} name={field.name} label={label}>
{({ ref, value, ...controlProps }) =>
field.type === "password" ? (
<PasswordInput {...controlProps} ref={ref} value={value ?? ""} placeholder={field.placeholder} />
<PasswordInput {...controlProps} ref={ref} value={value ?? ""} placeholder={placeholder} />
) : (
<Input {...controlProps} ref={ref} value={value ?? ""} type="text" placeholder={field.placeholder} />
<Input {...controlProps} ref={ref} value={value ?? ""} type="text" placeholder={placeholder} />
)
}
</FormField>

View file

@ -2,7 +2,7 @@ import { useModelCostMap } from "@/app/(dashboard)/hooks/models/useModelCostMap"
import { ArrowRight, Pencil, Play, Trash2 } from "lucide-react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import type { TFunction } from "i18next";
import type { ParseKeys, TFunction } from "i18next";
import openai from "openai";
import React, { useEffect, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
@ -133,7 +133,7 @@ async function testFallbackModelResponse(selectedModel: string, accessToken: str
}
}
function fallbacksWriteHint(source: RouterSettingsSource | null, ownedByConfig: boolean): string | undefined {
function fallbacksWriteHint(source: RouterSettingsSource | null, ownedByConfig: boolean): ParseKeys | undefined {
if (ownedByConfig) return CONFIG_OWNED_HINT;
if (source === null) return SOURCE_UNKNOWN_HINT;
return undefined;
@ -276,7 +276,8 @@ const Fallbacks: React.FC<FallbacksProps> = ({ accessToken, userRole, userID })
// Admin Viewer follows the read-parity rule: see fallbacks, no writes.
const canModify = isProxyAdminRole(userRole ?? "");
const ownedByConfig = routerSettingsSource !== null && isOwnedByConfig(routerSettingsSource, "fallbacks");
const writeHint = fallbacksWriteHint(routerSettingsSource, ownedByConfig);
const writeHintKey = fallbacksWriteHint(routerSettingsSource, ownedByConfig);
const writeHint = writeHintKey === undefined ? undefined : t(writeHintKey);
const canWrite = canModify && writeHint === undefined;
const writeActionClass = (hoverClass: string) =>
canWrite ? `${iconWrapperClass} cursor-pointer ${hoverClass}` : `${iconWrapperClass} cursor-not-allowed opacity-50`;

View file

@ -8,6 +8,7 @@ import { useTeamMetadataSchema } from "@/app/(dashboard)/hooks/teams/useTeamMeta
import { toast } from "@/lib/toast";
import { fetchAvailableModelsForTeamOrKey } from "./key_team_helpers/fetch_available_models_team_key";
import { MODEL_MAX_BUDGET_PREMIUM_HINT } from "./key_team_helpers/ModelMaxBudgetEditor";
import i18n from "@/i18n/i18n";
import {
fetchMCPAccessGroups,
getDefaultTeamSettings,
@ -1546,7 +1547,7 @@ describe("Teams - the exact bytes the create call sends", () => {
await openCreateModal({ premiumUser: false });
expect(screen.getByRole("button", { name: /Add Model Budget/i })).toBeDisabled();
expect(screen.getByText(MODEL_MAX_BUDGET_PREMIUM_HINT)).toBeInTheDocument();
expect(screen.getByText(i18n.t(MODEL_MAX_BUDGET_PREMIUM_HINT))).toBeInTheDocument();
});
it("sends the per-model budget a licensed operator fills in, keyed by model", async () => {

View file

@ -26,6 +26,7 @@ import { ColumnFiltersState, functionalUpdate, OnChangeFn } from "@tanstack/reac
import { KeyRound } from "lucide-react";
import { parseAsString, useQueryState } from "nuqs";
import React, { useCallback, useMemo, useState } from "react";
import type { ParseKeys } from "i18next";
import { useTranslation } from "react-i18next";
import { KeyResponse, Team } from "../key_team_helpers/key_list";
@ -39,28 +40,28 @@ interface VirtualKeysTableProps {
const FILTER_COLUMNS = ["team_id", "org_id", "user_id", "key_hash", "status"] as const;
type FilterColumn = (typeof FILTER_COLUMNS)[number];
const FILTER_LABELS: Record<FilterColumn, string> = {
team_id: "Team",
org_id: "Organization",
user_id: "User ID",
key_hash: "Key ID",
status: "Status",
const FILTER_LABELS: Record<FilterColumn, ParseKeys> = {
team_id: "apiKeys.virtualKeysTable.filterLabels.team",
org_id: "apiKeys.virtualKeysTable.filterLabels.organization",
user_id: "apiKeys.virtualKeysTable.filterLabels.userId",
key_hash: "apiKeys.virtualKeysTable.filterLabels.keyId",
status: "apiKeys.virtualKeysTable.filterLabels.status",
};
const KEY_STATUS_VALUES = ["active", "expired", "revoked", "deleted"] as const;
type KeyStatusFilter = (typeof KEY_STATUS_VALUES)[number];
const ALL_STATUSES = "all";
const KEY_STATUS_LABELS: Record<KeyStatusFilter, string> = {
active: "Active",
expired: "Expired",
revoked: "Revoked (blocked)",
deleted: "Deleted",
const KEY_STATUS_LABELS: Record<KeyStatusFilter, ParseKeys> = {
active: "apiKeys.virtualKeysTable.statuses.active",
expired: "apiKeys.virtualKeysTable.statuses.expired",
revoked: "apiKeys.virtualKeysTable.statuses.revoked",
deleted: "apiKeys.virtualKeysTable.statuses.deleted",
};
const STATUS_FILTER_ITEMS = [
{ value: ALL_STATUSES, label: "All statuses" },
...KEY_STATUS_VALUES.map((value) => ({ value, label: KEY_STATUS_LABELS[value] })),
const STATUS_FILTER_ITEMS: readonly { value: string; labelKey: ParseKeys }[] = [
{ value: ALL_STATUSES, labelKey: "apiKeys.virtualKeysTable.statuses.all" },
...KEY_STATUS_VALUES.map((value) => ({ value, labelKey: KEY_STATUS_LABELS[value] })),
];
const isKeyStatusFilter = (value: unknown): value is KeyStatusFilter =>
@ -216,12 +217,14 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) {
return organizations.find((org) => org.organization_id === raw)?.organization_alias || raw;
}
if (columnId === "status" && isKeyStatusFilter(raw)) {
return KEY_STATUS_LABELS[raw];
return t(KEY_STATUS_LABELS[raw]);
}
return raw;
},
[allTeams, organizations],
[allTeams, organizations, t],
);
const filterLabels = Object.fromEntries(FILTER_COLUMNS.map((column) => [column, t(FILTER_LABELS[column])]));
const statusFilterItems = STATUS_FILTER_ITEMS.map(({ value, labelKey }) => ({ value, label: t(labelKey) }));
if (selectedKeyId) {
if (!selectedKey && !selectedKeyLoadFailed) {
@ -285,7 +288,7 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) {
onRefresh={() => refetch?.()}
isRefreshing={isFetching}
onOpenFilters={() => setFiltersOpen(true)}
filterLabels={FILTER_LABELS}
filterLabels={filterLabels}
formatFilterValue={formatFilterValue}
/>
<DataTableFilterDrawer
@ -331,7 +334,7 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) {
</DataTableFilterField>
<DataTableFilterField label={t("apiKeys.virtualKeysTable.filters.status")}>
<Select
items={STATUS_FILTER_ITEMS}
items={statusFilterItems}
value={(get("status") as string) || ALL_STATUSES}
onValueChange={(value) => set("status", value === ALL_STATUSES ? undefined : value)}
>
@ -339,7 +342,7 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) {
<SelectValue placeholder={t("apiKeys.virtualKeysTable.filters.statusPlaceholder")} />
</SelectTrigger>
<SelectContent>
{STATUS_FILTER_ITEMS.map((item) => (
{statusFilterItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>

View file

@ -3,9 +3,9 @@
import type { ReactNode } from "react";
import { Info } from "lucide-react";
import { ColumnDef } from "@tanstack/react-table";
import type { TFunction } from "i18next";
import type { ParseKeys, TFunction } from "i18next";
import { DataTableMultiSortHeader, DataTableSortHeader, type DataTableSortField } from "@/components/shared/DataTable";
import { DataTableMultiSortHeader, DataTableSortHeader } from "@/components/shared/DataTable";
import {
inheritedBudgetGates,
keyOwnerBudgetSource,
@ -38,9 +38,9 @@ interface KeyStatus {
tooltip?: string;
}
const SPEND_BUDGET_SORT_FIELDS: DataTableSortField[] = [
{ id: "spend", label: "Spend" },
{ id: "max_budget", label: "Budget" },
const SPEND_BUDGET_SORT_FIELDS: readonly { id: string; labelKey: ParseKeys }[] = [
{ id: "spend", labelKey: "apiKeys.keyTableColumns.sortFields.spend" },
{ id: "max_budget", labelKey: "apiKeys.keyTableColumns.sortFields.budget" },
];
export const KEY_TABLE_SORT_FIELDS: readonly string[] = [
@ -328,7 +328,12 @@ export const getKeyTableColumns = (
id: "spend",
accessorKey: "spend",
meta: { title: t("apiKeys.keyTableColumns.columns.spendBudget"), skeleton: "meter", numeric: true },
header: ({ table }) => <DataTableMultiSortHeader table={table} fields={SPEND_BUDGET_SORT_FIELDS} />,
header: ({ table }) => (
<DataTableMultiSortHeader
table={table}
fields={SPEND_BUDGET_SORT_FIELDS.map(({ id, labelKey }) => ({ id, label: t(labelKey) }))}
/>
),
size: 240,
enableSorting: true,
cell: ({ row }) => {

View file

@ -1,3 +1,4 @@
import type { ParseKeys } from "i18next";
import arizeLogo from "../../public/assets/logos/arize.png";
import awsLogo from "../../public/assets/logos/aws.svg";
import braintrustLogo from "../../public/assets/logos/braintrust.png";
@ -21,7 +22,7 @@ interface CallbackConfig {
dynamic_params: Record<string, "text" | "password" | "select" | "upload" | "number">;
dynamic_param_options?: Record<string, readonly string[]>;
success_event_params?: string[];
description: string;
descriptionKey: ParseKeys;
}
export const CALLBACK_CONFIGS: CallbackConfig[] = [
@ -41,7 +42,7 @@ export const CALLBACK_CONFIGS: CallbackConfig[] = [
arize_otlp_protocol: ["grpc", "http/protobuf"],
},
success_event_params: ["arize_otlp_protocol"],
description: "Arize Logging Integration",
descriptionKey: "callbackInfo.descriptions.arize",
},
{
id: "braintrust",
@ -52,7 +53,7 @@ export const CALLBACK_CONFIGS: CallbackConfig[] = [
braintrust_api_key: "password",
braintrust_project_name: "text",
},
description: "Braintrust Logging Integration",
descriptionKey: "callbackInfo.descriptions.braintrust",
},
{
id: "custom_callback_api",
@ -62,7 +63,7 @@ export const CALLBACK_CONFIGS: CallbackConfig[] = [
custom_callback_api_url: "text",
custom_callback_api_headers: "text",
},
description: "Custom Callback API Logging Integration",
descriptionKey: "callbackInfo.descriptions.customCallbackApi",
},
{
id: "galileo",
@ -77,7 +78,7 @@ export const CALLBACK_CONFIGS: CallbackConfig[] = [
GALILEO_USERNAME: "text",
GALILEO_PASSWORD: "password",
},
description: "Galileo AI Observability Integration",
descriptionKey: "callbackInfo.descriptions.galileo",
},
{
id: "datadog",
@ -88,7 +89,7 @@ export const CALLBACK_CONFIGS: CallbackConfig[] = [
dd_api_key: "password",
dd_site: "text",
},
description: "Datadog Logging Integration",
descriptionKey: "callbackInfo.descriptions.datadog",
},
{
id: "newrelic",
@ -99,7 +100,7 @@ export const CALLBACK_CONFIGS: CallbackConfig[] = [
newrelic_api_key: "password",
newrelic_region: "text",
},
description: "New Relic Logging Integration",
descriptionKey: "callbackInfo.descriptions.newrelic",
},
{
id: "lago",
@ -110,7 +111,7 @@ export const CALLBACK_CONFIGS: CallbackConfig[] = [
lago_api_url: "text",
lago_api_key: "password",
},
description: "Lago Billing Logging Integration",
descriptionKey: "callbackInfo.descriptions.lago",
},
{
id: "langfuse",
@ -123,7 +124,7 @@ export const CALLBACK_CONFIGS: CallbackConfig[] = [
langfuse_host: "text",
langfuse_environment: "text",
},
description: "Langfuse v2 Logging Integration",
descriptionKey: "callbackInfo.descriptions.langfuse",
},
{
id: "langfuse_otel",
@ -140,7 +141,7 @@ export const CALLBACK_CONFIGS: CallbackConfig[] = [
dynamic_param_options: {
langfuse_span_scope: ["full", "llm_only"],
},
description: "Langfuse v3 OTEL Logging Integration",
descriptionKey: "callbackInfo.descriptions.langfuseOtel",
},
{
id: "langsmith",
@ -153,7 +154,7 @@ export const CALLBACK_CONFIGS: CallbackConfig[] = [
langsmith_base_url: "text",
langsmith_sampling_rate: "number",
},
description: "Langsmith Logging Integration",
descriptionKey: "callbackInfo.descriptions.langsmith",
},
{
id: "openmeter",
@ -164,7 +165,7 @@ export const CALLBACK_CONFIGS: CallbackConfig[] = [
openmeter_api_key: "password",
openmeter_base_url: "text",
},
description: "OpenMeter Logging Integration",
descriptionKey: "callbackInfo.descriptions.openmeter",
},
{
id: "otel",
@ -176,7 +177,7 @@ export const CALLBACK_CONFIGS: CallbackConfig[] = [
otel_headers: "text",
otel_exporter_otlp_protocol: "select",
},
description: "OpenTelemetry Logging Integration",
descriptionKey: "callbackInfo.descriptions.otel",
},
{
id: "pointfive",
@ -187,7 +188,7 @@ export const CALLBACK_CONFIGS: CallbackConfig[] = [
POINTFIVE_API_KEY: "password",
POINTFIVE_API_URL: "text",
},
description: "PointFive Logging Integration",
descriptionKey: "callbackInfo.descriptions.pointfive",
},
{
id: "zerobus",
@ -201,7 +202,7 @@ export const CALLBACK_CONFIGS: CallbackConfig[] = [
ZEROBUS_CLIENT_SECRET: "password",
ZEROBUS_TABLE_NAME: "text",
},
description: "Databricks Zerobus Ingest Logging Integration",
descriptionKey: "callbackInfo.descriptions.zerobus",
},
{
id: "s3",
@ -214,7 +215,7 @@ export const CALLBACK_CONFIGS: CallbackConfig[] = [
aws_secret_access_key: "password",
aws_region: "text",
},
description: "S3 Bucket (AWS) Logging Integration",
descriptionKey: "callbackInfo.descriptions.s3",
},
{
id: "signoz",
@ -225,7 +226,7 @@ export const CALLBACK_CONFIGS: CallbackConfig[] = [
signoz_ingestion_endpoint: "text",
signoz_ingestion_key: "password",
},
description: "SigNoz Logging Integration. Setup: https://signoz.io/docs/litellm-observability/",
descriptionKey: "callbackInfo.descriptions.signoz",
},
{
id: "SQS",
@ -238,7 +239,7 @@ export const CALLBACK_CONFIGS: CallbackConfig[] = [
aws_secret_access_key: "password",
aws_region: "text",
},
description: "SQS Queue (AWS) Logging Integration",
descriptionKey: "callbackInfo.descriptions.sqs",
},
];

View file

@ -6,12 +6,12 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
export const NEVER_RESETS_BUDGET_DURATION = "none";
const DURATION_LABELS: Record<string, string> = {
[NEVER_RESETS_BUDGET_DURATION]: "Never resets",
"1h": "hourly",
"24h": "daily",
"7d": "weekly",
"30d": "monthly",
const DURATION_LABELS: Record<string, ParseKeys> = {
[NEVER_RESETS_BUDGET_DURATION]: "budgetDurationDropdown.neverResets",
"1h": "budgetDurationDropdown.periods.hourly",
"24h": "budgetDurationDropdown.periods.daily",
"7d": "budgetDurationDropdown.periods.weekly",
"30d": "budgetDurationDropdown.periods.monthly",
};
interface BudgetDurationDropdownProps {
@ -34,8 +34,11 @@ const BudgetDurationDropdown: React.FC<BudgetDurationDropdownProps> = ({
showNeverResets = false,
}) => {
const { t } = useTranslation();
const durationItems = Object.fromEntries(
Object.entries(DURATION_LABELS).map(([duration, key]) => [duration, t(key)]),
);
return (
<Select items={DURATION_LABELS} value={value || null} onValueChange={onChange}>
<Select items={durationItems} value={value || null} onValueChange={onChange}>
<SelectTrigger id={id} className={`w-full ${className}`} style={style}>
<SelectValue placeholder={placeholder} />
</SelectTrigger>

View file

@ -2,6 +2,7 @@ import { Button } from "@/components/ui/button";
import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from "@/components/ui/input-group";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import React from "react";
import type { ParseKeys } from "i18next";
import { useTranslation } from "react-i18next";
export interface BudgetWindowEntry {
@ -9,11 +10,27 @@ export interface BudgetWindowEntry {
max_budget: number | null;
}
export const BUDGET_WINDOW_OPTIONS = [
{ value: "1h", label: "Hourly", resetHint: "Resets every hour" },
{ value: "24h", label: "Daily", resetHint: "Resets daily at midnight UTC" },
{ value: "7d", label: "Weekly", resetHint: "Resets every Sunday at midnight UTC" },
{ value: "30d", label: "Monthly", resetHint: "Resets on the 1st of every month at midnight UTC" },
export const BUDGET_WINDOW_OPTIONS: readonly { value: string; labelKey: ParseKeys; resetHintKey: ParseKeys }[] = [
{
value: "1h",
labelKey: "keyTeamHelpers.budgetWindowsEditor.options.hourly.label",
resetHintKey: "keyTeamHelpers.budgetWindowsEditor.options.hourly.resetHint",
},
{
value: "24h",
labelKey: "keyTeamHelpers.budgetWindowsEditor.options.daily.label",
resetHintKey: "keyTeamHelpers.budgetWindowsEditor.options.daily.resetHint",
},
{
value: "7d",
labelKey: "keyTeamHelpers.budgetWindowsEditor.options.weekly.label",
resetHintKey: "keyTeamHelpers.budgetWindowsEditor.options.weekly.resetHint",
},
{
value: "30d",
labelKey: "keyTeamHelpers.budgetWindowsEditor.options.monthly.label",
resetHintKey: "keyTeamHelpers.budgetWindowsEditor.options.monthly.resetHint",
},
];
interface BudgetWindowsEditorProps {
@ -23,6 +40,7 @@ interface BudgetWindowsEditorProps {
export function BudgetWindowsEditor({ value, onChange }: BudgetWindowsEditorProps) {
const { t } = useTranslation();
const windowItems = BUDGET_WINDOW_OPTIONS.map(({ value, labelKey }) => ({ value, label: t(labelKey) }));
const addWindow = () => {
onChange([...value, { budget_duration: "24h", max_budget: null }]);
};
@ -39,12 +57,12 @@ export function BudgetWindowsEditor({ value, onChange }: BudgetWindowsEditorProp
return (
<div>
{value.map((window, idx) => {
const hint = BUDGET_WINDOW_OPTIONS.find((o) => o.value === window.budget_duration)?.resetHint;
const hintKey = BUDGET_WINDOW_OPTIONS.find((o) => o.value === window.budget_duration)?.resetHintKey;
return (
<div key={idx} style={{ marginBottom: 12 }}>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<Select
items={BUDGET_WINDOW_OPTIONS}
items={windowItems}
value={window.budget_duration}
onValueChange={(v: string | null) => v && updateWindow(idx, "budget_duration", v)}
>
@ -52,7 +70,7 @@ export function BudgetWindowsEditor({ value, onChange }: BudgetWindowsEditorProp
<SelectValue />
</SelectTrigger>
<SelectContent>
{BUDGET_WINDOW_OPTIONS.map((option) => (
{windowItems.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
@ -90,7 +108,7 @@ export function BudgetWindowsEditor({ value, onChange }: BudgetWindowsEditorProp
✕
</Button>
</div>
{hint && <div style={{ fontSize: 11, color: "#888", marginTop: 3, marginLeft: 2 }}>↻ {hint}</div>}
{hintKey && <div style={{ fontSize: 11, color: "#888", marginTop: 3, marginLeft: 2 }}>↻ {t(hintKey)}</div>}
</div>
);
})}

View file

@ -2,6 +2,7 @@ import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { fireEvent, renderWithProviders, screen } from "../../../tests/test-utils";
import { MODEL_MAX_BUDGET_PREMIUM_HINT, ModelMaxBudgetEditor, type ModelMaxBudget } from "./ModelMaxBudgetEditor";
import i18n from "@/i18n/i18n";
const STORED: ModelMaxBudget = { "gpt-4o": { budget_limit: 5, time_period: "30d" } };
@ -37,14 +38,14 @@ describe("ModelMaxBudgetEditor without an enterprise license", () => {
it("says why the controls are locked instead of failing silently", () => {
renderEditor(false);
expect(screen.getByText(MODEL_MAX_BUDGET_PREMIUM_HINT)).toBeInTheDocument();
expect(screen.getByText(i18n.t(MODEL_MAX_BUDGET_PREMIUM_HINT))).toBeInTheDocument();
});
it("locks the empty state too, so no row can be started", () => {
renderEditor(false, {});
expect(addButton()).toBeDisabled();
expect(screen.getByText(MODEL_MAX_BUDGET_PREMIUM_HINT)).toBeInTheDocument();
expect(screen.getByText(i18n.t(MODEL_MAX_BUDGET_PREMIUM_HINT))).toBeInTheDocument();
});
});
@ -59,7 +60,7 @@ describe("ModelMaxBudgetEditor with an enterprise license", () => {
it("does not tell a licensed operator to upgrade", () => {
renderEditor(true);
expect(screen.queryByText(MODEL_MAX_BUDGET_PREMIUM_HINT)).not.toBeInTheDocument();
expect(screen.queryByText(i18n.t(MODEL_MAX_BUDGET_PREMIUM_HINT))).not.toBeInTheDocument();
});
it("leaves the empty state usable", () => {

View file

@ -6,6 +6,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import i18n from "@/i18n/i18n";
import { Plus, X } from "lucide-react";
import React, { useState } from "react";
import type { ParseKeys } from "i18next";
import { useTranslation } from "react-i18next";
export interface ModelBudgetConfig {
@ -42,17 +43,20 @@ const readNumber = (raw: unknown): number | null => {
const readPeriod = (raw: unknown): string | null => (typeof raw === "string" && raw !== "" ? raw : null);
export const MODEL_BUDGET_PERIOD_OPTIONS = [
{ value: "1h", label: "Hourly" },
{ value: "24h", label: "Daily" },
{ value: "7d", label: "Weekly" },
{ value: "30d", label: "Monthly" },
{ value: "1mo", label: "Calendar month" },
export const MODEL_BUDGET_PERIOD_OPTIONS: readonly { value: string; labelKey: ParseKeys }[] = [
{ value: "1h", labelKey: "keyTeamHelpers.modelMaxBudgetEditor.periods.hourly" },
{ value: "24h", labelKey: "keyTeamHelpers.modelMaxBudgetEditor.periods.daily" },
{ value: "7d", labelKey: "keyTeamHelpers.modelMaxBudgetEditor.periods.weekly" },
{ value: "30d", labelKey: "keyTeamHelpers.modelMaxBudgetEditor.periods.monthly" },
{ value: "1mo", labelKey: "keyTeamHelpers.modelMaxBudgetEditor.periods.calendarMonth" },
];
const DEFAULT_PERIOD = "30d";
const MODEL_PATTERN_VALUE = { hint: "Model pattern", accepts: (typed: string) => typed.includes("*") };
const MODEL_PATTERN_VALUE = {
hintKey: "keyTeamHelpers.modelMaxBudgetEditor.modelPattern" as ParseKeys,
accepts: (typed: string) => typed.includes("*"),
};
export const entriesToModelMaxBudget = (entries: readonly ModelBudgetEntry[]): ModelMaxBudget =>
Object.fromEntries(
@ -76,7 +80,7 @@ export const modelMaxBudgetToEntries = (budget: ModelMaxBudget | null | undefine
extra: Object.fromEntries(Object.entries(config ?? {}).filter(([field]) => !MODELLED_FIELDS.includes(field))),
}));
export const MODEL_MAX_BUDGET_PREMIUM_HINT = "Premium feature - Upgrade to set per-model budgets";
export const MODEL_MAX_BUDGET_PREMIUM_HINT: ParseKeys = "keyTeamHelpers.modelMaxBudgetEditor.premiumHint";
interface ModelMaxBudgetEditorProps {
value: ModelMaxBudget;
@ -121,11 +125,13 @@ export function ModelMaxBudgetEditor({
emitChange(entries.map((entry) => (entry.id === id ? { ...entry, ...patch } : entry)));
const usedModels = new Set(entries.map((entry) => entry.model).filter(Boolean));
const hintWhenLocked = premiumUser ? undefined : MODEL_MAX_BUDGET_PREMIUM_HINT;
const hintWhenLocked = premiumUser ? undefined : t(MODEL_MAX_BUDGET_PREMIUM_HINT);
const periodItems = MODEL_BUDGET_PERIOD_OPTIONS.map(({ value, labelKey }) => ({ value, label: t(labelKey) }));
const modelPatternValue = { hint: t(MODEL_PATTERN_VALUE.hintKey), accepts: MODEL_PATTERN_VALUE.accepts };
const blurb = (
<div className="text-xs text-muted-foreground">
{premiumUser ? t("keyTeamHelpers.modelMaxBudgetEditor.blurb") : MODEL_MAX_BUDGET_PREMIUM_HINT}
{premiumUser ? t("keyTeamHelpers.modelMaxBudgetEditor.blurb") : t(MODEL_MAX_BUDGET_PREMIUM_HINT)}
</div>
);
@ -170,7 +176,7 @@ export function ModelMaxBudgetEditor({
onValueChange={(model) => updateEntry(entry.id, { model })}
placeholder={t("keyTeamHelpers.modelMaxBudgetEditor.selectModel")}
emptyText={t("keyTeamHelpers.modelMaxBudgetEditor.noModels")}
customValue={MODEL_PATTERN_VALUE}
customValue={modelPatternValue}
reservedValues={entries.flatMap((other) => (other.id !== entry.id && other.model ? [other.model] : []))}
disabled={!premiumUser}
/>
@ -197,7 +203,7 @@ export function ModelMaxBudgetEditor({
/>
</InputGroup>
<Select
items={MODEL_BUDGET_PERIOD_OPTIONS}
items={periodItems}
value={entry.timePeriod}
onValueChange={(period: string | null) => period && updateEntry(entry.id, { timePeriod: period })}
>
@ -205,7 +211,7 @@ export function ModelMaxBudgetEditor({
<SelectValue />
</SelectTrigger>
<SelectContent>
{MODEL_BUDGET_PERIOD_OPTIONS.map((option) => (
{periodItems.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>

View file

@ -1,19 +1,22 @@
import { Badge } from "@/components/ui/badge";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Lock } from "lucide-react";
import type { ParseKeys } from "i18next";
import React from "react";
import { useTranslation } from "react-i18next";
export type RouterSettingSource = "config" | "db" | "env" | "default" | "unset";
export type RouterSettingsSource = { [key: string]: RouterSettingSource };
export const CONFIG_OWNED_HINT = "Set in config.yaml. Edit the file to change it.";
export const SOURCE_UNKNOWN_HINT = "Could not load which settings config.yaml owns. Reload the page to make changes.";
export const CONFIG_OWNED_HINT: ParseKeys = "routerSettingsForm.configOwned.hint";
export const SOURCE_UNKNOWN_HINT: ParseKeys = "routerSettingsForm.configOwned.sourceUnknown";
export function isOwnedByConfig(source: RouterSettingsSource, key: string): boolean {
return source[key] === "config";
}
export function ConfigOwnedBadge(): React.ReactElement {
const { t } = useTranslation();
return (
<Tooltip>
<TooltipTrigger
@ -22,7 +25,7 @@ export function ConfigOwnedBadge(): React.ReactElement {
<Lock aria-hidden />
config.yaml
</TooltipTrigger>
<TooltipContent>{CONFIG_OWNED_HINT}</TooltipContent>
<TooltipContent>{t(CONFIG_OWNED_HINT)}</TooltipContent>
</Tooltip>
);
}

View file

@ -51,7 +51,7 @@ const LatencyBasedConfiguration: React.FC<LatencyBasedConfigurationProps> = ({
name={param}
defaultValue={typeof value === "object" ? JSON.stringify(value, null, 2) : value?.toString()}
disabled={ownedByConfig}
title={ownedByConfig ? CONFIG_OWNED_HINT : undefined}
title={ownedByConfig ? t(CONFIG_OWNED_HINT) : undefined}
className="font-mono text-sm w-full"
/>
</label>

View file

@ -56,7 +56,7 @@ const ReliabilityRetriesSection: React.FC<ReliabilityRetriesSectionProps> = ({
}
placeholder="—"
disabled={isOwnedByConfig(routerSettingsSource, param)}
title={isOwnedByConfig(routerSettingsSource, param) ? CONFIG_OWNED_HINT : undefined}
title={isOwnedByConfig(routerSettingsSource, param) ? t(CONFIG_OWNED_HINT) : undefined}
className="font-mono text-sm w-full"
/>
</label>

View file

@ -38,7 +38,7 @@ const RoutingStrategySelector: React.FC<RoutingStrategySelectorProps> = ({
onValueChange={(strategy: string | null) => strategy && onStrategyChange(strategy)}
disabled={ownedByConfig}
>
<SelectTrigger className="w-full" aria-label={ownedByConfig ? CONFIG_OWNED_HINT : undefined}>
<SelectTrigger className="w-full" aria-label={ownedByConfig ? t(CONFIG_OWNED_HINT) : undefined}>
<SelectValue />
</SelectTrigger>
<SelectContent>

View file

@ -49,7 +49,7 @@ const TagFilteringToggle: React.FC<TagFilteringToggleProps> = ({
checked={enabled}
onCheckedChange={onToggle}
disabled={ownedByConfig}
title={ownedByConfig ? CONFIG_OWNED_HINT : undefined}
title={ownedByConfig ? t(CONFIG_OWNED_HINT) : undefined}
className="ml-4"
/>
</div>

View file

@ -210,7 +210,7 @@ const RouterSettings: React.FC<RouterSettingsProps> = ({ accessToken, userRole,
<Button
onClick={handleSaveChanges}
disabled={routerSettingsSource === null}
title={routerSettingsSource === null ? SOURCE_UNKNOWN_HINT : undefined}
title={routerSettingsSource === null ? t(SOURCE_UNKNOWN_HINT) : undefined}
>
{t("routerSettingsForm.page.save")}
</Button>

View file

@ -288,10 +288,10 @@ const LoggingSettings: React.FC<LoggingSettingsProps> = ({
</SelectTrigger>
<SelectContent>
{allCallbacks.map((callbackName) => {
const description = callbackInfo[callbackName]?.description;
const descriptionKey = callbackInfo[callbackName]?.descriptionKey;
return (
<SelectItem key={callbackName} value={callbackName}>
<SimpleTooltip content={description} side="right">
<SimpleTooltip content={descriptionKey ? t(descriptionKey) : undefined} side="right">
<div className="flex items-center space-x-2">
<Logo
src={callbackInfo[callbackName]?.logo}
@ -383,10 +383,10 @@ const LoggingSettings: React.FC<LoggingSettingsProps> = ({
</SelectTrigger>
<SelectContent>
{supportedCallbacks.map((callbackName) => {
const description = callbackInfo[callbackName]?.description;
const descriptionKey = callbackInfo[callbackName]?.descriptionKey;
return (
<SelectItem key={callbackName} value={callbackName}>
<SimpleTooltip content={description} side="right">
<SimpleTooltip content={descriptionKey ? t(descriptionKey) : undefined} side="right">
<div className="flex items-center space-x-2">
<Logo
src={callbackInfo[callbackName]?.logo}

View file

@ -50,6 +50,7 @@ import { toast } from "@/lib/toast";
import { CheckIcon, ChevronDown, CircleMinus, CopyIcon, Info, Pencil, Plus, Save } from "lucide-react";
import React, { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import i18n from "@/i18n/i18n";
import { useFieldArray } from "react-hook-form";
import { z } from "zod";
import GuardrailsSelect from "./GuardrailsSelect";
@ -235,7 +236,7 @@ export const grantedMcpServerIds = async ({
}
const loadedTeamGroups = await loadTeamGroups().catch(() => null);
if (loadedTeamGroups === null) {
return { kind: "unresolvable", reason: "the team's access groups could not be reloaded" };
return { kind: "unresolvable", reason: i18n.t("teams.teamInfo.mcpLookup.teamGroupsReloadFailed") };
}
if (sameIdSelection(selectedAccessGroupIds, loadedTeamGroups.ids)) {
return {
@ -243,7 +244,7 @@ export const grantedMcpServerIds = async ({
serverIds: new Set([...direct, ...loadedTeamGroups.serverIds, ...standingServerIds]),
};
}
return { kind: "unresolvable", reason: "the team's access groups could not be loaded" };
return { kind: "unresolvable", reason: i18n.t("teams.teamInfo.mcpLookup.teamGroupsLoadFailed") };
};
export const retainedMcpToolPermissions = (
@ -436,12 +437,12 @@ const buildTeamUpdateFieldsSchema = (t: TFunction) =>
}),
default_estimated_output_tokens: numericInputSchema.refine(
estimateChecks.positive.isValid,
estimateChecks.positive.message,
t(estimateChecks.positive.messageKey),
),
default_estimated_output_tokens_per_model: z
.string()
.optional()
.refine(estimateChecks.perModel.isValid, estimateChecks.perModel.message),
.refine(estimateChecks.perModel.isValid, t(estimateChecks.perModel.messageKey)),
guardrails: z.array(z.string()).optional(),
disable_global_guardrails: z.boolean().optional(),
require_trace_id: z.boolean().optional(),
@ -1396,7 +1397,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
const tabItems = [
{
key: TEAM_INFO_TAB_KEYS.OVERVIEW,
label: TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.OVERVIEW],
label: t(TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.OVERVIEW]),
children: (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
<Card className="block p-6">
@ -1605,17 +1606,17 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
},
{
key: TEAM_INFO_TAB_KEYS.MY_USER,
label: TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.MY_USER],
label: t(TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.MY_USER]),
children: <MyUserTab teamId={teamId} />,
},
{
key: TEAM_INFO_TAB_KEYS.VIRTUAL_KEYS,
label: TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.VIRTUAL_KEYS],
label: t(TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.VIRTUAL_KEYS]),
children: <TeamVirtualKeysTable teamId={teamId} teamAlias={info.team_alias} organization={organization} />,
},
{
key: TEAM_INFO_TAB_KEYS.MEMBERS,
label: TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.MEMBERS],
label: t(TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.MEMBERS]),
children: (
<TeamMembersComponent
teamData={teamData}
@ -1631,12 +1632,12 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
},
{
key: TEAM_INFO_TAB_KEYS.MEMBER_PERMISSIONS,
label: TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.MEMBER_PERMISSIONS],
label: t(TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.MEMBER_PERMISSIONS]),
children: <MemberPermissions teamId={teamId} accessToken={accessToken} canEditTeam={canEditTeam} />,
},
{
key: TEAM_INFO_TAB_KEYS.SETTINGS,
label: TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.SETTINGS],
label: t(TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.SETTINGS]),
children: (
<Card className="block p-6 overflow-y-auto max-h-[65vh]">
<div className="flex justify-between items-center mb-4">

View file

@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { getMethodForEndpoint, getPermissionInfo, PERMISSION_DESCRIPTIONS } from "./permission_definitions";
import i18n from "@/i18n/i18n";
describe("permission_definitions", () => {
describe("getMethodForEndpoint", () => {
@ -27,7 +28,7 @@ describe("permission_definitions", () => {
const result = getPermissionInfo("/key/generate");
expect(result.method).toBe("POST");
expect(result.endpoint).toBe("/key/generate");
expect(result.description).toBe(PERMISSION_DESCRIPTIONS["/key/generate"]);
expect(result.description).toBe(i18n.t(PERMISSION_DESCRIPTIONS["/key/generate"]));
expect(result.route).toBe("/key/generate");
});
@ -35,28 +36,28 @@ describe("permission_definitions", () => {
const result = getPermissionInfo("/key/info");
expect(result.method).toBe("GET");
expect(result.endpoint).toBe("/key/info");
expect(result.description).toBe(PERMISSION_DESCRIPTIONS["/key/info"]);
expect(result.description).toBe(i18n.t(PERMISSION_DESCRIPTIONS["/key/info"]));
});
it("should return GET method for list endpoint", () => {
const result = getPermissionInfo("/key/list");
expect(result.method).toBe("GET");
expect(result.endpoint).toBe("/key/list");
expect(result.description).toBe(PERMISSION_DESCRIPTIONS["/key/list"]);
expect(result.description).toBe(i18n.t(PERMISSION_DESCRIPTIONS["/key/list"]));
});
it("should find partial match for permission with pattern", () => {
const result = getPermissionInfo("/key/service-account/generate");
expect(result.method).toBe("POST");
expect(result.endpoint).toBe("/key/service-account/generate");
expect(result.description).toBe(PERMISSION_DESCRIPTIONS["/key/service-account/generate"]);
expect(result.description).toBe(i18n.t(PERMISSION_DESCRIPTIONS["/key/service-account/generate"]));
});
it("should return correct info for team daily activity permission", () => {
const result = getPermissionInfo("/team/daily/activity");
expect(result.method).toBe("GET");
expect(result.endpoint).toBe("/team/daily/activity");
expect(result.description).toBe(PERMISSION_DESCRIPTIONS["/team/daily/activity"]);
expect(result.description).toBe(i18n.t(PERMISSION_DESCRIPTIONS["/team/daily/activity"]));
expect(result.route).toBe("/team/daily/activity");
});
@ -72,12 +73,12 @@ describe("permission_definitions", () => {
describe("PERMISSION_DESCRIPTIONS", () => {
it("should include team daily activity permission", () => {
expect(PERMISSION_DESCRIPTIONS["/team/daily/activity"]).toBeDefined();
expect(PERMISSION_DESCRIPTIONS["/team/daily/activity"]).toContain("team usage");
expect(i18n.t(PERMISSION_DESCRIPTIONS["/team/daily/activity"])).toContain("team usage");
});
it("should include spend logs permission", () => {
expect(PERMISSION_DESCRIPTIONS["/spend/logs"]).toBeDefined();
expect(PERMISSION_DESCRIPTIONS["/spend/logs"]).toContain("spend logs");
expect(i18n.t(PERMISSION_DESCRIPTIONS["/spend/logs"])).toContain("spend logs");
});
});
@ -90,7 +91,7 @@ describe("permission_definitions", () => {
const result = getPermissionInfo("/spend/logs");
expect(result.method).toBe("GET");
expect(result.endpoint).toBe("/spend/logs");
expect(result.description).toBe(PERMISSION_DESCRIPTIONS["/spend/logs"]);
expect(result.description).toBe(i18n.t(PERMISSION_DESCRIPTIONS["/spend/logs"]));
expect(result.route).toBe("/spend/logs");
});
});

View file

@ -1,3 +1,6 @@
import type { ParseKeys } from "i18next";
import i18n from "@/i18n/i18n";
export interface PermissionInfo {
method: string;
endpoint: string;
@ -8,22 +11,21 @@ export interface PermissionInfo {
/**
* Map of permission endpoint patterns to their descriptions
*/
export const PERMISSION_DESCRIPTIONS: Record<string, string> = {
"/auto_router/manage": "Member can create auto routers for this team and edit their own router configurations",
"/key/generate": "Member can generate a virtual key for this team",
"/key/service-account/generate":
"Member can generate a service account key (not belonging to any user) for this team",
"/key/update": "Member can update a virtual key belonging to this team",
"/key/delete": "Member can delete a virtual key belonging to this team",
"/key/info": "Member can get info about a virtual key belonging to this team",
"/key/regenerate": "Member can regenerate a virtual key belonging to this team",
"/key/{key_id}/regenerate": "Member can regenerate a virtual key belonging to this team",
"/key/list": "Member can list virtual keys belonging to this team",
"/key/block": "Member can block a virtual key belonging to this team",
"/key/unblock": "Member can unblock a virtual key belonging to this team",
"/key/access_group_assignment": "Member can assign access groups to virtual keys for this team",
"/team/daily/activity": "Member can view all team usage data (not just their own)",
"/spend/logs": "Member can view spend logs for the entire team (not just their own)",
export const PERMISSION_DESCRIPTIONS: Record<string, ParseKeys> = {
"/auto_router/manage": "teams.memberPermissions.descriptions.autoRouterManage",
"/key/generate": "teams.memberPermissions.descriptions.keyGenerate",
"/key/service-account/generate": "teams.memberPermissions.descriptions.keyServiceAccountGenerate",
"/key/update": "teams.memberPermissions.descriptions.keyUpdate",
"/key/delete": "teams.memberPermissions.descriptions.keyDelete",
"/key/info": "teams.memberPermissions.descriptions.keyInfo",
"/key/regenerate": "teams.memberPermissions.descriptions.keyRegenerate",
"/key/{key_id}/regenerate": "teams.memberPermissions.descriptions.keyIdRegenerate",
"/key/list": "teams.memberPermissions.descriptions.keyList",
"/key/block": "teams.memberPermissions.descriptions.keyBlock",
"/key/unblock": "teams.memberPermissions.descriptions.keyUnblock",
"/key/access_group_assignment": "teams.memberPermissions.descriptions.keyAccessGroupAssignment",
"/team/daily/activity": "teams.memberPermissions.descriptions.teamDailyActivity",
"/spend/logs": "teams.memberPermissions.descriptions.spendLogs",
};
/**
@ -49,22 +51,24 @@ export const getPermissionInfo = (permission: string): PermissionInfo => {
const endpoint = permission;
// Find exact match or fallback to default description
let description = PERMISSION_DESCRIPTIONS[permission];
let descriptionKey = Object.hasOwn(PERMISSION_DESCRIPTIONS, permission)
? PERMISSION_DESCRIPTIONS[permission]
: undefined;
// If no exact match, try to find a partial match based on patterns
if (!description) {
for (const [pattern, desc] of Object.entries(PERMISSION_DESCRIPTIONS)) {
if (!descriptionKey) {
for (const [pattern, key] of Object.entries(PERMISSION_DESCRIPTIONS)) {
if (permission.includes(pattern)) {
description = desc;
descriptionKey = key;
break;
}
}
}
// Fallback if no match found
if (!description) {
description = `Access ${permission}`;
}
const description = descriptionKey
? i18n.t(descriptionKey)
: i18n.t("teams.memberPermissions.descriptions.fallback", { permission });
return {
method,

View file

@ -6,16 +6,17 @@ import {
TEAM_INFO_TAB_KEYS,
TEAM_INFO_TAB_LABELS,
} from "./tabVisibilityUtils";
import i18n from "@/i18n/i18n";
describe("team_info_tabs", () => {
describe("TEAM_INFO_TAB_LABELS", () => {
it("should have label for every tab key", () => {
expect(TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.OVERVIEW]).toBe("Overview");
expect(TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.MY_USER]).toBe("My User");
expect(TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.VIRTUAL_KEYS]).toBe("Virtual Keys");
expect(TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.MEMBERS]).toBe("Members");
expect(TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.MEMBER_PERMISSIONS]).toBe("Member Permissions");
expect(TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.SETTINGS]).toBe("Settings");
expect(i18n.t(TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.OVERVIEW])).toBe("Overview");
expect(i18n.t(TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.MY_USER])).toBe("My User");
expect(i18n.t(TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.VIRTUAL_KEYS])).toBe("Virtual Keys");
expect(i18n.t(TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.MEMBERS])).toBe("Members");
expect(i18n.t(TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.MEMBER_PERMISSIONS])).toBe("Member Permissions");
expect(i18n.t(TEAM_INFO_TAB_LABELS[TEAM_INFO_TAB_KEYS.SETTINGS])).toBe("Settings");
});
});

View file

@ -1,3 +1,5 @@
import type { ParseKeys } from "i18next";
/**
* Team info tab configuration and permission logic.
* Extracted for testability - permission rules can be unit tested in isolation.
@ -12,13 +14,13 @@ export const TEAM_INFO_TAB_KEYS = {
SETTINGS: "settings",
} as const;
export const TEAM_INFO_TAB_LABELS: Record<string, string> = {
[TEAM_INFO_TAB_KEYS.OVERVIEW]: "Overview",
[TEAM_INFO_TAB_KEYS.MY_USER]: "My User",
[TEAM_INFO_TAB_KEYS.VIRTUAL_KEYS]: "Virtual Keys",
[TEAM_INFO_TAB_KEYS.MEMBERS]: "Members",
[TEAM_INFO_TAB_KEYS.MEMBER_PERMISSIONS]: "Member Permissions",
[TEAM_INFO_TAB_KEYS.SETTINGS]: "Settings",
export const TEAM_INFO_TAB_LABELS: Record<string, ParseKeys> = {
[TEAM_INFO_TAB_KEYS.OVERVIEW]: "teams.teamInfo.tabs.overview",
[TEAM_INFO_TAB_KEYS.MY_USER]: "teams.teamInfo.tabs.myUser",
[TEAM_INFO_TAB_KEYS.VIRTUAL_KEYS]: "teams.teamInfo.tabs.virtualKeys",
[TEAM_INFO_TAB_KEYS.MEMBERS]: "teams.teamInfo.tabs.members",
[TEAM_INFO_TAB_KEYS.MEMBER_PERMISSIONS]: "teams.teamInfo.tabs.memberPermissions",
[TEAM_INFO_TAB_KEYS.SETTINGS]: "teams.teamInfo.tabs.settings",
};
/**

View file

@ -8,8 +8,6 @@ type FormValues = Record<string, unknown>;
const ESTIMATE_FIELD = "default_estimated_output_tokens";
const PER_MODEL_FIELD = "default_estimated_output_tokens_per_model";
const INVALID_PER_MODEL_MESSAGE = 'Enter a JSON object of positive integers, e.g. {"gpt-4": 4096}';
const perModelEstimateToText = (value: unknown): string =>
value != null && typeof value === "object" ? JSON.stringify(value) : "";
@ -49,17 +47,18 @@ export const estimateChecks = {
perModel: {
isValid: (value: unknown): boolean =>
typeof value !== "string" || value.trim() === "" ? true : parsePerModelEstimates(value) !== null,
message: INVALID_PER_MODEL_MESSAGE,
messageKey: "keyDetail.estimatedOutputTokens.invalidPerModel" as ParseKeys,
},
positive: {
isValid: (value: unknown): boolean =>
value === "" || value === null || value === undefined ? true : isPositiveInteger(Number(value)),
message: "Enter a positive integer",
messageKey: "keyDetail.estimatedOutputTokens.invalidPositive" as ParseKeys,
},
};
const asValidatorRule = ({ isValid, message }: { isValid: (value: unknown) => boolean; message: string }) => ({
validator: (_: unknown, value: unknown) => (isValid(value) ? Promise.resolve() : Promise.reject(new Error(message))),
const asValidatorRule = ({ isValid, messageKey }: { isValid: (value: unknown) => boolean; messageKey: ParseKeys }) => ({
validator: (_: unknown, value: unknown) =>
isValid(value) ? Promise.resolve() : Promise.reject(new Error(i18n.t(messageKey))),
});
export const estimateRules = {

View file

@ -1,4 +1,5 @@
import { z } from "zod";
import i18n from "@/i18n/i18n";
import { UseFormReturn, UseFormSetValue } from "react-hook-form";
import { KeyResponse } from "../key_team_helpers/key_list";
@ -142,11 +143,11 @@ export const keyEditFormSchema = z.object({
model_rpm_limit: z.custom<string | undefined>().optional(),
default_estimated_output_tokens: z
.custom<number | string | null | undefined>()
.refine(estimateChecks.positive.isValid, estimateChecks.positive.message)
.refine(estimateChecks.positive.isValid, { error: () => i18n.t(estimateChecks.positive.messageKey) })
.optional(),
default_estimated_output_tokens_per_model: z
.custom<string | undefined>()
.refine(estimateChecks.perModel.isValid, estimateChecks.perModel.message)
.refine(estimateChecks.perModel.isValid, { error: () => i18n.t(estimateChecks.perModel.messageKey) })
.optional(),
guardrails: z.custom<string[] | undefined>().optional(),
disable_global_guardrails: z.custom<boolean | undefined>().optional(),

View file

@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { chooseSelectOption, renderWithProviders, testQueryClient } from "../../../tests/test-utils";
import { KeyResponse } from "../key_team_helpers/key_list";
import { MODEL_MAX_BUDGET_PREMIUM_HINT } from "../key_team_helpers/ModelMaxBudgetEditor";
import i18n from "@/i18n/i18n";
import {
fetchMCPServers,
getPassThroughEndpointsCall,
@ -1501,14 +1502,14 @@ describe("KeyEditView", () => {
it("should say why the editor is locked when the proxy has no enterprise license", async () => {
renderWith(false);
expect(await screen.findByText(MODEL_MAX_BUDGET_PREMIUM_HINT)).toBeInTheDocument();
expect(await screen.findByText(i18n.t(MODEL_MAX_BUDGET_PREMIUM_HINT))).toBeInTheDocument();
});
it("should leave the editor usable when the proxy has one", async () => {
renderWith(true);
expect(await screen.findByText(/Cap spend per model over its own window/)).toBeInTheDocument();
expect(screen.queryByText(MODEL_MAX_BUDGET_PREMIUM_HINT)).not.toBeInTheDocument();
expect(screen.queryByText(i18n.t(MODEL_MAX_BUDGET_PREMIUM_HINT))).not.toBeInTheDocument();
});
// /key/update validates model_max_budget whenever the field is present and

View file

@ -55,6 +55,7 @@ export interface VectorStoreFieldConfig {
label: ParseKeys;
tooltip: ParseKeys;
placeholder?: string;
placeholderKey?: ParseKeys;
required: boolean;
type?: "text" | "password" | "select";
options?: { value: string; label: string }[];
@ -189,7 +190,7 @@ export const vectorStoreProviderFields: Record<string, VectorStoreFieldConfig[]>
name: "api_key",
label: "vectorStores.providerFields.labels.sidecarApiKey",
tooltip: "vectorStores.providerFields.tooltips.mongodb.apiKey",
placeholder: "Enter sidecar API key",
placeholderKey: "vectorStores.providerFields.placeholders.sidecarApiKey",
required: true,
type: "password",
},

View file

@ -150,6 +150,70 @@
"noMatchingOptions": "No matching options"
},
"formConfig": {
"fields": {
"name": {
"label": "Display Name",
"placeholder": "e.g., Customer Support Agent"
},
"description": {
"label": "Description",
"placeholder": "Describe what this agent does..."
},
"url": {
"label": "URL",
"tooltip": "Base URL where the agent is hosted (optional)"
},
"version": {
"label": "Version"
},
"protocolVersion": {
"label": "Protocol Version",
"tooltip": "The A2A protocol version LiteLLM serves to clients for this agent. LiteLLM converts the upstream agent's responses to this version, so clients always see the version you pick here regardless of the original agent's version."
},
"skills": {
"label": "Skills"
},
"streaming": {
"label": "Streaming"
},
"pushNotifications": {
"label": "Push Notifications"
},
"stateTransitionHistory": {
"label": "State Transition History"
},
"iconUrl": {
"label": "Icon URL"
},
"documentationUrl": {
"label": "Documentation URL"
},
"supportsAuthenticatedExtendedCard": {
"label": "Supports Authenticated Extended Card"
},
"model": {
"label": "Model (Optional)"
},
"makePublic": {
"label": "Make Public"
},
"costPerQuery": {
"label": "Cost Per Query ($)",
"tooltip": "Fixed cost per query"
},
"inputCostPerToken": {
"label": "Input Cost Per Token ($)",
"tooltip": "Cost per input token"
},
"outputCostPerToken": {
"label": "Output Cost Per Token ($)",
"tooltip": "Cost per output token"
},
"enableTracing": {
"label": "Enable Tracing",
"tooltip": "Enable request tracing for this agent"
}
},
"basic": {
"title": "Basic Information",
"protocolVersionHelp": "LiteLLM serves this version to clients and converts the upstream agent's responses to match it, regardless of the original agent's version."
@ -651,6 +715,12 @@
}
},
"documents": {
"statuses": {
"uploading": "Uploading",
"ready": "Ready",
"error": "Error",
"removed": "Removed"
},
"empty": {
"title": "No documents uploaded yet",
"description": "Upload documents above to get started."
@ -956,6 +1026,9 @@
}
},
"providerFields": {
"placeholders": {
"sidecarApiKey": "Enter sidecar API key"
},
"labels": {
"apiBase": "API Base",
"apiKey": "API Key",
@ -2253,6 +2326,8 @@
}
},
"estimatedOutputTokens": {
"invalidPerModel": "Enter a JSON object of positive integers, e.g. {\"gpt-4\": 4096}",
"invalidPositive": "Enter a positive integer",
"adminOnlyTooltip": "Only a proxy admin can change this. It sets how many output tokens the rate limiter reserves for a request that omits max_tokens, which is charged against the team and organization TPM windows.",
"estimateTooltip": "Expected output tokens reserved for TPM limiting when a request omits max_tokens. Overrides the built-in estimate for this {{entity}}.",
"perModelTooltip": "Per-model expected output tokens reserved for TPM limiting when a request omits max_tokens. Takes precedence over the {{entity}}-wide estimate."
@ -2274,6 +2349,10 @@
},
"apiKeys": {
"keyTableColumns": {
"sortFields": {
"spend": "Spend",
"budget": "Budget"
},
"status": {
"deleted": "Deleted",
"deletedTooltip": "Deleted {{date}}{{deletedBy}}. Kept for audit and spend history; requests using this key are rejected.",
@ -2315,6 +2394,20 @@
}
},
"virtualKeysTable": {
"filterLabels": {
"team": "Team",
"organization": "Organization",
"userId": "User ID",
"keyId": "Key ID",
"status": "Status"
},
"statuses": {
"all": "All statuses",
"active": "Active",
"expired": "Expired",
"revoked": "Revoked (blocked)",
"deleted": "Deleted"
},
"loadingKey": "Loading key...",
"title": "Virtual Keys",
"description": "Every key that authenticates requests to the gateway.",
@ -2652,6 +2745,15 @@
"allProviderModels": "All {{provider}} models"
},
"modelMaxBudgetEditor": {
"periods": {
"hourly": "Hourly",
"daily": "Daily",
"weekly": "Weekly",
"monthly": "Monthly",
"calendarMonth": "Calendar month"
},
"modelPattern": "Model pattern",
"premiumHint": "Premium feature - Upgrade to set per-model budgets",
"blurb": "Cap spend per model over its own window. A budget set on the bare model name also covers the provider-prefixed spelling of that model. Pick an access group, or type a pattern like claude-opus*, to share one budget across every model it covers.",
"addModelBudget": "Add Model Budget",
"removeModelBudget": "Remove model budget",
@ -2676,6 +2778,24 @@
"triedInOrder": "Tried in order; first model still within its own budget is used"
},
"budgetWindowsEditor": {
"options": {
"hourly": {
"label": "Hourly",
"resetHint": "Resets every hour"
},
"daily": {
"label": "Daily",
"resetHint": "Resets daily at midnight UTC"
},
"weekly": {
"label": "Weekly",
"resetHint": "Resets every Sunday at midnight UTC"
},
"monthly": {
"label": "Monthly",
"resetHint": "Resets on the 1st of every month at midnight UTC"
}
},
"maxSpend": "Max spend ($)",
"addWindow": "+ Add Budget Window"
},
@ -2752,6 +2872,10 @@
"copy": "Copy"
},
"routerSettingsForm": {
"configOwned": {
"hint": "Set in config.yaml. Edit the file to change it.",
"sourceUnknown": "Could not load which settings config.yaml owns. Reload the page to make changes."
},
"page": {
"updated": "router settings updated successfully",
"updateFailed": "Failed to update router settings: ",
@ -2917,6 +3041,27 @@
"placeholder": "Select pass through routes",
"empty": "No pass through routes found"
},
"callbackInfo": {
"descriptions": {
"arize": "Arize Logging Integration",
"braintrust": "Braintrust Logging Integration",
"customCallbackApi": "Custom Callback API Logging Integration",
"galileo": "Galileo AI Observability Integration",
"datadog": "Datadog Logging Integration",
"newrelic": "New Relic Logging Integration",
"lago": "Lago Billing Logging Integration",
"langfuse": "Langfuse v2 Logging Integration",
"langfuseOtel": "Langfuse v3 OTEL Logging Integration",
"langsmith": "Langsmith Logging Integration",
"openmeter": "OpenMeter Logging Integration",
"otel": "OpenTelemetry Logging Integration",
"pointfive": "PointFive Logging Integration",
"zerobus": "Databricks Zerobus Ingest Logging Integration",
"s3": "S3 Bucket (AWS) Logging Integration",
"signoz": "SigNoz Logging Integration. Setup: https://signoz.io/docs/litellm-observability/",
"sqs": "SQS Queue (AWS) Logging Integration"
}
},
"projects": {
"page": {
"title": "Projects",
@ -3068,6 +3213,13 @@
}
},
"budgets": {
"durationFilterOptions": {
"hourly": "hourly",
"daily": "daily",
"weekly": "weekly",
"monthly": "monthly",
"notSet": "Not set"
},
"panel": {
"title": "Budgets",
"description": "Spend, TPM and RPM limits you can assign to customers.",
@ -3100,6 +3252,11 @@
}
},
"table": {
"filterLabels": {
"reset": "Reset",
"maxBudget": "Max Budget",
"created": "Created"
},
"loading": "Loading budgets…",
"searchPlaceholder": "Search by budget ID…",
"emptyState": {
@ -3155,6 +3312,12 @@
}
},
"form": {
"resetOptions": {
"inTotal": "in total",
"perDay": "per day",
"perWeek": "per week",
"perMonth": "per month"
},
"noLimit": "No limit",
"optional": "Optional",
"spendLimit": "Spend limit",
@ -3190,6 +3353,10 @@
},
"costTracking": {
"settings": {
"docsLinks": {
"customPricing": "Custom pricing for models",
"spendTracking": "Spend tracking"
},
"removal": {
"discount": {
"title": "Remove Provider Discount",
@ -3296,6 +3463,7 @@
},
"addMarginForm": {
"provider": {
"global": "Global (All Providers)",
"label": "Provider",
"hint": "Select 'Global' to apply margin to all providers, or select a specific provider",
"placeholder": "Select provider or 'Global'",
@ -3854,6 +4022,12 @@
"loadFailed": "Existing evaluations could not be loaded. Refresh the page to retry.",
"loading": "Loading evaluations...",
"start": {
"directionOptions": {
"forward": "Adoption check: key's traffic vs the router",
"reverse": "Regression check: router's picks vs a baseline"
},
"durationDays_one": "{{count}} day",
"durationDays_other": "{{count}} days",
"description": {
"forward": "Duplicates a sampled slice of the selected targets' traffic (keys, teams, or users) through the auto-router and has an LLM judge compare both answers blind. Each target gets its own spend budget. The router's answers are never served to users; judge calls bill to the sampled traffic's own identity.",
"reverse": "Duplicates a sampled slice of the traffic the auto-router already serves against a fixed baseline model and has an LLM judge compare both answers blind. Each target gets its own spend budget. The baseline's answers are never served to users; judge calls bill to the sampled traffic's own identity."
@ -15326,6 +15500,14 @@
},
"teams": {
"teamInfo": {
"tabs": {
"overview": "Overview",
"myUser": "My User",
"virtualKeys": "Virtual Keys",
"members": "Members",
"memberPermissions": "Member Permissions",
"settings": "Settings"
},
"toasts": {
"mcpUnresolvable": "Cannot save MCP tool permissions because {{reason}}. Retry once the page has finished loading",
"loadFailed": "Failed to load team information",
@ -15351,6 +15533,8 @@
"tpmOrRpmRequired": "Set at least one of TPM or RPM"
},
"mcpLookup": {
"teamGroupsReloadFailed": "the team's access groups could not be reloaded",
"teamGroupsLoadFailed": "the team's access groups could not be loaded",
"serversFailed": "the MCP server list could not be loaded",
"toolsetsFailed": "the MCP toolset list could not be loaded",
"accessGroupsFailed": "the access group list could not be loaded",
@ -15671,6 +15855,23 @@
}
},
"memberPermissions": {
"descriptions": {
"autoRouterManage": "Member can create auto routers for this team and edit their own router configurations",
"keyGenerate": "Member can generate a virtual key for this team",
"keyServiceAccountGenerate": "Member can generate a service account key (not belonging to any user) for this team",
"keyUpdate": "Member can update a virtual key belonging to this team",
"keyDelete": "Member can delete a virtual key belonging to this team",
"keyInfo": "Member can get info about a virtual key belonging to this team",
"keyRegenerate": "Member can regenerate a virtual key belonging to this team",
"keyIdRegenerate": "Member can regenerate a virtual key belonging to this team",
"keyList": "Member can list virtual keys belonging to this team",
"keyBlock": "Member can block a virtual key belonging to this team",
"keyUnblock": "Member can unblock a virtual key belonging to this team",
"keyAccessGroupAssignment": "Member can assign access groups to virtual keys for this team",
"teamDailyActivity": "Member can view all team usage data (not just their own)",
"spendLogs": "Member can view spend logs for the entire team (not just their own)",
"fallback": "Access {{permission}}"
},
"toasts": {
"loadFailed": "Failed to load permissions",
"updated": "Permissions updated successfully",