diff --git a/ui/litellm-dashboard/src/components/molecules/filter.tsx b/ui/litellm-dashboard/src/components/molecules/filter.tsx index 7086892c32d..04a01df7049 100644 --- a/ui/litellm-dashboard/src/components/molecules/filter.tsx +++ b/ui/litellm-dashboard/src/components/molecules/filter.tsx @@ -2,6 +2,7 @@ import { FilterIcon } from "@heroicons/react/outline"; import { Button, Input, Select } from "antd"; import debounce from "lodash/debounce"; import React, { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; export interface FilterOptionCustomComponentProps { value?: string; @@ -36,8 +37,9 @@ const FilterComponent: React.FC = ({ onApplyFilters, onResetFilters, initialValues = {}, - buttonLabel = "Filters", + buttonLabel, }) => { + const { t } = useTranslation(); const [showFilters, setShowFilters] = useState(false); const [tempValues, setTempValues] = useState(initialValues); const [searchOptionsMap, setSearchOptionsMap] = useState<{ @@ -137,9 +139,9 @@ const FilterComponent: React.FC = ({ onClick={() => setShowFilters(!showFilters)} className="flex items-center gap-2" > - {buttonLabel} + {buttonLabel ?? t("molecules.filter.filters")} - + {showFilters && ( @@ -152,7 +154,7 @@ const FilterComponent: React.FC = ({ handleFilterChange(option.name, value)} allowClear @@ -192,7 +194,7 @@ const FilterComponent: React.FC = ({ handleFilterChange(option.name, value ?? "")} - placeholder={`Select ${option.label || option.name}...`} + placeholder={t("molecules.filter.selectPlaceholder", { label: option.label || option.name })} allFilters={tempValues} /> ); @@ -200,7 +202,7 @@ const FilterComponent: React.FC = ({ ) : ( handleFilterChange(option.name, e.target.value)} allowClear diff --git a/ui/litellm-dashboard/src/components/molecules/models/columns.tsx b/ui/litellm-dashboard/src/components/molecules/models/columns.tsx index 329e5cd7729..3977ba681f8 100644 --- a/ui/litellm-dashboard/src/components/molecules/models/columns.tsx +++ b/ui/litellm-dashboard/src/components/molecules/models/columns.tsx @@ -5,13 +5,14 @@ import { Badge, Button, Icon } from "@tremor/react"; import { Divider, Flex, Popover, Space, Switch, Tooltip, Typography } from "antd"; import { ModelData } from "../../model_dashboard/types"; import { ProviderLogo } from "./ProviderLogo"; +import i18n from "@/lib/i18n"; const { Text, Title } = Typography; -const credentialsInfoPopoverContent = ( +const getCredentialsInfoPopoverContent = () => ( - Credential types + {i18n.t("molecules.modelsColumns.credentialTypes")} @@ -19,10 +20,10 @@ const credentialsInfoPopoverContent = ( - Reusable + {i18n.t("molecules.modelsColumns.reusable")} - Credentials saved in LiteLLM that can be added to models repeatedly. + {i18n.t("molecules.modelsColumns.reusableDescription")} @@ -31,10 +32,10 @@ const credentialsInfoPopoverContent = ( - Manual + {i18n.t("molecules.modelsColumns.manual")} - Credentials added directly during model creation or defined in the config file. + {i18n.t("molecules.modelsColumns.manualDescription")} @@ -57,7 +58,7 @@ export const columns = ( pausingModelId?: string | null, ): ColumnDef[] => [ { - header: () => Model ID, + header: () => {i18n.t("molecules.modelsColumns.modelId")}, accessorKey: "model_info.id", enableSorting: false, size: 130, @@ -82,7 +83,7 @@ export const columns = ( }, }, { - header: () => Model Information, + header: () => {i18n.t("molecules.modelsColumns.modelInformation")}, accessorKey: "model_name", size: 250, minSize: 120, @@ -94,14 +95,14 @@ export const columns = ( - {model.provider || "Unknown provider"} + {model.provider || i18n.t("molecules.modelsColumns.unknownProvider")} - Public Model Name + {i18n.t("molecules.modelsColumns.publicModelName")} {displayName} @@ -110,7 +111,7 @@ export const columns = ( - LiteLLM Model Name + {i18n.t("molecules.modelsColumns.litellmModelName")} ( - Credentials - + {i18n.t("molecules.modelsColumns.credentials")} + @@ -188,7 +189,7 @@ export const columns = ( ) : ( <> - Manual + {i18n.t("molecules.modelsColumns.manual")} )} @@ -196,7 +197,7 @@ export const columns = ( }, }, { - header: () => Created By, + header: () => {i18n.t("molecules.modelsColumns.createdBy")}, accessorKey: "model_info.created_by", sortingFn: "datetime", size: 160, @@ -212,23 +213,25 @@ export const columns = ( {/* Created By - Primary */}
- {isConfigModel ? "Defined in config" : createdBy || "Unknown"} + {isConfigModel ? i18n.t("molecules.modelsColumns.definedInConfig") : createdBy || i18n.t("common.unknown")}
{/* Created At - Secondary */}
- {isConfigModel ? "-" : createdAt || "Unknown date"} + {isConfigModel ? "-" : createdAt || i18n.t("molecules.modelsColumns.unknownDate")}
); }, }, { - header: () => Updated At, + header: () => {i18n.t("common.updatedAt")}, accessorKey: "model_info.updated_at", sortingFn: "datetime", size: 120, @@ -243,7 +246,7 @@ export const columns = ( }, }, { - header: () => Costs, + header: () => {i18n.t("molecules.modelsColumns.costs")}, accessorKey: "input_cost", size: 120, minSize: 80, @@ -262,19 +265,27 @@ export const columns = ( } return ( - +
{/* Input Cost - Primary */} - {inputCost != null &&
In: ${inputCost}
} + {inputCost != null && ( +
+ {i18n.t("molecules.modelsColumns.inputCost", { cost: inputCost })} +
+ )} {/* Output Cost - Secondary */} - {outputCost != null &&
Out: ${outputCost}
} + {outputCost != null && ( +
+ {i18n.t("molecules.modelsColumns.outputCost", { cost: outputCost })} +
+ )}
); }, }, { - header: () => Team ID, + header: () => {i18n.t("molecules.modelsColumns.teamId")}, accessorKey: "model_info.team_id", enableSorting: false, size: 130, @@ -303,7 +314,7 @@ export const columns = ( }, }, { - header: () => Model Access Group, + header: () => {i18n.t("molecules.modelsColumns.modelAccessGroup")}, accessorKey: "model_info.model_access_group", enableSorting: false, size: 180, @@ -364,7 +375,7 @@ export const columns = ( }, }, { - header: () => Status, + header: () => {i18n.t("common.status")}, accessorKey: "model_info.db_model", size: 120, minSize: 80, @@ -377,14 +388,16 @@ export const columns = ( ${model.model_info.db_model ? "bg-blue-50 text-blue-600" : "bg-gray-100 text-gray-600"} `} > - {model.model_info.db_model ? "DB Model" : "Config Model"} + {model.model_info.db_model + ? i18n.t("molecules.modelsColumns.dbModel") + : i18n.t("molecules.modelsColumns.configModel")} ); }, }, { id: "actions", - header: () => Actions, + header: () => {i18n.t("common.actions")}, size: 100, minSize: 80, enableResizing: false, @@ -396,12 +409,12 @@ export const columns = ( const isBlocked = model.model_info?.blocked === true; const isPauseToggleable = !isConfigModel && isAdmin && Boolean(onTogglePauseClick); const pauseTooltip = isConfigModel - ? "Config models cannot be paused from the dashboard. Pause is DB-backed." + ? i18n.t("molecules.modelsColumns.configNoPause") : !isAdmin - ? "Only proxy admins can pause or resume a model." + ? i18n.t("molecules.modelsColumns.nonAdminNoPause") : isBlocked - ? "Resume model — restore normal routing." - : "Pause model — stop routing requests until resumed."; + ? i18n.t("molecules.modelsColumns.resumeTooltip") + : i18n.t("molecules.modelsColumns.pauseTooltip"); // antd's `loading` prop on Switch is purely cosmetic — it does not block // clicks. Pair `loading` with `disabled` derived from the same condition // so a double-click during a pending PATCH cannot send a second, @@ -415,7 +428,9 @@ export const columns = ( checked={!isBlocked} disabled={!isPauseToggleable || isPausing} loading={isPausing} - aria-label={isBlocked ? "Resume model" : "Pause model"} + aria-label={ + isBlocked ? i18n.t("molecules.modelsColumns.resumeModel") : i18n.t("molecules.modelsColumns.pauseModel") + } onClick={(_, e) => { e.stopPropagation(); }} @@ -428,11 +443,11 @@ export const columns = ( />
{isConfigModel ? ( - + ) : ( - + d.includes(s))) return "Authentication Error"; - if (FORBIDDEN_MATCH.some((s) => d.includes(s))) return "Access Denied"; - if (DB_MATCH?.some?.((s: string) => d.includes(s)) || status === 503) return "Service Unavailable"; - if (BUDGET_MATCH?.some?.((s: string) => d.includes(s))) return "Budget Exceeded"; - if (ENTERPRISE_MATCH?.some?.((s: string) => d.includes(s))) return "Feature Unavailable"; - if (ROUTER_MATCH?.some?.((s: string) => d.includes(s))) return "Routing Error"; + if (AUTH_MATCH.some((s) => d.includes(s))) return "authenticationError"; + if (FORBIDDEN_MATCH.some((s) => d.includes(s))) return "accessDenied"; + if (DB_MATCH?.some?.((s: string) => d.includes(s)) || status === 503) return "serviceUnavailable"; + if (BUDGET_MATCH?.some?.((s: string) => d.includes(s))) return "budgetExceeded"; + if (ENTERPRISE_MATCH?.some?.((s: string) => d.includes(s))) return "featureUnavailable"; + if (ROUTER_MATCH?.some?.((s: string) => d.includes(s))) return "routingError"; - if (EXISTS_MATCH.some((s) => d.includes(s))) return "Already Exists"; - if (GUARDRAIL_MATCH.some((s) => d.includes(s))) return "Content Blocked"; + if (EXISTS_MATCH.some((s) => d.includes(s))) return "alreadyExists"; + if (GUARDRAIL_MATCH.some((s) => d.includes(s))) return "contentBlocked"; - if (FILE_UPLOAD_MATCH.some((s) => d.includes(s))) return "Validation Error"; - if (CLOUDZERO_MATCH.some((s) => d.includes(s))) return "Integration Error"; + if (FILE_UPLOAD_MATCH.some((s) => d.includes(s))) return "validationError"; + if (CLOUDZERO_MATCH.some((s) => d.includes(s))) return "integrationError"; - if (VALIDATION_MATCH.some((s) => d.includes(s))) return "Validation Error"; - if (status === 404 || d.includes("not found") || NOT_FOUND_MATCH.some((s) => d.includes(s))) return "Not Found"; + if (VALIDATION_MATCH.some((s) => d.includes(s))) return "validationError"; + if (status === 404 || d.includes("not found") || NOT_FOUND_MATCH.some((s) => d.includes(s))) return "notFound"; if ( status === 429 || d.includes("rate limit") || @@ -171,13 +190,13 @@ function titleFor(status?: number, desc?: string): string { d.includes("rpm") || RATE_LIMIT_EXTRA?.some?.((s: string) => d.includes(s)) ) - return "Rate Limit Exceeded"; - if (status && status >= 500) return "Server Error"; - if (status === 401) return "Authentication Error"; - if (status === 403) return "Access Denied"; - if (d.includes("enterprise") || d.includes("premium")) return "Info"; - if (status && status >= 400) return "Request Error"; - return "Error"; + return "rateLimitExceeded"; + if (status && status >= 500) return "serverError"; + if (status === 401) return "authenticationError"; + if (status === 403) return "accessDenied"; + if (d.includes("enterprise") || d.includes("premium")) return "info"; + if (status && status >= 400) return "requestError"; + return "error"; } const SUCCESS_MATCH = [ @@ -221,10 +240,14 @@ const CONFIG_WARN_MATCH = [ function classifyGeneralMessage(desc?: string): { kind: "success" | "info" | "warning"; title: string } | null { const d = (desc || "").toLowerCase(); - if (SUCCESS_MATCH.some((s) => d.includes(s))) return { kind: "success", title: "Success" }; - if (DEPRECATION_FEATURE_WARN_MATCH.some((s) => d.includes(s))) return { kind: "warning", title: "Feature Notice" }; - if (CONFIG_WARN_MATCH.some((s) => d.includes(s))) return { kind: "warning", title: "Configuration Warning" }; - if (INFO_MATCH.some((s) => d.includes(s))) return { kind: "warning", title: "Rate Limit" }; // show as warning for visibility + if (SUCCESS_MATCH.some((s) => d.includes(s))) + return { kind: "success", title: i18n.t("molecules.notificationsManager.success") }; + if (DEPRECATION_FEATURE_WARN_MATCH.some((s) => d.includes(s))) + return { kind: "warning", title: i18n.t("molecules.notificationsManager.featureNotice") }; + if (CONFIG_WARN_MATCH.some((s) => d.includes(s))) + return { kind: "warning", title: i18n.t("molecules.notificationsManager.configurationWarning") }; + if (INFO_MATCH.some((s) => d.includes(s))) + return { kind: "warning", title: i18n.t("molecules.notificationsManager.rateLimit") }; return null; } @@ -260,7 +283,7 @@ function looksErrorPayload(input: any, status?: number): boolean { const NotificationManager = { error(input: string | NotificationConfig) { - const cfg = normalize(input, "Error"); + const cfg = normalize(input, i18n.t("molecules.notificationsManager.error")); getNotification().error({ ...COMMON_NOTIFICATION_PROPS, ...cfg, @@ -270,7 +293,7 @@ const NotificationManager = { }, warning(input: string | NotificationConfig) { - const cfg = normalize(input, "Warning"); + const cfg = normalize(input, i18n.t("molecules.notificationsManager.warning")); getNotification().warning({ ...COMMON_NOTIFICATION_PROPS, ...cfg, @@ -280,7 +303,7 @@ const NotificationManager = { }, info(input: string | NotificationConfig) { - const cfg = normalize(input, "Info"); + const cfg = normalize(input, i18n.t("molecules.notificationsManager.info")); getNotification().info({ ...COMMON_NOTIFICATION_PROPS, ...cfg, @@ -293,14 +316,14 @@ const NotificationManager = { if (React.isValidElement(input)) { getNotification().success({ ...COMMON_NOTIFICATION_PROPS, - message: "Success", + message: i18n.t("molecules.notificationsManager.success"), description: input, placement: defaultPlacement(), duration: 3.5, }); return; } - const cfg = normalize(input as string | NotificationConfig, "Success"); + const cfg = normalize(input as string | NotificationConfig, i18n.t("molecules.notificationsManager.success")); getNotification().success({ ...COMMON_NOTIFICATION_PROPS, ...cfg, @@ -315,31 +338,31 @@ const NotificationManager = { const base = { ...(extra ?? {}), description, placement: extra?.placement ?? defaultPlacement() }; if (looksErrorPayload(input, status)) { - const title = titleFor(status, description); - const payload = { ...base, message: title }; + const category = categoryFor(status, description); + const payload = { ...base, message: i18n.t(`molecules.notificationsManager.${category}`) }; if ( - title === "Rate Limit Exceeded" || - title === "Info" || - title === "Budget Exceeded" || - title === "Feature Unavailable" || - title === "Content Blocked" || - title === "Integration Error" + category === "rateLimitExceeded" || + category === "info" || + category === "budgetExceeded" || + category === "featureUnavailable" || + category === "contentBlocked" || + category === "integrationError" ) { getNotification().warning({ ...COMMON_NOTIFICATION_PROPS, ...payload, duration: extra?.duration ?? 7 }); return; } - if (title === "Server Error") { + if (category === "serverError") { getNotification().error({ ...COMMON_NOTIFICATION_PROPS, ...payload, duration: extra?.duration ?? 8 }); return; } if ( - title === "Request Error" || - title === "Authentication Error" || - title === "Access Denied" || - title === "Not Found" || - title === "Error" || - title === "Already Exists" + category === "requestError" || + category === "authenticationError" || + category === "accessDenied" || + category === "notFound" || + category === "error" || + category === "alreadyExists" ) { getNotification().error({ ...COMMON_NOTIFICATION_PROPS, ...payload, duration: extra?.duration ?? 6 }); return; @@ -350,7 +373,7 @@ const NotificationManager = { // Non-error: success/info/warning classifier const cls = classifyGeneralMessage(description); - const payload = { ...base, message: cls?.title ?? "Info" }; + const payload = { ...base, message: cls?.title ?? i18n.t("molecules.notificationsManager.info") }; if (cls?.kind === "success") { getNotification().success({ ...COMMON_NOTIFICATION_PROPS, ...payload, duration: extra?.duration ?? 3.5 }); diff --git a/ui/litellm-dashboard/src/locales/en.json b/ui/litellm-dashboard/src/locales/en.json index 49f950692b5..05418fa6a91 100644 --- a/ui/litellm-dashboard/src/locales/en.json +++ b/ui/litellm-dashboard/src/locales/en.json @@ -133,6 +133,70 @@ "selected": "Selected" } }, + "molecules": { + "filter": { + "resetFilters": "Reset Filters", + "filters": "Filters", + "searchPlaceholder": "Search {{label}}...", + "selectPlaceholder": "Select {{label}}...", + "enterPlaceholder": "Enter {{label}}..." + }, + "modelsColumns": { + "modelId": "Model ID", + "modelInformation": "Model Information", + "credentials": "Credentials", + "credentialTypes": "Credential types", + "reusable": "Reusable", + "reusableDescription": "Credentials saved in LiteLLM that can be added to models repeatedly.", + "manual": "Manual", + "manualDescription": "Credentials added directly during model creation or defined in the config file.", + "createdBy": "Created By", + "costs": "Costs", + "teamId": "Team ID", + "modelAccessGroup": "Model Access Group", + "unknownProvider": "Unknown provider", + "publicModelName": "Public Model Name", + "litellmModelName": "LiteLLM Model Name", + "definedInConfig": "Defined in config", + "unknownDate": "Unknown date", + "costPerMTokens": "Cost per 1M tokens", + "inputCost": "In: ${{cost}}", + "outputCost": "Out: ${{cost}}", + "dbModel": "DB Model", + "configModel": "Config Model", + "resumeModel": "Resume model", + "pauseModel": "Pause model", + "configNoPause": "Config models cannot be paused from the dashboard. Pause is DB-backed.", + "nonAdminNoPause": "Only proxy admins can pause or resume a model.", + "resumeTooltip": "Resume model — restore normal routing.", + "pauseTooltip": "Pause model — stop routing requests until resumed.", + "configNoDelete": "Config model cannot be deleted on the dashboard. Please delete it from the config file.", + "deleteModel": "Delete model" + }, + "notificationsManager": { + "authenticationError": "Authentication Error", + "accessDenied": "Access Denied", + "serviceUnavailable": "Service Unavailable", + "budgetExceeded": "Budget Exceeded", + "featureUnavailable": "Feature Unavailable", + "routingError": "Routing Error", + "alreadyExists": "Already Exists", + "contentBlocked": "Content Blocked", + "validationError": "Validation Error", + "integrationError": "Integration Error", + "notFound": "Not Found", + "rateLimitExceeded": "Rate Limit Exceeded", + "serverError": "Server Error", + "requestError": "Request Error", + "info": "Info", + "error": "Error", + "success": "Success", + "warning": "Warning", + "featureNotice": "Feature Notice", + "configurationWarning": "Configuration Warning", + "rateLimit": "Rate Limit" + } + }, "common": { "save": "Save", "cancel": "Cancel", diff --git a/ui/litellm-dashboard/src/locales/zh-CN.json b/ui/litellm-dashboard/src/locales/zh-CN.json index fb4318c2965..668369503b4 100644 --- a/ui/litellm-dashboard/src/locales/zh-CN.json +++ b/ui/litellm-dashboard/src/locales/zh-CN.json @@ -133,6 +133,70 @@ "selected": "已选择" } }, + "molecules": { + "filter": { + "resetFilters": "重置筛选", + "filters": "筛选", + "searchPlaceholder": "搜索 {{label}}...", + "selectPlaceholder": "选择 {{label}}...", + "enterPlaceholder": "输入 {{label}}..." + }, + "modelsColumns": { + "modelId": "模型 ID", + "modelInformation": "模型信息", + "credentials": "凭据", + "credentialTypes": "凭据类型", + "reusable": "可复用", + "reusableDescription": "保存在 LiteLLM 中可被多次添加到模型的凭据。", + "manual": "手动", + "manualDescription": "在创建模型时直接添加或在配置文件中定义的凭据。", + "createdBy": "创建者", + "costs": "费用", + "teamId": "团队 ID", + "modelAccessGroup": "模型访问组", + "unknownProvider": "未知提供商", + "publicModelName": "公开模型名称", + "litellmModelName": "LiteLLM 模型名称", + "definedInConfig": "在配置文件中定义", + "unknownDate": "未知日期", + "costPerMTokens": "每百万 Token 费用", + "inputCost": "输入:${{cost}}", + "outputCost": "输出:${{cost}}", + "dbModel": "数据库模型", + "configModel": "配置模型", + "resumeModel": "恢复模型", + "pauseModel": "暂停模型", + "configNoPause": "配置模型无法从仪表盘暂停。暂停功能依赖数据库。", + "nonAdminNoPause": "只有代理管理员可以暂停或恢复模型。", + "resumeTooltip": "恢复模型 — 恢复正常路由。", + "pauseTooltip": "暂停模型 — 停止路由请求直到恢复。", + "configNoDelete": "配置模型无法在仪表盘上删除。请从配置文件中删除。", + "deleteModel": "删除模型" + }, + "notificationsManager": { + "authenticationError": "认证错误", + "accessDenied": "访问被拒绝", + "serviceUnavailable": "服务不可用", + "budgetExceeded": "预算超限", + "featureUnavailable": "功能不可用", + "routingError": "路由错误", + "alreadyExists": "已存在", + "contentBlocked": "内容已屏蔽", + "validationError": "验证错误", + "integrationError": "集成错误", + "notFound": "未找到", + "rateLimitExceeded": "超出速率限制", + "serverError": "服务器错误", + "requestError": "请求错误", + "info": "信息", + "error": "错误", + "success": "成功", + "warning": "警告", + "featureNotice": "功能通知", + "configurationWarning": "配置警告", + "rateLimit": "速率限制" + } + }, "common": { "save": "保存", "cancel": "取消",