mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-11 08:38:24 +00:00
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:
parent
4cd169f3bd
commit
69ffe4279e
38 changed files with 553 additions and 273 deletions
|
|
@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue