diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index df70c086a46..dd428b286bd 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -1209,6 +1209,11 @@ "count": 1 } }, + "src/components/ModelInfoEditForm.tsx": { + "max-lines": { + "count": 1 + } + }, "src/components/Navbar/BlogDropdown/BlogDropdown.test.tsx": { "max-nested-callbacks": { "count": 10 @@ -1319,6 +1324,11 @@ "count": 1 } }, + "src/components/add_model/ComplexityRouterConfig.tsx": { + "max-lines": { + "count": 1 + } + }, "src/components/add_model/RouterConfigBuilder.tsx": { "react-hooks/set-state-in-effect": { "count": 1 @@ -1344,6 +1354,11 @@ "count": 1 } }, + "src/components/add_model/build_complexity_router_config.ts": { + "max-lines": { + "count": 1 + } + }, "src/components/add_model/cache_control_settings.tsx": { "local/filename-pascal-case": { "count": 1 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/model-insights/_components/ModelInsightsView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/model-insights/_components/ModelInsightsView.tsx index ec3b5066d2f..1e1f3944267 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/model-insights/_components/ModelInsightsView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/model-insights/_components/ModelInsightsView.tsx @@ -1,7 +1,9 @@ "use client"; import { Page } from "@/components/shared/Page"; +import type { ParseKeys } from "i18next"; import React from "react"; +import { useTranslation } from "react-i18next"; import { ArrowDownRight, ArrowUpRight, BarChart3, Minus } from "lucide-react"; import { StackedUsageChart } from "@/components/shared/charts"; @@ -27,8 +29,15 @@ import { const SCALES = ["linear", "log"] as const; const GRANULARITIES = ["day", "week"] as const; -const GRANULARITY_LABELS: Record = { day: "Daily", week: "Weekly" }; -const METRIC_LABELS: Record = { requests: "requests", spend: "spend", tokens: "tokens" }; +const GRANULARITY_LABELS: Record = { + day: "modelInsights.modelInsightsView.granularity.day", + week: "modelInsights.modelInsightsView.granularity.week", +}; +const METRIC_LABELS: Record = { + requests: "modelInsights.modelInsightsView.metrics.requests", + spend: "modelInsights.modelInsightsView.metrics.spend", + tokens: "modelInsights.modelInsightsView.metrics.tokens", +}; const RANKING_ROWS = 5; type Scale = (typeof SCALES)[number]; @@ -52,22 +61,28 @@ const DeltaBadge = ({ value }: { value: number }) => { ); }; -const RankingRow = ({ model, rank }: { model: RankedModel; rank: number }) => ( -
  • - {rank} - -
    -

    {model.model_group}

    -

    by {model.provider}

    -
    -
    -

    {model.share.toFixed(1)}%

    - -
    -
  • -); +const RankingRow = ({ model, rank }: { model: RankedModel; rank: number }) => { + const { t } = useTranslation(); + return ( +
  • + {rank} + +
    +

    {model.model_group}

    +

    + {t("modelInsights.modelInsightsView.byProvider", { provider: model.provider })} +

    +
    +
    +

    {model.share.toFixed(1)}%

    + +
    +
  • + ); +}; export default function ModelInsightsView({ accessToken }: { accessToken: string | null }) { + const { t } = useTranslation(); const [loaded, setLoaded] = React.useState<{ metric: Metric; response: ModelInsightsResponse } | null>(null); const [metric, setMetric] = React.useState("tokens"); const [scale, setScale] = React.useState("linear"); @@ -114,7 +129,7 @@ export default function ModelInsightsView({ accessToken }: { accessToken: string return (
    - Could not load model insights + {t("modelInsights.modelInsightsView.loadError")} {error}
    @@ -135,19 +150,22 @@ export default function ModelInsightsView({ accessToken }: { accessToken: string - Model Leaderboard + {t("modelInsights.modelInsightsView.title")} - See which models your gateway used from {data.start_date} through {data.end_date} + {t("modelInsights.modelInsightsView.description", { start: data.start_date, end: data.end_date })}
    - Top models + {t("modelInsights.modelInsightsView.topModels.title")} - {GRANULARITY_LABELS[granularity]} {METRIC_LABELS[shown]} across your gateway + {t("modelInsights.modelInsightsView.topModels.description", { + granularity: t(GRANULARITY_LABELS[granularity]), + metric: t(METRIC_LABELS[shown]), + })}
    @@ -161,10 +179,10 @@ export default function ModelInsightsView({ accessToken }: { accessToken: string setGranularity(value as Granularity)}> - + {GRANULARITIES.map((value) => ( - {GRANULARITY_LABELS[value]} + {t(GRANULARITY_LABELS[value])} ))} @@ -187,7 +205,7 @@ export default function ModelInsightsView({ accessToken }: { accessToken: string xKey="date" scale={scale} format={(value) => formatMetric(value, shown)} - totalLabel="Gateway total" + totalLabel={t("modelInsights.modelInsightsView.gatewayTotal")} totalFor={(label) => bucketTotals.get(label) ?? 0} /> @@ -195,9 +213,9 @@ export default function ModelInsightsView({ accessToken }: { accessToken: string - Leaderboard + {t("modelInsights.modelInsightsView.leaderboard.title")} - Share of {METRIC_LABELS[shown]}, with the change between the first and second half of the period + {t("modelInsights.modelInsightsView.leaderboard.description", { metric: t(METRIC_LABELS[shown]) })} @@ -216,14 +234,11 @@ export default function ModelInsightsView({ accessToken }: { accessToken: string - Cost per session - Session cost is not estimated from request counts + {t("modelInsights.modelInsightsView.costPerSession.title")} + {t("modelInsights.modelInsightsView.costPerSession.description")} -

    - Add a stable session_id to requests to unlock accurate session-level model comparisons in a future bounded - session rollup -

    +

    {t("modelInsights.modelInsightsView.costPerSession.hint")}

    diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AccessGroupBudgetColumns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AccessGroupBudgetColumns.tsx index 4401941c2f4..d8b23bf9e48 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AccessGroupBudgetColumns.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AccessGroupBudgetColumns.tsx @@ -1,6 +1,8 @@ "use client"; import { ColumnDef } from "@tanstack/react-table"; +import type { TFunction } from "i18next"; +import { useTranslation } from "react-i18next"; import { MoreHorizontal, Trash2, Wallet } from "lucide-react"; import { getBudgetDurationLabel } from "@/components/common_components/budget_duration_dropdown"; @@ -15,6 +17,7 @@ import { } from "@/components/ui/dropdown-menu"; import { cn } from "@/lib/cva.config"; import { ModelAccessGroup } from "@/app/(dashboard)/hooks/modelAccessGroups/useModelAccessGroups"; +import i18n from "@/i18n/i18n"; const budgetDecimals = (maxBudget: number | null | undefined): number => maxBudget != null && maxBudget > 0 && maxBudget < 0.01 ? 5 : 2; @@ -25,10 +28,14 @@ const budgetDecimals = (maxBudget: number | null | undefined): number => */ export const isBudgetAddressable = (accessGroup: string): boolean => !accessGroup.includes("/"); -const writeBlockedReason = (accessGroup: ModelAccessGroup, canWrite: boolean): string | undefined => { - if (!canWrite) return "Only a proxy admin can change an access group budget"; +const writeBlockedReason = ( + accessGroup: ModelAccessGroup, + canWrite: boolean, + t: TFunction = i18n.t.bind(i18n), +): string | undefined => { + if (!canWrite) return t("modelsAndEndpoints.accessGroupBudgetColumns.blocked.notAdmin"); if (!isBudgetAddressable(accessGroup.access_group)) { - return "A budget cannot be set on a group whose name contains a slash"; + return t("modelsAndEndpoints.accessGroupBudgetColumns.blocked.slashInName"); } return undefined; }; @@ -41,13 +48,16 @@ interface AccessGroupRowActionsProps { } function AccessGroupRowActions({ accessGroup, canWrite, onSetBudget, onClearBudget }: AccessGroupRowActionsProps) { + const { t } = useTranslation(); const hasBudget = accessGroup.budget != null; - const blocked = writeBlockedReason(accessGroup, canWrite); + const blocked = writeBlockedReason(accessGroup, canWrite, t); return ( @@ -61,17 +71,21 @@ function AccessGroupRowActions({ accessGroup, canWrite, onSetBudget, onClearBudg onClick={() => onSetBudget(accessGroup)} > - {hasBudget ? "Edit budget" : "Set budget"} + {hasBudget + ? t("modelsAndEndpoints.accessGroupBudgetColumns.actions.editBudget") + : t("modelsAndEndpoints.accessGroupBudgetColumns.actions.setBudget")} onClearBudget(accessGroup)} > - Clear budget + {t("modelsAndEndpoints.accessGroupBudgetColumns.actions.clearBudget")} @@ -84,16 +98,20 @@ interface AccessGroupBudgetColumnsDeps { onClearBudget: (accessGroup: ModelAccessGroup) => void; } -export const getAccessGroupBudgetColumns = ({ - canWrite, - onSetBudget, - onClearBudget, -}: AccessGroupBudgetColumnsDeps): ColumnDef[] => [ +export const getAccessGroupBudgetColumns = ( + { canWrite, onSetBudget, onClearBudget }: AccessGroupBudgetColumnsDeps, + t: TFunction = i18n.t.bind(i18n), +): ColumnDef[] => [ { id: "access_group", accessorKey: "access_group", - meta: { title: "Access Group" }, - header: ({ column }) => , + meta: { title: t("modelsAndEndpoints.accessGroupBudgetColumns.columns.accessGroup") }, + header: ({ column }) => ( + + ), size: 220, enableSorting: true, cell: ({ row }) => ( @@ -104,8 +122,8 @@ export const getAccessGroupBudgetColumns = ({ }, { id: "models", - meta: { title: "Models", skeleton: "chips" }, - header: "Models", + meta: { title: t("modelsAndEndpoints.accessGroupBudgetColumns.columns.models"), skeleton: "chips" }, + header: t("modelsAndEndpoints.accessGroupBudgetColumns.columns.models"), size: 280, enableSorting: false, cell: ({ row }) => , @@ -113,8 +131,13 @@ export const getAccessGroupBudgetColumns = ({ { id: "deployment_count", accessorKey: "deployment_count", - meta: { title: "Deployments", numeric: true }, - header: ({ column }) => , + meta: { title: t("modelsAndEndpoints.accessGroupBudgetColumns.columns.deployments"), numeric: true }, + header: ({ column }) => ( + + ), size: 120, enableSorting: true, cell: ({ row }) => row.original.deployment_count, @@ -122,8 +145,13 @@ export const getAccessGroupBudgetColumns = ({ { id: "spend", accessorKey: "spend", - meta: { title: "Shared Spend" }, - header: ({ column }) => , + meta: { title: t("modelsAndEndpoints.accessGroupBudgetColumns.columns.sharedSpend") }, + header: ({ column }) => ( + + ), size: 180, enableSorting: true, cell: ({ row }) => ( @@ -136,8 +164,8 @@ export const getAccessGroupBudgetColumns = ({ }, { id: "budget_duration", - meta: { title: "Resets" }, - header: "Resets", + meta: { title: t("modelsAndEndpoints.accessGroupBudgetColumns.columns.resets") }, + header: t("modelsAndEndpoints.accessGroupBudgetColumns.columns.resets"), size: 110, enableSorting: false, cell: ({ row }) => ( @@ -149,7 +177,7 @@ export const getAccessGroupBudgetColumns = ({ { id: "actions", meta: { className: "text-right", headerClassName: "text-right" }, - header: () => Actions, + header: () => {t("modelsAndEndpoints.accessGroupBudgetColumns.columns.actions")}, size: 64, enableSorting: false, enableHiding: false, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AccessGroupBudgetModal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AccessGroupBudgetModal.tsx index 54decbfff3d..cf030b0a7bc 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AccessGroupBudgetModal.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AccessGroupBudgetModal.tsx @@ -2,7 +2,9 @@ import { CircleHelp } from "lucide-react"; import React from "react"; +import { Trans, useTranslation } from "react-i18next"; import { z } from "zod"; +import i18n from "@/i18n/i18n"; import BudgetDurationDropdown from "@/components/common_components/budget_duration_dropdown"; import { FieldGroup } from "@/components/ui/field"; import { FormField } from "@/components/shared/form/FormField"; @@ -32,7 +34,7 @@ const budgetSchema = z budget_duration: z.string().optional(), }) .refine(hasAnyBudgetValue, { - message: "Set at least one of max budget, soft budget or reset window", + error: () => i18n.t("modelsAndEndpoints.accessGroupBudgetModal.validation.atLeastOne"), path: ["max_budget"], }); @@ -49,6 +51,7 @@ const AccessGroupBudgetModal: React.FC = ({ onCancel, onSubmit, }) => { + const { t } = useTranslation(); const budget = accessGroup?.budget ?? null; const form = useZodForm(budgetSchema, { values: accessGroupBudgetFormValues(budget) }); @@ -57,12 +60,19 @@ const AccessGroupBudgetModal: React.FC = ({ - {budget ? "Edit" : "Set"} budget for "{accessGroup?.access_group}" + {t("modelsAndEndpoints.accessGroupBudgetModal.title", { + action: budget + ? t("modelsAndEndpoints.accessGroupBudgetModal.edit") + : t("modelsAndEndpoints.accessGroupBudgetModal.set"), + accessGroup: accessGroup?.access_group, + })}

    - Every key granted this access group by name draws from this one budget. A key that reaches the group's - models through a wildcard or all-proxy-models is not charged against it. + all-proxy-models }} + />

    onSubmit(buildAccessGroupBudgetBody(values)))} noValidate> @@ -71,8 +81,8 @@ const AccessGroupBudgetModal: React.FC = ({ control={form.control} name="max_budget" label={labelWithHint( - "Max Budget (USD)", - "Total the whole group may spend. Once its shared spend reaches this, every key that draws from the group is refused", + t("modelsAndEndpoints.accessGroupBudgetModal.maxBudget.label"), + t("modelsAndEndpoints.accessGroupBudgetModal.maxBudget.hint"), )} > {({ ref, value, ...field }) => } @@ -82,8 +92,8 @@ const AccessGroupBudgetModal: React.FC = ({ control={form.control} name="soft_budget" label={labelWithHint( - "Soft Budget (USD)", - "Fires an alert when the group's spend reaches this. Requests keep succeeding", + t("modelsAndEndpoints.accessGroupBudgetModal.softBudget.label"), + t("modelsAndEndpoints.accessGroupBudgetModal.softBudget.hint"), )} > {({ ref, value, ...field }) => } @@ -93,8 +103,8 @@ const AccessGroupBudgetModal: React.FC = ({ control={form.control} name="budget_duration" label={labelWithHint( - "Reset Budget", - "How often the group's spend resets. Leave empty for a budget that never resets", + t("modelsAndEndpoints.accessGroupBudgetModal.resetBudget.label"), + t("modelsAndEndpoints.accessGroupBudgetModal.resetBudget.hint"), )} > {({ id, value, onChange }) => ( @@ -102,22 +112,24 @@ const AccessGroupBudgetModal: React.FC = ({ id={id} value={value || null} onChange={(next) => onChange(next ?? undefined)} - placeholder="Keep current" + placeholder={t("modelsAndEndpoints.accessGroupBudgetModal.resetBudget.keepCurrent")} /> )}

    - A field left blank keeps whatever the budget already has. Use Clear budget to remove the budget itself. + {t("modelsAndEndpoints.accessGroupBudgetModal.blankFieldNote")}

    diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx index 92890031ddd..ff027ac8b3c 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx @@ -6,6 +6,7 @@ import { useCredentials } from "@/app/(dashboard)/hooks/credentials/useCredentia import { credentialLabelsByName } from "@/components/shared/credentialOptions"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import DeleteResourceModal from "@/components/common_components/DeleteResourceModal"; +import i18n from "@/i18n/i18n"; import ModelSettingsModal from "@/components/model_dashboard/ModelSettingsModal/ModelSettingsModal"; import { ModelData } from "@/components/model_dashboard/types"; import { toast } from "@/lib/toast"; @@ -18,6 +19,7 @@ import { ColumnFiltersState, functionalUpdate, OnChangeFn, PaginationState, Sort import { Info } from "lucide-react"; import { createParser, parseAsInteger, parseAsString, parseAsStringLiteral, useQueryStates } from "nuqs"; import { useCallback, useMemo, useState } from "react"; +import { Trans, useTranslation } from "react-i18next"; import { useModelsInfo } from "../../hooks/models/useModels"; import { ROUTING_STATUS_FILTER_ID, routingStatusToBlocked } from "../utils/routingStatus"; @@ -82,6 +84,7 @@ const AllModelsTab = ({ setSelectedModelId, setSelectedTeamId, }: AllModelsTabProps) => { + const { t } = useTranslation(); const { data: modelCostMapData, isLoading: isLoadingModelCostMap } = useModelCostMap(); const { accessToken, userId, userRole, isViewOnly } = useAuthorized(); const { data: teams, isLoading: isLoadingTeams } = useTeams(); @@ -240,7 +243,7 @@ const AllModelsTab = ({ const teamOptions = useMemo( () => [ - { value: PERSONAL_TEAM_VALUE, label: "Personal" }, + { value: PERSONAL_TEAM_VALUE, label: i18n.t("modelsAndEndpoints.allModelsTab.personalTeam") }, ...(teams ?? []) .filter((team) => team.team_id) .map((team) => ({ value: team.team_id, label: team.team_alias ? team.team_alias : team.team_id })), @@ -263,7 +266,7 @@ const AllModelsTab = ({ try { setDeleteLoading(true); await modelDeleteCall(accessToken, deleteModalModelId); - toast.success("Model deleted successfully"); + toast.success(t("modelsAndEndpoints.allModelsTab.toasts.deleted")); queryClient.invalidateQueries({ queryKey: ["models", "list"] }); refetchModels(); } catch (error) { @@ -281,7 +284,11 @@ const AllModelsTab = ({ try { setPausingModelId(modelId); await modelPatchUpdateCall(accessToken, { blocked }, modelId); - toast.success(blocked ? "Model paused" : "Model resumed"); + toast.success( + blocked + ? i18n.t("modelsAndEndpoints.allModelsTab.toasts.paused") + : i18n.t("modelsAndEndpoints.allModelsTab.toasts.resumed"), + ); // invalidateQueries already schedules a refetch for active observers // on this key — no need to also call refetchModels() (would double-fetch). queryClient.invalidateQueries({ queryKey: ["models", "list"] }); @@ -352,19 +359,22 @@ const AllModelsTab = ({ {selectedTeamValue === PERSONAL_TEAM_VALUE ? ( - To access these models, create a Virtual Key without selecting a team on the{" "} - - Virtual Keys page - - . + , + }} + /> ) : ( - To access these models, create a Virtual Key and select Team as "{teamAccessLabel}" on the{" "} - - Virtual Keys page - - . + {teamAccessLabel}, + keys: , + }} + /> )}
    @@ -373,28 +383,29 @@ const AllModelsTab = ({ = { [MODEL_NAME_COLUMN_ID]: "Public Model Name", @@ -42,13 +45,13 @@ const FILTER_LABELS: Record = { }; const ROUTING_STATUS_FILTER_VALUES = { - all: "All", - active: "Active", - paused: "Paused", + all: "modelsAndEndpoints.allModelsTable.status.all", + active: "modelsAndEndpoints.allModelsTable.status.active", + paused: "modelsAndEndpoints.allModelsTable.status.paused", } as const; -const VIEW_MODE_LABELS: Record = { - current_team: "Current Team Models", +const VIEW_MODE_LABELS: Record = { + current_team: "modelsAndEndpoints.allModelsTable.viewModes.currentTeam", all: ALL_PROXY_MODELS_LABEL, }; @@ -93,14 +96,17 @@ interface AllModelsTableProps { } function EmptyState() { + const { t } = useTranslation(); return (
    -
    No models found
    +
    + {t("modelsAndEndpoints.allModelsTable.empty.title")} +
    - No models match your search or filters. Try resetting them. + {t("modelsAndEndpoints.allModelsTable.empty.description")}
    ); @@ -140,6 +146,7 @@ export function AllModelsTable({ pausingModelId, credentialLabels, }: AllModelsTableProps) { + const { t } = useTranslation(); const [filtersOpen, setFiltersOpen] = useState(false); const columns = useMemo(() => { @@ -169,8 +176,8 @@ export function AllModelsTable({ const modelGroupOptions = useMemo( () => [ - { label: ALL_PROXY_MODELS_LABEL, value: ALL_MODEL_GROUPS_VALUE }, - { label: "Wildcard Models (*)", value: WILDCARD_MODEL_GROUP_VALUE }, + { label: i18n.t(ALL_PROXY_MODELS_LABEL), value: ALL_MODEL_GROUPS_VALUE }, + { label: i18n.t("modelsAndEndpoints.allModelsTable.wildcardModels"), value: WILDCARD_MODEL_GROUP_VALUE }, ...availableModelGroups.map((group) => ({ label: group, value: group })), ], [availableModelGroups], @@ -178,7 +185,7 @@ export function AllModelsTable({ const accessGroupOptions = useMemo( () => [ - { label: "All Model Access Groups", value: ALL_MODEL_GROUPS_VALUE }, + { label: i18n.t("modelsAndEndpoints.allModelsTable.allAccessGroups"), value: ALL_MODEL_GROUPS_VALUE }, ...availableModelAccessGroups.map((accessGroup) => ({ label: accessGroup, value: accessGroup })), ], [availableModelAccessGroups], @@ -187,10 +194,12 @@ export function AllModelsTable({ const formatFilterValue = (columnId: string, value: unknown): string => { const raw = String(value); if (columnId === MODEL_NAME_COLUMN_ID && raw === WILDCARD_MODEL_GROUP_VALUE) { - return "Wildcard Models (*)"; + return t("modelsAndEndpoints.allModelsTable.wildcardModels"); } if (columnId === ROUTING_STATUS_FILTER_ID) { - return ROUTING_STATUS_FILTER_VALUES[raw as keyof typeof ROUTING_STATUS_FILTER_VALUES] ?? raw; + return Object.hasOwn(ROUTING_STATUS_FILTER_VALUES, raw) + ? t(ROUTING_STATUS_FILTER_VALUES[raw as keyof typeof ROUTING_STATUS_FILTER_VALUES]) + : raw; } return raw; }; @@ -219,7 +228,7 @@ export function AllModelsTable({ enableColumnResizing maxBodyHeight={MODEL_TABLE_BODY_HEIGHT} isLoading={isLoading} - loadingMessage="Loading models…" + loadingMessage={t("modelsAndEndpoints.allModelsTable.loading")} noDataMessage={} size="compact" toolbar={(table) => ( @@ -228,7 +237,7 @@ export function AllModelsTable({ table={table} searchValue={searchValue} onSearchChange={onSearchChange} - searchPlaceholder="Search model names…" + searchPlaceholder={t("modelsAndEndpoints.allModelsTable.searchPlaceholder")} onOpenFilters={() => setFiltersOpen(true)} onRefresh={onRefresh} isRefreshing={isRefreshing} @@ -238,7 +247,7 @@ export function AllModelsTable({ @@ -283,8 +297,8 @@ export function AllModelsTable({ )} @@ -105,8 +104,8 @@ export function AutoRoutersPanel({ className="max-h-[90vh] overflow-y-auto sm:max-w-4xl" > - Add Auto Router - Choose models for your auto router. + {t("modelsAndEndpoints.autoRoutersPanel.addDialogTitle")} + {t("modelsAndEndpoints.autoRoutersPanel.addDialogDescription")} setDeletingRouter(null)} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersTable.tsx index 2102f5e55d9..bb09da7073c 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersTable.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersTable.tsx @@ -2,6 +2,7 @@ import { SortingState } from "@tanstack/react-table"; import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; import { DataTable } from "@/components/shared/DataTable"; import { AutoRouterIcon } from "@/components/shared/table_cells"; @@ -25,16 +26,17 @@ const DEFAULT_SORTING: SortingState = [ ]; function EmptyState({ canModify }: { canModify: boolean }) { + const { t } = useTranslation(); return (
    -
    No auto routers yet
    +
    {t("modelsAndEndpoints.autoRoutersTable.emptyTitle")}
    {canModify - ? "Create an auto router to pick the right model per request instead of pinning one." - : "An auto router picks the right model per request instead of pinning one."} + ? t("modelsAndEndpoints.autoRoutersTable.emptyCreate") + : t("modelsAndEndpoints.autoRoutersTable.emptyReadOnly")}
    ); @@ -47,9 +49,10 @@ export function AutoRoutersTable({ onRouterClick, onDeleteClick, }: AutoRoutersTableProps) { + const { t } = useTranslation(); const columns = useMemo( - () => getAutoRoutersTableColumns({ canModify, onRouterClick, onDeleteClick }), - [canModify, onRouterClick, onDeleteClick], + () => getAutoRoutersTableColumns({ canModify, onRouterClick, onDeleteClick }, t), + [canModify, onRouterClick, onDeleteClick, t], ); return ( @@ -62,7 +65,7 @@ export function AutoRoutersTable({ paginationMode="client" pageSizeOptions={PAGE_SIZE_OPTIONS} isLoading={isLoading} - loadingMessage="Loading auto routers…" + loadingMessage={t("modelsAndEndpoints.autoRoutersTable.loading")} noDataMessage={} size="compact" /> diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersTableColumns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersTableColumns.tsx index 4a99062988f..4878ebff6bc 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersTableColumns.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersTableColumns.tsx @@ -1,6 +1,8 @@ "use client"; import { ColumnDef } from "@tanstack/react-table"; +import type { TFunction } from "i18next"; +import { useTranslation } from "react-i18next"; import { useEffect, useMemo, useRef, useState } from "react"; import { MoreHorizontal, Trash2 } from "lucide-react"; @@ -14,6 +16,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import i18n from "@/i18n/i18n"; import { cn } from "@/lib/cva.config"; import { AutoRouterRow } from "./autoRouterRows"; @@ -71,10 +74,11 @@ function AutoRouterRowActions({ row: AutoRouterRow; onDeleteClick: (row: AutoRouterRow) => void; }) { + const { t } = useTranslation(); return ( @@ -87,7 +91,7 @@ function AutoRouterRowActions({ onClick={() => onDeleteClick(row)} > - Delete auto router + {t("modelsAndEndpoints.autoRoutersTableColumns.deleteAutoRouter")} @@ -100,16 +104,17 @@ interface AutoRoutersTableColumnsDeps { onDeleteClick: (row: AutoRouterRow) => void; } -export const getAutoRoutersTableColumns = ({ - canModify, - onRouterClick, - onDeleteClick, -}: AutoRoutersTableColumnsDeps): ColumnDef[] => [ +export const getAutoRoutersTableColumns = ( + { canModify, onRouterClick, onDeleteClick }: AutoRoutersTableColumnsDeps, + t: TFunction = i18n.t.bind(i18n), +): ColumnDef[] => [ { id: "name", accessorKey: "name", - meta: { title: "Name" }, - header: ({ column }) => , + meta: { title: t("modelsAndEndpoints.autoRoutersTableColumns.name") }, + header: ({ column }) => ( + + ), size: 260, enableSorting: true, cell: ({ row }) => onRouterClick(row.original)} />, @@ -117,16 +122,16 @@ export const getAutoRoutersTableColumns = ({ { id: "kind", accessorKey: "kind", - meta: { title: "Type" }, - header: "Type", + meta: { title: t("modelsAndEndpoints.autoRoutersTableColumns.type") }, + header: t("modelsAndEndpoints.autoRoutersTableColumns.type"), size: 180, enableSorting: false, cell: ({ row }) => , }, { id: "targets", - meta: { title: "Routes to" }, - header: "Routes to", + meta: { title: t("modelsAndEndpoints.autoRoutersTableColumns.routesTo") }, + header: t("modelsAndEndpoints.autoRoutersTableColumns.routesTo"), size: 320, enableSorting: false, cell: ({ row }) => , @@ -134,8 +139,8 @@ export const getAutoRoutersTableColumns = ({ { id: "defaultModel", accessorKey: "defaultModel", - meta: { title: "Default model" }, - header: "Default model", + meta: { title: t("modelsAndEndpoints.autoRoutersTableColumns.defaultModel") }, + header: t("modelsAndEndpoints.autoRoutersTableColumns.defaultModel"), size: 200, enableSorting: false, cell: ({ row }) => @@ -150,8 +155,10 @@ export const getAutoRoutersTableColumns = ({ { id: "createdAt", accessorKey: "createdAt", - meta: { title: "Created" }, - header: ({ column }) => , + meta: { title: t("modelsAndEndpoints.autoRoutersTableColumns.created") }, + header: ({ column }) => ( + + ), size: 150, enableSorting: true, sortingFn: "datetime", diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelRetrySettingsTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelRetrySettingsTab.tsx index a61eccbb5a2..4d6d542a0b4 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelRetrySettingsTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelRetrySettingsTab.tsx @@ -1,5 +1,6 @@ import { LoaderCircle } from "lucide-react"; import React from "react"; +import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -53,9 +54,10 @@ const ModelRetrySettingsTab = ({ handleSaveRetrySettings, isSaving = false, }: ModelRetrySettingsTabProps) => { + const { t } = useTranslation(); const isGlobalScope = selectedModelGroup === "global"; const scopeItems = [ - { value: "global", label: "Global Default" }, + { value: "global", label: t("modelsAndEndpoints.modelRetrySettingsTab.globalDefault") }, ...availableModelGroups.map((group) => ({ value: group, label: group })), ]; @@ -86,7 +88,7 @@ const ModelRetrySettingsTab = ({ return (
    - +
    {!isGlobalScope && hasOverride && ( )} @@ -161,7 +167,7 @@ const ModelRetrySettingsTab = ({
    ); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelsTableColumns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelsTableColumns.tsx index 1b7369c9520..0e286418355 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelsTableColumns.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelsTableColumns.tsx @@ -1,6 +1,8 @@ "use client"; import { ColumnDef } from "@tanstack/react-table"; +import type { TFunction } from "i18next"; +import { useTranslation } from "react-i18next"; import { CredentialIdentity } from "@/components/shared/CredentialIdentity"; import { Copy, Info, Loader2, Pencil, RefreshCw, Trash2 } from "lucide-react"; @@ -14,6 +16,7 @@ import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/h import { Switch } from "@/components/ui/switch"; import { getDisplayModelName } from "@/components/view_model/model_name_display"; import { copyToClipboard, formatPerSecondCost } from "@/utils/dataUtils"; +import i18n from "@/i18n/i18n"; export const MODEL_ID_COLUMN_ID = "model_info_id"; export const MODEL_NAME_COLUMN_ID = "model_name"; @@ -56,6 +59,7 @@ const formatShortDate = (value: string | null | undefined): string | null => { }; function ModelInformationCell({ model, displayName }: { model: ModelData; displayName: string }) { + const { t } = useTranslation(); const litellmModelName = model.litellm_model_name || "-"; return ( @@ -85,26 +89,37 @@ function ModelInformationCell({ model, displayName }: { model: ModelData; displa
    {model.provider ? : null} - {model.provider || "Unknown provider"} + + {model.provider || t("modelsAndEndpoints.modelsTableColumns.unknownProvider")} +
    - Public Model Name + + {t("modelsAndEndpoints.modelsTableColumns.information.publicModelName")} + {displayName}
    - LiteLLM Model Name + + {t("modelsAndEndpoints.modelsTableColumns.information.litellmModelName")} + {litellmModelName} @@ -117,15 +132,16 @@ function ModelInformationCell({ model, displayName }: { model: ModelData; displa } function CredentialsHeader() { + const { t } = useTranslation(); return ( - Credentials + {t("modelsAndEndpoints.modelsTableColumns.columns.credentials")} @@ -135,23 +151,25 @@ function CredentialsHeader() {
    - Credential types + + {t("modelsAndEndpoints.modelsTableColumns.credentialTypes.title")} +
    - Reusable + {t("modelsAndEndpoints.modelsTableColumns.credentialTypes.reusable")} - Credentials saved in LiteLLM that can be added to models repeatedly. + {t("modelsAndEndpoints.modelsTableColumns.credentialTypes.reusableDescription")}
    - Manual + {t("modelsAndEndpoints.modelsTableColumns.credentialTypes.manual")} - Credentials added directly during model creation or defined in the config file. + {t("modelsAndEndpoints.modelsTableColumns.credentialTypes.manualDescription")}
    @@ -162,11 +180,12 @@ function CredentialsHeader() { } function CredentialsCell({ credentialName, label }: { credentialName: string | undefined; label: string | undefined }) { + const { t } = useTranslation(); if (!credentialName) { return ( - Manual + {t("modelsAndEndpoints.modelsTableColumns.credentialTypes.manual")} ); } @@ -182,10 +201,13 @@ function CredentialsCell({ credentialName, label }: { credentialName: string | u } function CreatedByCell({ model }: { model: ModelData }) { + const { t } = useTranslation(); const isConfigModel = !model.model_info?.db_model; const createdAt = formatShortDate(model.model_info.created_at); - const primary = isConfigModel ? "Defined in config" : model.model_info.created_by || "Unknown"; - const secondaryForDbModel = createdAt ?? "Unknown date"; + const primary = isConfigModel + ? t("modelsAndEndpoints.modelsTableColumns.createdBy.definedInConfig") + : model.model_info.created_by || t("modelsAndEndpoints.modelsTableColumns.createdBy.unknown"); + const secondaryForDbModel = createdAt ?? t("modelsAndEndpoints.modelsTableColumns.createdBy.unknownDate"); return (
    @@ -207,6 +229,7 @@ function CostRow({ label, value }: { label: string; value: string }) { } function CostsCell({ model }: { model: ModelData }) { + const { t } = useTranslation(); const { input_cost: inputCost, output_cost: outputCost, output_cost_per_second: perSecond } = model; const hasPerSecond = perSecond != null; const showInput = inputCost != null && (!hasPerSecond || Number(inputCost) > 0); @@ -218,12 +241,25 @@ function CostsCell({ model }: { model: ModelData }) { return ( - {showInput && } - {showOutput && } - {hasPerSecond && } + {showInput && ( + + )} + {showOutput && ( + + )} + {hasPerSecond && ( + + )}
    } /> @@ -231,6 +267,7 @@ function CostsCell({ model }: { model: ModelData }) { } function AccessGroupsCell({ accessGroups }: { accessGroups: string[] | null }) { + const { t } = useTranslation(); if (!accessGroups || accessGroups.length === 0) { return -; } @@ -253,7 +290,7 @@ function AccessGroupsCell({ accessGroups }: { accessGroups: string[] | null }) { } trigger={ - +{overflow.length} more + {t("modelsAndEndpoints.modelsTableColumns.accessGroups.more", { hiddenCount: overflow.length })} } /> @@ -281,6 +318,7 @@ function ModelRowActions({ onDeleteClick, onTogglePauseClick, }: ModelRowActionsProps) { + const { t } = useTranslation(); const modelId = model.model_info?.id; const isConfigModel = !model.model_info?.db_model; const isAdmin = userRole === "Admin" && !isViewOnly; @@ -290,17 +328,19 @@ function ModelRowActions({ const resolvePauseTooltip = (): string => { if (isConfigModel) { - return "Config models cannot be paused from the dashboard. Pause is DB-backed."; + return t("modelsAndEndpoints.modelsTableColumns.actions.configPauseTooltip"); } if (!isAdmin) { - return "Only proxy admins can pause or resume a model."; + return t("modelsAndEndpoints.modelsTableColumns.actions.adminOnlyPauseTooltip"); } - return isBlocked ? "Resume model — restore normal routing." : "Pause model — stop routing requests until resumed."; + return isBlocked + ? t("modelsAndEndpoints.modelsTableColumns.actions.resumeTooltip") + : t("modelsAndEndpoints.modelsTableColumns.actions.pauseTooltip"); }; const deleteTooltip = isConfigModel - ? "Config model cannot be deleted on the dashboard. Please delete it from the config file." - : "Delete model"; + ? t("modelsAndEndpoints.modelsTableColumns.actions.configDeleteTooltip") + : t("modelsAndEndpoints.modelsTableColumns.actions.delete"); return (
    @@ -319,7 +359,11 @@ function ModelRowActions({ size="sm" checked={!isBlocked} disabled={!isPauseToggleable} - aria-label={isBlocked ? "Resume model" : "Pause model"} + aria-label={ + isBlocked + ? t("modelsAndEndpoints.modelsTableColumns.actions.resume") + : t("modelsAndEndpoints.modelsTableColumns.actions.pause") + } data-testid={`model-pause-toggle-${modelId}`} onCheckedChange={(nextChecked) => { if (isPauseToggleable && onTogglePauseClick && modelId) { @@ -339,7 +383,7 @@ function ModelRowActions({
    diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AccessGroupBudgetsPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AccessGroupBudgetsPanel.tsx index 20eb7b2e64c..d632408c85a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AccessGroupBudgetsPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AccessGroupBudgetsPanel.tsx @@ -3,6 +3,7 @@ import { SortingState } from "@tanstack/react-table"; import { Inbox } from "lucide-react"; import React, { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; import DeleteResourceModal from "@/components/common_components/DeleteResourceModal"; import { DataTable } from "@/components/shared/DataTable"; @@ -21,20 +22,24 @@ import { getAccessGroupBudgetColumns } from "@/app/(dashboard)/models-and-endpoi const DEFAULT_SORTING: SortingState = [{ id: "access_group", desc: false }]; function EmptyState() { + const { t } = useTranslation(); return (
    -
    No model access groups yet
    +
    + {t("modelsAndEndpoints.accessGroupBudgetsPanel.empty.title")} +
    - Put a deployment in an access group from its model settings, then give the group a shared budget here. + {t("modelsAndEndpoints.accessGroupBudgetsPanel.empty.description")}
    ); } export default function AccessGroupBudgetsPanel() { + const { t } = useTranslation(); const { userRole } = useAuthorized(); const { data: accessGroups, isLoading } = useModelAccessGroups(); const setBudget = useSetModelAccessGroupBudget(); @@ -57,7 +62,7 @@ export default function AccessGroupBudgetsPanel() { { accessGroup, params }, { onSuccess: () => { - toast.success(`Budget saved for "${accessGroup}"`); + toast.success(t("modelsAndEndpoints.accessGroupBudgetsPanel.toasts.saved", { accessGroup })); setEditing(null); }, }, @@ -69,7 +74,7 @@ export default function AccessGroupBudgetsPanel() { const accessGroup = clearing.access_group; clearBudget.mutate(accessGroup, { onSuccess: () => { - toast.success(`Budget cleared for "${accessGroup}"`); + toast.success(t("modelsAndEndpoints.accessGroupBudgetsPanel.toasts.cleared", { accessGroup })); setClearing(null); }, }); @@ -77,10 +82,7 @@ export default function AccessGroupBudgetsPanel() { return (
    -

    - A model access group can carry one budget that every key granted the group by name draws from together. Keys - that reach the group's models through a wildcard or all-proxy-models are not charged against it. -

    +

    {t("modelsAndEndpoints.accessGroupBudgetsPanel.description")}

    } size="compact" /> @@ -105,12 +107,19 @@ export default function AccessGroupBudgetsPanel() { setClearing(null)} onOk={handleConfirmClear} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/ModelRetrySettingsPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/ModelRetrySettingsPanel.tsx index a5c10ca8a8b..50dc4908865 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/ModelRetrySettingsPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/ModelRetrySettingsPanel.tsx @@ -1,6 +1,7 @@ "use client"; import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; import ModelRetrySettingsTab from "@/app/(dashboard)/models-and-endpoints/components/ModelRetrySettingsTab"; import { getCallbacksCall } from "@/components/networking"; import { useUpdateRetryPolicy } from "@/app/(dashboard)/hooks/routerSettings/useUpdateRetryPolicy"; @@ -23,6 +24,7 @@ interface RouterSettings { } export default function ModelRetrySettingsPanel() { + const { t } = useTranslation(); const { accessToken, userId: userID, userRole } = useAuthorized(); const { availableModelGroups } = useModelDashboardData(); const updateRetryPolicy = useUpdateRetryPolicy(accessToken); @@ -69,7 +71,7 @@ export default function ModelRetrySettingsPanel() { { retry_policy: globalRetryPolicy, model_group_retry_policy: modelGroupRetryPolicy }, { onSuccess: () => { - toast.success("Retry settings saved successfully"); + toast.success(t("modelsAndEndpoints.modelRetrySettingsPanel.toasts.saved")); void fetchRetrySettings().then((routerSettings) => { if (routerSettings) { applyRetrySettings(routerSettings); @@ -77,7 +79,7 @@ export default function ModelRetrySettingsPanel() { }); }, onError: () => { - toast.fromError("Failed to save retry settings"); + toast.fromError(t("modelsAndEndpoints.modelRetrySettingsPanel.toasts.saveFailed")); }, }, ); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/transform-request/TransformRequestPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/transform-request/TransformRequestPanel.tsx index cf1eecdb03f..8fe92b51943 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/transform-request/TransformRequestPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/transform-request/TransformRequestPanel.tsx @@ -1,4 +1,5 @@ import React, { useState } from "react"; +import { Trans, useTranslation } from "react-i18next"; import { ArrowRight, Copy } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; @@ -12,6 +13,7 @@ interface TransformRequestPanelProps { } const TransformRequestPanel: React.FC = ({ accessToken }) => { + const { t } = useTranslation(); const [originalRequestJSON, setOriginalRequestJSON] = useState(`{ "model": "openai/gpt-4o", "messages": [ @@ -69,7 +71,7 @@ ${formattedBody} try { requestBody = JSON.parse(originalRequestJSON); } catch (e) { - toast.fromError("Invalid JSON in request body"); + toast.fromError(t("transformRequest.transformRequestPanel.toasts.invalidJson")); setIsLoading(false); return; } @@ -82,7 +84,7 @@ ${formattedBody} // Make the API call using fetch if (!accessToken) { - toast.fromError("No access token found"); + toast.fromError(t("transformRequest.transformRequestPanel.toasts.noAccessToken")); setIsLoading(false); return; } @@ -100,17 +102,17 @@ ${formattedBody} // Update state with the formatted curl command setTransformedResponse(formattedCurl); - toast.success("Request transformed successfully"); + toast.success(t("transformRequest.transformRequestPanel.toasts.transformed")); } else { // Handle the case where the API returns a different format // Try to extract the parts from a string response if needed const rawText = typeof data === "string" ? data : JSON.stringify(data); setTransformedResponse(rawText); - toast.info("Transformed request received in unexpected format"); + toast.info(t("transformRequest.transformRequestPanel.toasts.unexpectedFormat")); } } catch (err) { console.error("Error transforming request:", err); - toast.fromError("Failed to transform request"); + toast.fromError(t("transformRequest.transformRequestPanel.toasts.failed")); } finally { setIsLoading(false); } @@ -126,16 +128,16 @@ ${formattedBody} return (
    -

    Playground

    -

    - See how LiteLLM transforms your request for the specified provider. -

    +

    {t("transformRequest.transformRequestPanel.title")}

    +

    {t("transformRequest.transformRequestPanel.description")}

    {/* Original Request Panel */} - Original Request - The request you would send to LiteLLM /chat/completions endpoint. + + {t("transformRequest.transformRequestPanel.original.title")} + + {t("transformRequest.transformRequestPanel.original.description")} @@ -144,13 +146,13 @@ ${formattedBody} value={originalRequestJSON} onChange={(e) => setOriginalRequestJSON(e.target.value)} onKeyDown={handleKeyDown} - placeholder="Press Cmd/Ctrl + Enter to transform" + placeholder={t("transformRequest.transformRequestPanel.original.placeholder")} /> @@ -159,9 +161,13 @@ ${formattedBody} {/* Transformed Request Panel */} - Transformed Request - How LiteLLM transforms your request for the specified provider. -

    Note: Sensitive headers are not shown.

    + + {t("transformRequest.transformRequestPanel.transformed.title")} + + {t("transformRequest.transformRequestPanel.transformed.description")} +

    + {t("transformRequest.transformRequestPanel.transformed.sensitiveHeaders")} +

    @@ -187,11 +193,11 @@ ${formattedBody}
    diff --git a/ui/litellm-dashboard/src/components/GuardrailSettingsView.tsx b/ui/litellm-dashboard/src/components/GuardrailSettingsView.tsx index 5510eb62bdb..fa810521757 100644 --- a/ui/litellm-dashboard/src/components/GuardrailSettingsView.tsx +++ b/ui/litellm-dashboard/src/components/GuardrailSettingsView.tsx @@ -1,5 +1,6 @@ import React from "react"; import { Globe2 } from "lucide-react"; +import { useTranslation } from "react-i18next"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { cn } from "@/lib/cva.config"; @@ -21,6 +22,7 @@ export function GuardrailSettingsView({ variant = "card", className = "", }: GuardrailSettingsViewProps) { + const { t } = useTranslation(); const optedOutSet = new Set(optedOutGlobalGuardrails); const globalsRunning = Array.from(globalGuardrailNames).filter((n) => !optedOutSet.has(n)); const nonGlobalOptIns = teamGuardrails.filter((n) => !globalGuardrailNames.has(n)); @@ -28,16 +30,16 @@ export function GuardrailSettingsView({ const isEmpty = !killSwitchOn && globalsRunning.length === 0 && nonGlobalOptIns.length === 0; const content = isEmpty ? ( - No guardrails configured + {t("guardrailSettingsView.empty")} ) : (
    - - Global + + {t("guardrailSettingsView.global")} {killSwitchOn ? ( - Bypassed for this team + {t("guardrailSettingsView.bypassed")} ) : globalsRunning.length > 0 ? (
    {globalsRunning.map((name) => ( @@ -45,11 +47,13 @@ export function GuardrailSettingsView({ ))}
    ) : ( - None configured + {t("guardrailSettingsView.noneConfigured")} )}
    - Team-specific + + {t("guardrailSettingsView.teamSpecific")} + {nonGlobalOptIns.length > 0 ? (
    {nonGlobalOptIns.map((name) => ( @@ -57,7 +61,7 @@ export function GuardrailSettingsView({ ))}
    ) : ( - None configured + {t("guardrailSettingsView.noneConfigured")} )}
    @@ -67,8 +71,8 @@ export function GuardrailSettingsView({ return ( - Guardrails Settings - Global and team-specific guardrails applied to this team + {t("guardrailSettingsView.title")} + {t("guardrailSettingsView.description")} {content} @@ -77,7 +81,7 @@ export function GuardrailSettingsView({ return (
    - Guardrails Settings + {t("guardrailSettingsView.title")} {content}
    ); diff --git a/ui/litellm-dashboard/src/components/ModelInfoEditForm.tsx b/ui/litellm-dashboard/src/components/ModelInfoEditForm.tsx index 085334567ed..08af899cf7b 100644 --- a/ui/litellm-dashboard/src/components/ModelInfoEditForm.tsx +++ b/ui/litellm-dashboard/src/components/ModelInfoEditForm.tsx @@ -3,8 +3,10 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { CircleHelp } from "lucide-react"; import type { Dayjs } from "dayjs"; +import type { ParseKeys, TFunction } from "i18next"; import * as React from "react"; import { useForm, type Resolver } from "react-hook-form"; +import { useTranslation } from "react-i18next"; import { z } from "zod"; import { TagsInput } from "@/app/(dashboard)/guardrails/_components/content_filter/TagsInput"; @@ -21,6 +23,7 @@ import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; +import i18n from "@/i18n/i18n"; import CacheControlInjectionPoints, { CACHE_CONTROL_LABEL, @@ -50,17 +53,28 @@ import { interface PtuEditField { name: string; - label: string; + label: ParseKeys; input: "number" | "datetime"; - placeholder?: string; + placeholder?: ParseKeys; isCount?: boolean; } const PTU_EDIT_FIELDS: PtuEditField[] = [ - { name: PTU_COUNT_FIELD, label: "PTU Count", input: "number", placeholder: "e.g. 15", isCount: true }, - { name: PTU_RATE_FIELD, label: "Cost per PTU / Hour (USD)", input: "number", placeholder: "e.g. 2.00" }, - { name: PTU_START_FIELD, label: "PTU Effective From (UTC)", input: "datetime" }, - { name: PTU_END_FIELD, label: "PTU Effective To (UTC)", input: "datetime" }, + { + name: PTU_COUNT_FIELD, + label: "modelInfoEditForm.fields.ptuCount.label", + input: "number", + placeholder: "modelInfoEditForm.fields.ptuCount.placeholder", + isCount: true, + }, + { + name: PTU_RATE_FIELD, + label: "modelInfoEditForm.fields.ptuRate.label", + input: "number", + placeholder: "modelInfoEditForm.fields.ptuRate.placeholder", + }, + { name: PTU_START_FIELD, label: "modelInfoEditForm.fields.ptuEffectiveFrom.label", input: "datetime" }, + { name: PTU_END_FIELD, label: "modelInfoEditForm.fields.ptuEffectiveTo.label", input: "datetime" }, ]; const PRICING_FIELDS = [ @@ -86,10 +100,9 @@ const textish = z.string().optional(); const isBlank = (value: string | number | null | undefined): boolean => value == null || String(value).trim() === ""; export const perMillionToPerToken = scalar - .refine( - (perMillion) => isBlank(perMillion) || (Number.isFinite(Number(perMillion)) && Number(perMillion) >= 0), - "Enter a price of 0 or more", - ) + .refine((perMillion) => isBlank(perMillion) || (Number.isFinite(Number(perMillion)) && Number(perMillion) >= 0), { + error: () => i18n.t("modelInfoEditForm.validation.priceNonNegative"), + }) .transform((perMillion) => (isBlank(perMillion) ? null : Number(perMillion) / 1_000_000)); const modelEditShape = { @@ -127,13 +140,17 @@ const modelEditShape = { type ModelEditFieldName = keyof typeof modelEditShape; -const buildSchema = (ptuEnabled: boolean, isFieldTouched: (field: TouchedPricingField) => boolean) => +const buildSchema = ( + ptuEnabled: boolean, + isFieldTouched: (field: TouchedPricingField) => boolean, + t: TFunction = i18n.t.bind(i18n), +) => z.object(modelEditShape).superRefine((values, ctx) => { const reject = (path: ModelEditFieldName, message: string) => ctx.addIssue({ code: "custom", path: [path], message }); if (parseModelExtraParams(values.litellm_extra_params) === null) { - reject("litellm_extra_params", "Please enter a valid JSON object"); + reject("litellm_extra_params", t("modelInfoEditForm.validation.invalidJson")); } // antd validates only mounted fields, and the PTU block does not render when the flag is off. @@ -142,24 +159,24 @@ const buildSchema = (ptuEnabled: boolean, isFieldTouched: (field: TouchedPricing } if (!isPositiveWholePtuCount(values.ptu_count)) { - reject("ptu_count", `PTU Count must be a whole number between 1 and ${MAX_PTU_COUNT.toLocaleString()}`); + reject("ptu_count", t("modelInfoEditForm.validation.ptuCountRange", { max: MAX_PTU_COUNT.toLocaleString() })); } if (!isNonNegativePtuRate(values.cost_per_ptu_per_hour)) { reject( "cost_per_ptu_per_hour", - `Cost per PTU / Hour must be between 0 and ${MAX_COST_PER_PTU_PER_HOUR.toLocaleString()}`, + t("modelInfoEditForm.validation.ptuRateRange", { max: MAX_COST_PER_PTU_PER_HOUR.toLocaleString() }), ); } if (isFilledPtuValue(values.ptu_count) !== isFilledPtuValue(values.cost_per_ptu_per_hour)) { - const message = "PTU Count and Cost per PTU / Hour must be set together"; + const message = t("modelInfoEditForm.validation.ptuPair"); reject("ptu_count", message); reject("cost_per_ptu_per_hour", message); } if (isFilledPtuValue(values.ptu_count) && !isFilledPtuValue(values.ptu_effective_from)) { - reject("ptu_effective_from", "PTU Effective From is required when PTU Count is set"); + reject("ptu_effective_from", t("modelInfoEditForm.validation.ptuStartRequired")); } if (!ptuWindowIsOrdered(values.ptu_effective_from, values.ptu_effective_to)) { - const message = "PTU Effective To must be after PTU Effective From"; + const message = t("modelInfoEditForm.validation.ptuWindowOrder"); reject("ptu_effective_from", message); reject("ptu_effective_to", message); } @@ -172,7 +189,7 @@ const buildSchema = (ptuEnabled: boolean, isFieldTouched: (field: TouchedPricing isFilledPtuValue(value) && Number(value) !== 0 ) { - reject(field, "A PTU deployment bills by reserved capacity, so this cost must be 0 or blank"); + reject(field, t("modelInfoEditForm.validation.ptuPricing")); } } }); @@ -240,10 +257,10 @@ export const toModelEditFormValues = (localModelData: any, isWildcardModel: bool team_id: localModelData.model_info?.team_id ?? undefined, }); -const displayCost = (localModelData: any, field: TouchedPricingField): string => { +const displayCost = (localModelData: any, field: TouchedPricingField, t: TFunction = i18n.t.bind(i18n)): string => { const key = COST_SOURCES[field]; const rate = localModelData?.litellm_params?.[key] ?? localModelData?.model_info?.[key]; - return rate != null ? (Number(rate) * 1_000_000).toFixed(4) : "Not Set"; + return rate != null ? (Number(rate) * 1_000_000).toFixed(4) : t("modelInfoEditForm.notSet"); }; interface ModelInfoEditFormProps { @@ -298,8 +315,9 @@ const DocsHint: React.FC<{ text: string; href: string }> = ({ text, href }) => ( ); const ChipList: React.FC<{ values: unknown; emptyLabel: string }> = ({ values, emptyLabel }) => { + const { t } = useTranslation(); if (!values) { - return <>Not Set; + return <>{t("modelInfoEditForm.notSet")}; } if (!Array.isArray(values)) { return <>{String(values)}; @@ -338,6 +356,7 @@ const ModelInfoEditForm: React.FC = ({ healthCheckModelOptions, teams, }) => { + const { t } = useTranslation(); // Neither RHF's blur-based touchedFields nor its resettable dirtyFields matches antd's touched-on-change. const touchedRef = React.useRef>(new Set()); const isFieldTouched = React.useCallback((field: TouchedPricingField) => touchedRef.current.has(field), []); @@ -347,7 +366,7 @@ const ModelInfoEditForm: React.FC = ({ // react-hook-form refreshes control._options every render, so this rebuild is what the next submit runs. const resolver: Resolver = (values, context, options) => - zodResolver(buildSchema(ptuCostAttributionEnabled, isFieldTouched))(values, context, options); + zodResolver(buildSchema(ptuCostAttributionEnabled, isFieldTouched, t))(values, context, options); const form = useForm({ resolver, @@ -373,7 +392,7 @@ const ModelInfoEditForm: React.FC = ({ {({ value, ...control }) => } ) : ( - {(stored as string) || "Not Set"} + {(stored as string) || t("modelInfoEditForm.notSet")} )}
    ); @@ -386,7 +405,7 @@ const ModelInfoEditForm: React.FC = ({ {({ value, ...control }) => } ) : ( - {(stored as string) || "Not Set"} + {(stored as string) || t("modelInfoEditForm.notSet")} )}
    ); @@ -409,7 +428,7 @@ const ModelInfoEditForm: React.FC = ({ ) : (
    {label} - {displayCost(localModelData, name)} + {displayCost(localModelData, name, t)}
    ); @@ -437,21 +456,34 @@ const ModelInfoEditForm: React.FC = ({
    - {textField("model_name", "Model Name", "Enter model name", localModelData.model_name)} + {textField( + "model_name", + t("modelInfoEditForm.fields.modelName.label"), + t("modelInfoEditForm.fields.modelName.placeholder"), + localModelData.model_name, + )} {textField( "litellm_model_name", - "LiteLLM Model Name", - "Enter LiteLLM model name", + t("modelInfoEditForm.fields.litellmModelName.label"), + t("modelInfoEditForm.fields.litellmModelName.placeholder"), localModelData.litellm_model_name, )} - {pricingField("input_cost", "Input Cost (per 1M tokens)", "Enter input cost")} - {pricingField("output_cost", "Output Cost (per 1M tokens)", "Enter output cost")} + {pricingField( + "input_cost", + t("modelInfoEditForm.fields.inputCost.label"), + t("modelInfoEditForm.fields.inputCost.placeholder"), + )} + {pricingField( + "output_cost", + t("modelInfoEditForm.fields.outputCost.label"), + t("modelInfoEditForm.fields.outputCost.placeholder"), + )} {ptuCostAttributionEnabled && PTU_EDIT_FIELDS.map((ptuField) => (
    - {ptuField.label} + {t(ptuField.label)} {isEditing ? ( {({ value, onChange, ...control }) => @@ -461,7 +493,7 @@ const ModelInfoEditForm: React.FC = ({ id={ptuField.name} onChange={onChange} value={value ?? ""} - placeholder={ptuField.placeholder} + placeholder={ptuField.placeholder && t(ptuField.placeholder)} step={ptuField.isCount ? 1 : undefined} min={ptuField.isCount ? 1 : 0} /> @@ -479,7 +511,7 @@ const ModelInfoEditForm: React.FC = ({ {(ptuField.input === "datetime" ? formatPtuUtcDisplay(localModelData?.model_info?.[ptuField.name]) - : localModelData?.model_info?.[ptuField.name]) ?? "Not Set"} + : localModelData?.model_info?.[ptuField.name]) ?? t("modelInfoEditForm.notSet")} )}
    @@ -487,68 +519,96 @@ const ModelInfoEditForm: React.FC = ({ {pricingField( "cache_read_cost", - "Cache Read Cost (per 1M tokens)", - "Defaults to Input Cost if blank", - "If left blank on save, defaults to Input Cost.", + t("modelInfoEditForm.fields.cacheReadCost.label"), + t("modelInfoEditForm.fields.cacheReadCost.placeholder"), + t("modelInfoEditForm.fields.cacheReadCost.description"), )} {pricingField( "cache_write_cost", - "Cache Write Cost (per 1M tokens)", - "Defaults to Input Cost if blank", - "If left blank on save, defaults to Input Cost (backend falls back to input_cost_per_token).", + t("modelInfoEditForm.fields.cacheWriteCost.label"), + t("modelInfoEditForm.fields.cacheWriteCost.placeholder"), + t("modelInfoEditForm.fields.cacheWriteCost.description"), )} {pricingField( "cache_write_cost_1h", - "Cache Write Cost (1 hour, per 1M tokens)", - "Defaults to the cost map 1 hour rate, else Cache Write Cost", - "Rate for prompt cache writes with a 1 hour TTL. If left blank on save, known models use their cost map 1 hour rate, otherwise Cache Write Cost.", + t("modelInfoEditForm.fields.cacheWriteCost1h.label"), + t("modelInfoEditForm.fields.cacheWriteCost1h.placeholder"), + t("modelInfoEditForm.fields.cacheWriteCost1h.description"), )} - {textField("api_base", "API Base", "Enter API base", localModelData.litellm_params?.api_base)} + {textField( + "api_base", + t("modelInfoEditForm.fields.apiBase.label"), + t("modelInfoEditForm.fields.apiBase.placeholder"), + localModelData.litellm_params?.api_base, + )} {textField( "custom_llm_provider", - "Custom LLM Provider", - "Enter custom LLM provider", + t("modelInfoEditForm.fields.customLlmProvider.label"), + t("modelInfoEditForm.fields.customLlmProvider.placeholder"), localModelData.litellm_params?.custom_llm_provider, )} {textField( "organization", - "Organization", - "Enter organization", + t("modelInfoEditForm.fields.organization.label"), + t("modelInfoEditForm.fields.organization.placeholder"), localModelData.litellm_params?.organization, )} - {numberField("tpm", "TPM (Tokens per Minute)", "Enter TPM", localModelData.litellm_params?.tpm)} - {numberField("rpm", "RPM (Requests per Minute)", "Enter RPM", localModelData.litellm_params?.rpm)} - {numberField("max_retries", "Max Retries", "Enter max retries", localModelData.litellm_params?.max_retries)} - {numberField("timeout", "Timeout (seconds)", "Enter timeout", localModelData.litellm_params?.timeout)} + {numberField( + "tpm", + t("modelInfoEditForm.fields.tpm.label"), + t("modelInfoEditForm.fields.tpm.placeholder"), + localModelData.litellm_params?.tpm, + )} + {numberField( + "rpm", + t("modelInfoEditForm.fields.rpm.label"), + t("modelInfoEditForm.fields.rpm.placeholder"), + localModelData.litellm_params?.rpm, + )} + {numberField( + "max_retries", + t("modelInfoEditForm.fields.maxRetries.label"), + t("modelInfoEditForm.fields.maxRetries.placeholder"), + localModelData.litellm_params?.max_retries, + )} + {numberField( + "timeout", + t("modelInfoEditForm.fields.timeout.label"), + t("modelInfoEditForm.fields.timeout.placeholder"), + localModelData.litellm_params?.timeout, + )} {numberField( "stream_timeout", - "Stream Timeout (seconds)", - "Enter stream timeout", + t("modelInfoEditForm.fields.streamTimeout.label"), + t("modelInfoEditForm.fields.streamTimeout.placeholder"), localModelData.litellm_params?.stream_timeout, )}
    - Model Access Groups + {t("modelInfoEditForm.fields.modelAccessGroups.label")} {isEditing ? ( tagsField( "model_access_group", (modelAccessGroups ?? []).map((group) => ({ value: group, label: group })), - "Select existing groups or type to create new ones", + t("modelInfoEditForm.fields.modelAccessGroups.placeholder"), ) ) : ( - + )}
    - Guardrails + {t("modelInfoEditForm.fields.guardrails.label")} @@ -556,20 +616,23 @@ const ModelInfoEditForm: React.FC = ({ tagsField( "guardrails", guardrailsList.map((name) => ({ value: name, label: name })), - "Select existing guardrails or type to create new ones", + t("modelInfoEditForm.fields.guardrails.placeholder"), ) ) : ( - + )}
    - Attached Knowledge Bases (RAG) + {t("modelInfoEditForm.fields.knowledgeBases.label")} @@ -580,7 +643,7 @@ const ModelInfoEditForm: React.FC = ({ value={value as string[] | undefined} onChange={onChange} accessToken={accessToken || ""} - placeholder="Select knowledge bases (optional)" + placeholder={t("modelInfoEditForm.fields.knowledgeBases.placeholder")} /> )} @@ -588,36 +651,39 @@ const ModelInfoEditForm: React.FC = ({ )}
    - Tags + {t("modelInfoEditForm.fields.tags.label")} {isEditing ? ( tagsField( "tags", Object.values(tagsList).map((tag: Tag) => ({ value: tag.name, label: tag.name })), - "Select existing tags or type to create new ones", + t("modelInfoEditForm.fields.tags.placeholder"), ) ) : ( - + )}
    - Existing Credentials + {t("modelInfoEditForm.fields.existingCredentials.label")} {isEditing ? ( {({ id, value, onChange }) => ( <> = ({ {isWildcardModel && (
    - Health Check Model + {t("modelInfoEditForm.fields.healthCheckModel.label")} {isEditing ? ( {({ id, value, onChange, onBlur }) => ( @@ -654,10 +720,10 @@ const ModelInfoEditForm: React.FC = ({ onValueChange={onChange} > - + - None + {t("modelInfoEditForm.fields.healthCheckModel.none")} {healthCheckModelOptions.map((option) => ( {option.label} @@ -668,7 +734,7 @@ const ModelInfoEditForm: React.FC = ({ )} ) : ( - {localModelData.model_info?.health_check_model || "Not Set"} + {localModelData.model_info?.health_check_model || t("modelInfoEditForm.notSet")} )}
    )} @@ -711,29 +777,34 @@ const ModelInfoEditForm: React.FC = ({ ) : (
    - Cache Control + {t("modelInfoEditForm.fields.cacheControl.label")} {localModelData.litellm_params?.cache_control_injection_points ? (
    -

    Enabled

    +

    {t("modelInfoEditForm.fields.cacheControl.enabled")}

    {localModelData.litellm_params.cache_control_injection_points.map((point: any, i: number) => (
    - Location: {point.location},{point.role && Role: {point.role}} - {point.index !== undefined && Index: {point.index}} + {t("modelInfoEditForm.fields.cacheControl.location", { location: point.location })} + {point.role && ( + {t("modelInfoEditForm.fields.cacheControl.role", { role: point.role })} + )} + {point.index !== undefined && ( + {t("modelInfoEditForm.fields.cacheControl.index", { index: point.index })} + )}
    ))}
    ) : ( - "Disabled" + t("modelInfoEditForm.fields.cacheControl.disabled") )}
    )}
    - Model Info + {t("modelInfoEditForm.fields.modelInfo.label")} {isEditing ? ( {({ value, ...control }) => ( @@ -756,9 +827,9 @@ const ModelInfoEditForm: React.FC = ({
    - LiteLLM Params + {t("modelInfoEditForm.fields.litellmParams.label")} @@ -767,7 +838,7 @@ const ModelInfoEditForm: React.FC = ({ {({ value, ...control }) => (