From 7cdaa73fc0a7eebcbe2aeacfa5600530b64311dd Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Thu, 5 Nov 2026 12:43:09 -0800 Subject: [PATCH] refactor(ui): move remaining Aria and admin text onto i18n (#50242) Moves the English left in the Aria assistant, the MCP tool permission groups, the shared chat composer, the Beta badge, the user banner and the metadata key fields onto react-i18next. English output is unchanged. Text that Aria sends to the model (system prompt, tool descriptions, tool results and usage chart payloads) stays English byte for byte. Where the same string is also shown on screen, a display map keyed by the operation name or by the chart's English value translates it at render time, and the model copy is left alone. --- .../components/chat_ui/ChatComposer.tsx | 14 +- .../src/components/BetaBadge.tsx | 4 +- .../src/components/UserBanner.tsx | 4 +- .../MetadataKeyValueFields.tsx | 14 +- .../src/components/liteadmin/AriaAnswer.tsx | 10 +- .../src/components/liteadmin/AriaStart.tsx | 41 ++-- .../src/components/liteadmin/LiteAdmin.tsx | 46 +++-- .../liteadmin/LiteAdminConversation.tsx | 70 +++++-- .../components/liteadmin/UsageAnswerChart.tsx | 57 +++++- .../src/components/liteadmin/agent.ts | 11 +- .../src/components/liteadmin/answerExport.ts | 3 +- .../src/components/liteadmin/operations.ts | 3 +- .../src/components/liteadmin/usageChart.ts | 3 +- .../src/components/liteadmin/useLiteAdmin.ts | 10 +- .../mcp_tools/McpCrudPermissionPanel.tsx | 6 +- ui/litellm-dashboard/src/i18n/locales/en.json | 183 ++++++++++++++++++ .../src/utils/mcpToolCrudClassification.ts | 24 +-- 17 files changed, 400 insertions(+), 103 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatComposer.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatComposer.tsx index c33b5e992fc..0a3b5f9d7e8 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatComposer.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatComposer.tsx @@ -1,5 +1,6 @@ import React from "react"; import { ArrowUp, Code2, Square } from "lucide-react"; +import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupTextarea } from "@/components/ui/input-group"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; @@ -38,6 +39,7 @@ export function ChatComposer({ onSuggestionSelect, className, }: ChatComposerProps) { + const { t } = useTranslation(); const submitIfAllowed = () => { if (!submitDisabled && !isLoading) { onSubmit(); @@ -115,7 +117,7 @@ export function ChatComposer({ } > - - {enabled ? "Code Interpreter enabled (click to disable)" : "Enable Code Interpreter"} - + {label} ); } diff --git a/ui/litellm-dashboard/src/components/BetaBadge.tsx b/ui/litellm-dashboard/src/components/BetaBadge.tsx index 4e2195d1c36..69c624751e6 100644 --- a/ui/litellm-dashboard/src/components/BetaBadge.tsx +++ b/ui/litellm-dashboard/src/components/BetaBadge.tsx @@ -1,14 +1,16 @@ +import { useTranslation } from "react-i18next"; import { Badge } from "@/components/ui/badge"; import { useDisableShowNewBadge } from "@/app/(dashboard)/hooks/useDisableShowNewBadge"; export default function BetaBadge({ children, dot = false }: { children?: React.ReactNode; dot?: boolean }) { + const { t } = useTranslation(); const disableShowNewBadge = useDisableShowNewBadge(); if (disableShowNewBadge) { return children ? <>{children} : null; } - const badge = dot ? : Beta; + const badge = dot ? : {t("betaBadge.label")}; return children ? ( diff --git a/ui/litellm-dashboard/src/components/UserBanner.tsx b/ui/litellm-dashboard/src/components/UserBanner.tsx index 0c6620133f4..12497e16094 100644 --- a/ui/litellm-dashboard/src/components/UserBanner.tsx +++ b/ui/litellm-dashboard/src/components/UserBanner.tsx @@ -2,6 +2,7 @@ import React, { useState } from "react"; import ReactMarkdown from "react-markdown"; +import { useTranslation } from "react-i18next"; import remarkGfm from "remark-gfm"; import { CircleAlert, Info, TriangleAlert, X } from "lucide-react"; import { Alert, AlertAction, AlertDescription } from "@/components/shared/Alert"; @@ -36,6 +37,7 @@ interface UserBannerProps { } export const UserBanner: React.FC = ({ accessToken }) => { + const { t } = useTranslation(); const { data: banner } = useUserBanner(accessToken); const [dismissedSignature, setDismissedSignature] = useState(() => typeof window === "undefined" ? null : localStorage.getItem(DISMISS_STORAGE_KEY), @@ -62,7 +64,7 @@ export const UserBanner: React.FC = ({ accessToken }) => { - diff --git a/ui/litellm-dashboard/src/components/common_components/MetadataKeyValueFields.tsx b/ui/litellm-dashboard/src/components/common_components/MetadataKeyValueFields.tsx index 8bdc4005be0..4791d222dce 100644 --- a/ui/litellm-dashboard/src/components/common_components/MetadataKeyValueFields.tsx +++ b/ui/litellm-dashboard/src/components/common_components/MetadataKeyValueFields.tsx @@ -12,6 +12,7 @@ import { import { z } from "zod"; import { TeamMetadataField } from "@/app/(dashboard)/hooks/teams/useTeamMetadataSchema"; +import i18n from "@/i18n/i18n"; import { FormField } from "@/components/shared/form/FormField"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -24,11 +25,20 @@ export interface MetadataPair { } export const metadataPairsSchema = z - .array(z.object({ key: z.string().min(1, "Missing key"), value: z.string().optional() })) + .array( + z.object({ + key: z.string().min(1, { error: () => i18n.t("metadataKeyValueFields.errors.missingKey") }), + value: z.string().optional(), + }), + ) .superRefine((pairs, ctx) => { pairs.forEach((pair, index) => { if (pair.key && pairs.filter((other) => other.key === pair.key).length > 1) { - ctx.addIssue({ code: "custom", message: "Duplicate key", path: [index, "key"] }); + ctx.addIssue({ + code: "custom", + message: i18n.t("metadataKeyValueFields.errors.duplicateKey"), + path: [index, "key"], + }); } }); }); diff --git a/ui/litellm-dashboard/src/components/liteadmin/AriaAnswer.tsx b/ui/litellm-dashboard/src/components/liteadmin/AriaAnswer.tsx index 2160447b122..8bfd24ea973 100644 --- a/ui/litellm-dashboard/src/components/liteadmin/AriaAnswer.tsx +++ b/ui/litellm-dashboard/src/components/liteadmin/AriaAnswer.tsx @@ -1,5 +1,6 @@ import { useRef } from "react"; import { Download } from "lucide-react"; +import { useTranslation } from "react-i18next"; import { ChatMessageContent } from "@/components/chat/ChatMessages"; import type { ChatMessage } from "@/components/chat/types"; import { Button } from "@/components/ui/button"; @@ -18,14 +19,15 @@ export function AriaAnswer({ question?: string; chart?: UsageChart; }) { + const { t } = useTranslation(); const answerRef = useRef(null); const exportAnswer = () => { if (!answerRef.current || !exportAriaAnswer(question ?? "", answerRef.current)) { - toast.error("Could not open the PDF print dialog. Please try again."); + toast.error(t("aria.answer.exportFailed")); } }; return ( -
+
{chart && } diff --git a/ui/litellm-dashboard/src/components/liteadmin/AriaStart.tsx b/ui/litellm-dashboard/src/components/liteadmin/AriaStart.tsx index 4769911ec00..b776b465a9e 100644 --- a/ui/litellm-dashboard/src/components/liteadmin/AriaStart.tsx +++ b/ui/litellm-dashboard/src/components/liteadmin/AriaStart.tsx @@ -1,12 +1,26 @@ import type { ReactNode } from "react"; +import type { ParseKeys } from "i18next"; +import { useTranslation } from "react-i18next"; import type { AriaAccess } from "./operations"; -import { ChartNoAxesColumnIncreasing, Coins, TrendingDown } from "lucide-react"; +import { ChartNoAxesColumnIncreasing, Coins, TrendingDown, type LucideIcon } from "lucide-react"; import { Button } from "@/components/ui/button"; -const QUESTIONS = [ - { label: "Spend overview", question: "How much have I spent this week?", Icon: Coins }, - { label: "Cost by model", question: "Which models are costing the most?", Icon: ChartNoAxesColumnIncreasing }, - { label: "Find savings", question: "How can I reduce my spend?", Icon: TrendingDown }, +const QUESTIONS: { label: ParseKeys; question: ParseKeys; Icon: LucideIcon }[] = [ + { + label: "aria.start.questions.spendOverview.label", + question: "aria.start.questions.spendOverview.question", + Icon: Coins, + }, + { + label: "aria.start.questions.costByModel.label", + question: "aria.start.questions.costByModel.question", + Icon: ChartNoAxesColumnIncreasing, + }, + { + label: "aria.start.questions.findSavings.label", + question: "aria.start.questions.findSavings.question", + Icon: TrendingDown, + }, ]; export function AriaStart({ @@ -20,31 +34,34 @@ export function AriaStart({ disabled: boolean; access: AriaAccess; }) { + const { t } = useTranslation(); const usageDescription = - access.mode === "usage" && access.scope.type === "user" ? "Ask about your usage" : "Ask about gateway usage"; - const description = access.mode === "admin" ? "Manage your gateway · Changes require approval" : usageDescription; + access.mode === "usage" && access.scope.type === "user" + ? t("aria.start.descriptionUser") + : t("aria.start.descriptionGateway"); + const description = access.mode === "admin" ? t("aria.start.descriptionAdmin") : usageDescription; return (

- What would you like to know? + {t("aria.start.title")}

{description}

{composer}
-
+
{QUESTIONS.map(({ label, question, Icon }) => ( ))}
diff --git a/ui/litellm-dashboard/src/components/liteadmin/LiteAdmin.tsx b/ui/litellm-dashboard/src/components/liteadmin/LiteAdmin.tsx index c192241ab1f..3c128b6f6e9 100644 --- a/ui/litellm-dashboard/src/components/liteadmin/LiteAdmin.tsx +++ b/ui/litellm-dashboard/src/components/liteadmin/LiteAdmin.tsx @@ -13,6 +13,7 @@ import { } from "react"; import { createPortal } from "react-dom"; import { useQuery, type UseQueryResult } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { uiHref } from "@/utils/uiHref"; @@ -149,19 +150,20 @@ export function AriaSidebarLink({ active }: { active: boolean }) { } export function LiteAdminPage() { + const { t } = useTranslation(); const setPageContainer = useContext(LiteAdminContext)?.setPageContainer; - if (!setPageContainer) - return

Aria is not available for this session.

; + if (!setPageContainer) return

{t("aria.page.unavailable")}

; return
; } export function AriaUsageTrigger() { + const { t } = useTranslation(); const state = useContext(LiteAdminContext); if (!state) return null; return ( - Ask Aria + {t("aria.usageTrigger.ask")} ); } @@ -200,6 +202,7 @@ function Destination({ retry: () => void; open: boolean; }) { + const { t } = useTranslation(); const [approved, setApproved] = useState(false); if (target?.baseUrl && (!target.requiresConsent || approved)) { return ; @@ -208,29 +211,26 @@ function Destination({ - {loading && } + {loading && } {!loading && !target?.baseUrl && ( - {target?.error || "Could not load gateway settings."} + {target?.error || t("aria.destination.settingsError")} )} {!loading && target?.baseUrl && ( - Connect to the configured gateway + {t("aria.destination.consentTitle")}

{target.baseUrl}

-

- This gateway uses a different address. Aria will send your existing session credential to the address - shown above. -

+

{t("aria.destination.consentBody")}

- +
)} @@ -292,6 +292,7 @@ function useForegroundReviewFocus( } function LiteAdminChat({ session, open }: { session: LiteAdminSession; open: boolean }) { + const { t } = useTranslation(); const state = useContext(LiteAdminContext); const chat = useLiteAdmin(session); const [input, setInput] = useState(""); @@ -391,7 +392,7 @@ function LiteAdminChat({ session, open }: { session: LiteAdminSession; open: boo className="text-muted-foreground" > - Start over + {t("aria.chat.startOver")} )} @@ -418,13 +419,14 @@ function LiteAdminComposer({ onModelChange: (value: string | null) => void; tooLong: boolean; }) { + const { t } = useTranslation(); return (
- {tooLong && Keep your message within {MAX_INPUT_LENGTH.toLocaleString()} characters.} + {tooLong && {t("aria.composer.tooLong", { limit: MAX_INPUT_LENGTH.toLocaleString() })}} ; + const { t } = useTranslation(); + if (models.isPending) return ; return ( ({ value: item.model_group, label: item.model_group }))} value={value} onValueChange={onChange} - placeholder="Choose a chat model" - emptyText={models.data?.length ? "No matching models" : "No available models"} + placeholder={t("aria.modelPicker.placeholder")} + emptyText={models.data?.length ? t("aria.modelPicker.noMatch") : t("aria.modelPicker.noModels")} disabled={disabled} allowClear={false} className={compact ? "border-transparent shadow-none dark:bg-transparent" : undefined} @@ -470,13 +473,14 @@ function ModelPicker({ } function ModelStatus({ models }: { models: UseQueryResult }) { + const { t } = useTranslation(); if (!models.isError) return null; return ( - Could not load models.{" "} + {t("aria.modelStatus.loadError")}{" "} diff --git a/ui/litellm-dashboard/src/components/liteadmin/LiteAdminConversation.tsx b/ui/litellm-dashboard/src/components/liteadmin/LiteAdminConversation.tsx index dc92f57a4d3..522aac6a048 100644 --- a/ui/litellm-dashboard/src/components/liteadmin/LiteAdminConversation.tsx +++ b/ui/litellm-dashboard/src/components/liteadmin/LiteAdminConversation.tsx @@ -1,5 +1,7 @@ import { useEffect, type RefObject } from "react"; +import type { ParseKeys } from "i18next"; import { ArrowDown, Check, ChevronDown } from "lucide-react"; +import { useTranslation } from "react-i18next"; import { MessageScroller, useMessageScroller } from "@shadcn/react/message-scroller"; import CopyButton from "@/components/shared/CopyButton"; import { Alert, AlertDescription } from "@/components/ui/alert"; @@ -13,12 +15,33 @@ import type { ActionEntry, ConversationEntry } from "./useLiteAdmin"; import { AriaAnswer } from "./AriaAnswer"; import { ARIA_AGENT_LABEL } from "./aria-brand"; -const ACTION_STATUS: Record = { - review: "Review change", - applying: "Applying…", - completed: "Completed", - cancelled: "Cancelled", - unknown: "Check result", +const ACTION_STATUS: Record = { + review: "aria.conversation.status.review", + applying: "aria.conversation.status.applying", + completed: "aria.conversation.status.completed", + cancelled: "aria.conversation.status.cancelled", + unknown: "aria.conversation.status.unknown", +}; + +const ACTION_TITLES: Record = { + key_create: "aria.actions.keyCreate", + key_update: "aria.actions.keyUpdate", + key_detach_from_team: "aria.actions.keyDetachFromTeam", + key_delete: "aria.actions.keyDelete", + key_block: "aria.actions.keyBlock", + key_unblock: "aria.actions.keyUnblock", + team_create: "aria.actions.teamCreate", + team_update: "aria.actions.teamUpdate", + team_delete: "aria.actions.teamDelete", + team_member_add: "aria.actions.teamMemberAdd", + team_member_update: "aria.actions.teamMemberUpdate", + team_member_delete: "aria.actions.teamMemberDelete", + user_create: "aria.actions.userCreate", + user_update: "aria.actions.userUpdate", + user_delete: "aria.actions.userDelete", + budget_create: "aria.actions.budgetCreate", + budget_update: "aria.actions.budgetUpdate", + budget_delete: "aria.actions.budgetDelete", }; interface ConversationProps { @@ -30,12 +53,13 @@ interface ConversationProps { } export function LiteAdminConversation({ entries, thinking, open, reviewRef, onAnswer }: ConversationProps) { + const { t } = useTranslation(); return ( {entries.map((entry) => ( @@ -45,7 +69,7 @@ export function LiteAdminConversation({ entries, thinking, open, reviewRef, onAn ) : (
{entry.message.content} @@ -63,17 +87,21 @@ export function LiteAdminConversation({ entries, thinking, open, reviewRef, onAn ))} {thinking && ( -
+
- Working… + {t("aria.conversation.working")}
)} } > @@ -90,8 +118,12 @@ function ActionCard({ reviewRef, onAnswer, }: Pick & { entry: ActionEntry }) { + const { t } = useTranslation(); const { scrollToEnd } = useMessageScroller(); const review = entry.status === "review"; + const title = Object.hasOwn(ACTION_TITLES, entry.action.name) + ? t(ACTION_TITLES[entry.action.name]) + : entry.action.title; useEffect(() => { if (!open || !review) return; scrollToEnd(); @@ -123,14 +155,14 @@ function ActionCard({ ref={review ? reviewRef : undefined} tabIndex={review ? -1 : undefined} role="region" - aria-label={entry.action.title} + aria-label={title} size="sm" > - {entry.action.title} + {title} {entry.status === "completed" && } - {ACTION_STATUS[entry.status]} + {t(ACTION_STATUS[entry.status])} @@ -140,7 +172,7 @@ function ActionCard({ }> - Details + {t("aria.conversation.details")} {fields} @@ -154,19 +186,19 @@ function ActionCard({
{entry.key} - +
-

Copy this key now. It stays only in this chat session.

+

{t("aria.conversation.keyNotice")}

)}
{review && ( )} diff --git a/ui/litellm-dashboard/src/components/liteadmin/UsageAnswerChart.tsx b/ui/litellm-dashboard/src/components/liteadmin/UsageAnswerChart.tsx index a55cd8165c6..047bf894fed 100644 --- a/ui/litellm-dashboard/src/components/liteadmin/UsageAnswerChart.tsx +++ b/ui/litellm-dashboard/src/components/liteadmin/UsageAnswerChart.tsx @@ -1,17 +1,54 @@ +import type { ParseKeys } from "i18next"; +import { useTranslation } from "react-i18next"; import { BarChart } from "@/components/shared/charts/bar_chart"; import { LineChart } from "@/components/shared/charts/line_chart"; import { STACKED_USAGE_PALETTE } from "@/components/shared/charts/StackedUsageChart"; import { formatUsageChartValue, type UsageChart } from "./usageChart"; +const CHART_TITLES: Record = { + "Daily spend": "aria.chart.titles.dailySpend", + "Daily requests": "aria.chart.titles.dailyRequests", + "Daily tokens": "aria.chart.titles.dailyTokens", + "Spend by model": "aria.chart.titles.spendByModel", + "Spend by provider": "aria.chart.titles.spendByProvider", + "Token breakdown": "aria.chart.titles.tokenBreakdown", +}; + +const CHART_SCOPES: Record = { + "Gateway usage": "aria.chart.scopes.gateway", + "All teams": "aria.chart.scopes.allTeams", + "All tags": "aria.chart.scopes.allTags", + "Your usage": "aria.chart.scopes.yours", +}; + +const CHART_LABELS: Record = { + Unknown: "aria.chart.labels.unknown", + Other: "aria.chart.labels.other", + Prompt: "aria.chart.labels.prompt", + Completion: "aria.chart.labels.completion", + "Cache read": "aria.chart.labels.cacheRead", + "Cache creation": "aria.chart.labels.cacheCreation", +}; + +const CHART_NOTES: Record = { + "Up to six leading entries. Other includes remaining recorded spend.": "aria.chart.notes.otherRemainder", + "Cache counts are reported separately. Do not add them to total tokens.": "aria.chart.notes.cacheSeparate", +}; + export function UsageAnswerChart({ chart }: { chart: UsageChart }) { + const { t } = useTranslation(); + const display = (keys: Record, text: string) => (Object.hasOwn(keys, text) ? t(keys[text]) : text); + const title = display(CHART_TITLES, chart.title); const formatValue = (value: number) => formatUsageChartValue(value, chart.unit); - const unavailable = chart.data.filter((point) => point.value == null).map((point) => point.label); - const data = chart.data.map((point) => ({ label: point.label, [chart.unit]: point.value })); + const unavailable = chart.data + .filter((point) => point.value == null) + .map((point) => display(CHART_LABELS, point.label)); + const data = chart.data.map((point) => ({ label: display(CHART_LABELS, point.label), [chart.unit]: point.value })); return ( -
+
-

{chart.title}

+

{title}

{formatUsageChartValue(chart.total, chart.unit)} {chart.unit !== "usd" && ( @@ -20,7 +57,7 @@ export function UsageAnswerChart({ chart }: { chart: UsageChart }) {

- {chart.scope} · {chart.period} + {display(CHART_SCOPES, chart.scope)} · {chart.period}

{chart.kind === "bar" ? ( @@ -51,17 +88,17 @@ export function UsageAnswerChart({ chart }: { chart: UsageChart }) { className="h-40" /> )} - +
- + {chart.data.map((point, index) => ( - + ))} @@ -69,8 +106,8 @@ export function UsageAnswerChart({ chart }: { chart: UsageChart }) {
Category{t("aria.chart.category")} {chart.unit}
{point.label}{display(CHART_LABELS, point.label)} {formatUsageChartValue(point.value, chart.unit)}
{(chart.note || unavailable.length > 0) && (

- {chart.note} - {unavailable.length > 0 && ` Values unavailable for: ${unavailable.join(", ")}.`} + {chart.note && display(CHART_NOTES, chart.note)} + {unavailable.length > 0 && ` ${t("aria.chart.valuesUnavailable", { labels: unavailable.join(", ") })}`}

)}
diff --git a/ui/litellm-dashboard/src/components/liteadmin/agent.ts b/ui/litellm-dashboard/src/components/liteadmin/agent.ts index 4b9b7df3109..b9b8420a6e5 100644 --- a/ui/litellm-dashboard/src/components/liteadmin/agent.ts +++ b/ui/litellm-dashboard/src/components/liteadmin/agent.ts @@ -3,6 +3,7 @@ import type { ChatMessage } from "@/components/chat/types"; import { EndpointType, isModeCompatibleWithEndpoint } from "@/components/chat_ui/mode_endpoint_mapping"; import type { ModelGroup } from "@/components/llm_calls/fetch_models"; import { createGatewayClient } from "@/components/llm_calls/gateway_client"; +import i18n from "@/i18n/i18n"; import { createLiteAdminOperations, type AriaAccess, type OperationContext } from "./operations"; export const MAX_INPUT_LENGTH = 8_000; @@ -53,7 +54,7 @@ export function resolveInferenceTarget(candidate: string, managementBaseUrl: str const invalid: InferenceTarget = { baseUrl: null, requiresConsent: false, - error: "The gateway must be a valid HTTP(S) URL without credentials, a query, or a fragment.", + error: i18n.t("aria.inferenceTarget.invalid"), }; try { const page = new URL(pageUrl); @@ -65,7 +66,7 @@ export function resolveInferenceTarget(candidate: string, managementBaseUrl: str return { baseUrl: null, requiresConsent: false, - error: "Inference must use HTTPS when the dashboard or management gateway uses HTTPS.", + error: i18n.t("aria.inferenceTarget.httpsRequired"), }; } return { baseUrl: target.href, requiresConsent: target.origin !== management.origin, error: null }; @@ -140,10 +141,8 @@ export async function runLiteAdmin(options: LiteAdminOptions, client?: OpenAI): active(); const message = completion.choices[0]?.message; if (message?.tool_calls?.length) { - options.onMessage( - "I reached the step limit. Any completed actions remain applied; check the relevant page before continuing.", - ); + options.onMessage(i18n.t("aria.agent.stepLimit")); } else if (!message?.content && !message?.refusal) { - options.onMessage("The model returned no answer. Try another request or model."); + options.onMessage(i18n.t("aria.agent.noAnswer")); } } diff --git a/ui/litellm-dashboard/src/components/liteadmin/answerExport.ts b/ui/litellm-dashboard/src/components/liteadmin/answerExport.ts index bb3529299df..92fab666a2b 100644 --- a/ui/litellm-dashboard/src/components/liteadmin/answerExport.ts +++ b/ui/litellm-dashboard/src/components/liteadmin/answerExport.ts @@ -1,3 +1,4 @@ +import i18n from "@/i18n/i18n"; import { ARIA_AGENT_LABEL } from "./aria-brand"; const SVG_NAMESPACE = "http://www.w3.org/2000/svg"; @@ -209,7 +210,7 @@ export function exportAriaAnswer(question: string, answerElement: HTMLElement): const owner = answerElement.ownerDocument; const report = createAriaAnswerDocument(question, answerElement); const frame = owner.createElement("iframe"); - frame.title = `${ARIA_AGENT_LABEL} print preview`; + frame.title = i18n.t("aria.answer.printPreview", { agent: ARIA_AGENT_LABEL }); frame.setAttribute("sandbox", "allow-modals allow-same-origin"); frame.style.cssText = "position:fixed;inset:0;width:0;height:0;border:0;"; owner.body.appendChild(frame); diff --git a/ui/litellm-dashboard/src/components/liteadmin/operations.ts b/ui/litellm-dashboard/src/components/liteadmin/operations.ts index 11b287da07c..bfc8192746f 100644 --- a/ui/litellm-dashboard/src/components/liteadmin/operations.ts +++ b/ui/litellm-dashboard/src/components/liteadmin/operations.ts @@ -1,6 +1,7 @@ import { zodFunction } from "openai/helpers/zod"; import { z } from "zod"; import { apiClient } from "@/components/networking"; +import i18n from "@/i18n/i18n"; import { ApiError } from "@/lib/http/client"; import type { components } from "@/lib/http/schema"; import { generatedKey, projectToolResult, type ResultKind } from "./toolResults"; @@ -192,7 +193,7 @@ export function createLiteAdminOperations(context: OperationContext) { if (action) { const result: ActionResult = { status: "unknown", - message: "The change could not be verified. Check the resource before trying again.", + message: i18n.t("aria.actions.unverified"), }; context.onResult(action, result); throw new Error(result.message); diff --git a/ui/litellm-dashboard/src/components/liteadmin/usageChart.ts b/ui/litellm-dashboard/src/components/liteadmin/usageChart.ts index b6a6d1a008f..82bdd1e3b44 100644 --- a/ui/litellm-dashboard/src/components/liteadmin/usageChart.ts +++ b/ui/litellm-dashboard/src/components/liteadmin/usageChart.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import i18n from "@/i18n/i18n"; import type { AriaAccess } from "./operations"; export const usageChartRequestSchema = z.strictObject({ @@ -141,7 +142,7 @@ export function buildUsageChart(source: UsageChartSource, view: UsageChartView): } export function formatUsageChartValue(value: number | null, unit: UsageChart["unit"]): string { - if (value == null) return "Unavailable"; + if (value == null) return i18n.t("aria.chart.unavailable"); return unit === "usd" ? value.toLocaleString("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 6 }) : value.toLocaleString("en-US"); diff --git a/ui/litellm-dashboard/src/components/liteadmin/useLiteAdmin.ts b/ui/litellm-dashboard/src/components/liteadmin/useLiteAdmin.ts index 0bbd62f2241..fdf3cf35a9c 100644 --- a/ui/litellm-dashboard/src/components/liteadmin/useLiteAdmin.ts +++ b/ui/litellm-dashboard/src/components/liteadmin/useLiteAdmin.ts @@ -1,7 +1,9 @@ import { useLayoutEffect, useRef, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; +import type { ParseKeys } from "i18next"; import type { ChatMessage } from "@/components/chat/types"; import { getProxyBaseUrl } from "@/components/networking"; +import i18n from "@/i18n/i18n"; import { extractProxyErrorMessage } from "@/lib/http/client"; import { toast } from "@/lib/toast"; import { getCookie } from "@/utils/cookieUtils"; @@ -32,7 +34,7 @@ type ActiveTurn = { approval?: { id: string; resolve: (approved: boolean) => void }; chart?: UsageChart; }; -const INTERRUPTED_WRITE = "A submitted change may have completed. Check the relevant page before trying again."; +const INTERRUPTED_WRITE: ParseKeys = "aria.session.interruptedWrite"; const RESOURCE_QUERIES = new Set([ "keys", "infiniteKeys", @@ -59,7 +61,7 @@ export function useLiteAdmin(session: LiteAdminSession) { () => () => { const turn = active.current; active.current = null; - if (turn?.outcome === "submitted") toast.warning(INTERRUPTED_WRITE); + if (turn?.outcome === "submitted") toast.warning(i18n.t(INTERRUPTED_WRITE)); turn?.controller.abort(); turn?.approval?.resolve(false); }, @@ -168,10 +170,10 @@ export function useLiteAdmin(session: LiteAdminSession) { } catch (error) { if (active.current !== turn) return; if (!sameSession()) { - if (turn.outcome === "submitted") toast.warning(INTERRUPTED_WRITE); + if (turn.outcome === "submitted") toast.warning(i18n.t(INTERRUPTED_WRITE)); setEntries([]); } else if (!turn.controller.signal.aborted && turn.outcome !== "unknown") { - const context = turn.outcome === "completed" ? "Completed changes are shown above. " : ""; + const context = turn.outcome === "completed" ? `${i18n.t("aria.session.completedChangesShown")} ` : ""; setEntries((current) => [ ...current, { kind: "error", id: crypto.randomUUID(), text: context + extractProxyErrorMessage(error) }, diff --git a/ui/litellm-dashboard/src/components/mcp_tools/McpCrudPermissionPanel.tsx b/ui/litellm-dashboard/src/components/mcp_tools/McpCrudPermissionPanel.tsx index d33b28e9ba7..74f3f5b9317 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/McpCrudPermissionPanel.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/McpCrudPermissionPanel.tsx @@ -181,7 +181,7 @@ const McpCrudPermissionPanel: React.FC = ({ ) : ( )} - {meta.label} + {t(meta.label)} {meta.risk === "high" ? t("mcpTools.crudPermissionPanel.risk.high") @@ -210,7 +210,7 @@ const McpCrudPermissionPanel: React.FC = ({

{/* Checkbox supports `indeterminate`; Switch does not. */} toggleGroup(op, checked)} @@ -223,7 +223,7 @@ const McpCrudPermissionPanel: React.FC = ({ {/* Description row */} {!isCollapsed && (
- {meta.description} + {t(meta.description)}
)} diff --git a/ui/litellm-dashboard/src/i18n/locales/en.json b/ui/litellm-dashboard/src/i18n/locales/en.json index ea4435d50dc..b237a3444e5 100644 --- a/ui/litellm-dashboard/src/i18n/locales/en.json +++ b/ui/litellm-dashboard/src/i18n/locales/en.json @@ -5553,6 +5553,163 @@ "autoApply": "Changes apply automatically when the proxy refreshes its configuration." } }, + "aria": { + "page": { + "unavailable": "Aria is not available for this session." + }, + "usageTrigger": { + "ask": "Ask Aria" + }, + "destination": { + "loadingSettings": "Loading gateway settings", + "settingsError": "Could not load gateway settings.", + "retry": "Retry", + "consentTitle": "Connect to the configured gateway", + "consentBody": "This gateway uses a different address. Aria will send your existing session credential to the address shown above.", + "useConfigured": "Use configured gateway" + }, + "inferenceTarget": { + "invalid": "The gateway must be a valid HTTP(S) URL without credentials, a query, or a fragment.", + "httpsRequired": "Inference must use HTTPS when the dashboard or management gateway uses HTTPS." + }, + "chat": { + "startOver": "Start over" + }, + "composer": { + "tooLong": "Keep your message within {{limit}} characters.", + "placeholder": "Ask Aria…" + }, + "modelPicker": { + "loading": "Loading models", + "ariaLabel": "{{agent}} model", + "placeholder": "Choose a chat model", + "noMatch": "No matching models", + "noModels": "No available models" + }, + "modelStatus": { + "loadError": "Could not load models.", + "retry": "Retry" + }, + "start": { + "title": "What would you like to know?", + "descriptionAdmin": "Manage your gateway · Changes require approval", + "descriptionUser": "Ask about your usage", + "descriptionGateway": "Ask about gateway usage", + "suggestedQuestions": "Suggested questions", + "questions": { + "spendOverview": { + "label": "Spend overview", + "question": "How much have I spent this week?" + }, + "costByModel": { + "label": "Cost by model", + "question": "Which models are costing the most?" + }, + "findSavings": { + "label": "Find savings", + "question": "How can I reduce my spend?" + } + } + }, + "conversation": { + "ariaLabel": "{{agent}} conversation", + "yourQuestion": "Your question", + "workingLabel": "{{agent}} is working", + "working": "Working…", + "jumpToLatest": "Jump to latest", + "status": { + "review": "Review change", + "applying": "Applying…", + "completed": "Completed", + "cancelled": "Cancelled", + "unknown": "Check result" + }, + "details": "Details", + "copyGeneratedKey": "Copy generated key", + "keyNotice": "Copy this key now. It stays only in this chat session.", + "cancel": "Cancel", + "confirmChange": "Confirm change" + }, + "actions": { + "keyCreate": "Create a virtual key", + "keyUpdate": "Update a virtual key", + "keyDetachFromTeam": "Remove a virtual key from its team without deleting the key", + "keyDelete": "Delete virtual keys", + "keyBlock": "Block a virtual key", + "keyUnblock": "Unblock a virtual key", + "teamCreate": "Create a team", + "teamUpdate": "Update a team", + "teamDelete": "Delete teams", + "teamMemberAdd": "Add a team member", + "teamMemberUpdate": "Update a team member", + "teamMemberDelete": "Remove a team member", + "userCreate": "Create a user without a key", + "userUpdate": "Update a user", + "userDelete": "Delete users", + "budgetCreate": "Create a budget", + "budgetUpdate": "Update a budget", + "budgetDelete": "Delete a budget", + "unverified": "The change could not be verified. Check the resource before trying again." + }, + "session": { + "interruptedWrite": "A submitted change may have completed. Check the relevant page before trying again.", + "completedChangesShown": "Completed changes are shown above." + }, + "agent": { + "stepLimit": "I reached the step limit. Any completed actions remain applied; check the relevant page before continuing.", + "noAnswer": "The model returned no answer. Try another request or model." + }, + "answer": { + "ariaLabel": "{{agent}} answer", + "export": "Export answer", + "exportFailed": "Could not open the PDF print dialog. Please try again.", + "printPreview": "{{agent}} print preview" + }, + "chart": { + "valuesLabel": "{{title}} values", + "category": "Category", + "valuesUnavailable": "Values unavailable for: {{labels}}.", + "unavailable": "Unavailable", + "titles": { + "dailySpend": "Daily spend", + "dailyRequests": "Daily requests", + "dailyTokens": "Daily tokens", + "spendByModel": "Spend by model", + "spendByProvider": "Spend by provider", + "tokenBreakdown": "Token breakdown" + }, + "scopes": { + "gateway": "Gateway usage", + "allTeams": "All teams", + "allTags": "All tags", + "yours": "Your usage" + }, + "labels": { + "unknown": "Unknown", + "other": "Other", + "prompt": "Prompt", + "completion": "Completion", + "cacheRead": "Cache read", + "cacheCreation": "Cache creation" + }, + "notes": { + "otherRemainder": "Up to six leading entries. Other includes remaining recorded spend.", + "cacheSeparate": "Cache counts are reported separately. Do not add them to total tokens." + } + } + }, + "betaBadge": { + "label": "Beta" + }, + "chatComposer": { + "stopRequest": "Stop request", + "sendMessage": "Send message", + "codeInterpreterEnabled": "Code Interpreter enabled (click to disable)", + "enableCodeInterpreter": "Enable Code Interpreter" + }, + "userBanner": { + "dismiss": "Dismiss banner" + }, "caching": { "dashboard": { "tabs": { @@ -6640,6 +6797,10 @@ } }, "metadataKeyValueFields": { + "errors": { + "duplicateKey": "Duplicate key", + "missingKey": "Missing key" + }, "keyPlaceholder": "Key", "valuePlaceholder": "Value", "removePair": "Remove key-value pair", @@ -13523,6 +13684,28 @@ } }, "crudPermissionPanel": { + "groups": { + "read": { + "label": "Read", + "description": "Safe operations — fetch, list, search. No side effects." + }, + "create": { + "label": "Create", + "description": "Add new resources — insert, upload, register." + }, + "update": { + "label": "Update", + "description": "Modify existing resources — edit, patch, rename." + }, + "delete": { + "label": "Delete", + "description": "Destructive operations — remove, purge, destroy." + }, + "unknown": { + "label": "Other", + "description": "Operations that could not be automatically classified." + } + }, "risk": { "high": "High Risk", "medium": "Medium Risk", diff --git a/ui/litellm-dashboard/src/utils/mcpToolCrudClassification.ts b/ui/litellm-dashboard/src/utils/mcpToolCrudClassification.ts index 523995cce46..f7b0c91b140 100644 --- a/ui/litellm-dashboard/src/utils/mcpToolCrudClassification.ts +++ b/ui/litellm-dashboard/src/utils/mcpToolCrudClassification.ts @@ -1,3 +1,5 @@ +import type { ParseKeys } from "i18next"; + export type CrudOp = "read" | "create" | "update" | "delete" | "unknown"; const DELETE_RE = /\b(delete|remove|destroy|purge|drop|erase|unlink)\b/i; @@ -56,31 +58,31 @@ export function groupToolsByCrud(tools: MCPToolEntry[]): Record = { read: { - label: "Read", - description: "Safe operations — fetch, list, search. No side effects.", + label: "mcpTools.crudPermissionPanel.groups.read.label", + description: "mcpTools.crudPermissionPanel.groups.read.description", risk: "low", }, create: { - label: "Create", - description: "Add new resources — insert, upload, register.", + label: "mcpTools.crudPermissionPanel.groups.create.label", + description: "mcpTools.crudPermissionPanel.groups.create.description", risk: "medium", }, update: { - label: "Update", - description: "Modify existing resources — edit, patch, rename.", + label: "mcpTools.crudPermissionPanel.groups.update.label", + description: "mcpTools.crudPermissionPanel.groups.update.description", risk: "medium", }, delete: { - label: "Delete", - description: "Destructive operations — remove, purge, destroy.", + label: "mcpTools.crudPermissionPanel.groups.delete.label", + description: "mcpTools.crudPermissionPanel.groups.delete.description", risk: "high", }, unknown: { - label: "Other", - description: "Operations that could not be automatically classified.", + label: "mcpTools.crudPermissionPanel.groups.unknown.label", + description: "mcpTools.crudPermissionPanel.groups.unknown.description", risk: "unknown", }, };