feat(ui): i18n for routing groups

This commit is contained in:
吹雪 2026-06-11 05:54:58 +09:00
parent 17e3d704df
commit 04778d2dad
5 changed files with 201 additions and 56 deletions

View file

@ -2,6 +2,7 @@
import React, { useMemo } from "react";
import { Form, Input, Modal, Select, Space, Typography } from "antd";
import { useTranslation } from "react-i18next";
import type { RoutingGroup, RoutingStrategy } from "./types";
const { Text, Paragraph } = Typography;
@ -43,6 +44,7 @@ const RoutingGroupModal: React.FC<RoutingGroupModalProps> = ({
onSubmit,
saving,
}) => {
const { t } = useTranslation();
const [form] = Form.useForm<FormValues>();
const selectedStrategy = Form.useWatch("routing_strategy", form);
@ -71,7 +73,7 @@ const RoutingGroupModal: React.FC<RoutingGroupModalProps> = ({
form.setFields([
{
name: "routing_strategy_args",
errors: ["Must be valid JSON"],
errors: [t("routingGroups.routingGroupModal.strategyArgsMustBeJson")],
},
]);
return;
@ -88,12 +90,20 @@ const RoutingGroupModal: React.FC<RoutingGroupModalProps> = ({
return (
<Modal
title={mode === "create" ? "Create Routing Group" : `Edit ${initialValue?.group_name ?? ""}`}
title={
mode === "create"
? t("routingGroups.routingGroupModal.titleCreate")
: t("routingGroups.routingGroupModal.titleEdit", { groupName: initialValue?.group_name ?? "" })
}
open={open}
onCancel={onClose}
onOk={handleSubmit}
okText={mode === "create" ? "Create Group" : "Save Changes"}
cancelText="Cancel"
okText={
mode === "create"
? t("routingGroups.routingGroupModal.okTextCreate")
: t("routingGroups.routingGroupModal.okTextEdit")
}
cancelText={t("common.cancel")}
confirmLoading={saving}
destroyOnClose
width={560}
@ -106,51 +116,57 @@ const RoutingGroupModal: React.FC<RoutingGroupModalProps> = ({
initialValues={initialValues}
>
<Form.Item
label="Group Name"
label={t("routingGroups.routingGroupModal.groupNameLabel")}
name="group_name"
rules={[
{ required: true, message: "Group name is required" },
{ max: GROUP_NAME_MAX_LENGTH, message: `Must be ${GROUP_NAME_MAX_LENGTH} characters or fewer` },
{ required: true, message: t("routingGroups.routingGroupModal.groupNameRequired") },
{
max: GROUP_NAME_MAX_LENGTH,
message: t("routingGroups.routingGroupModal.groupNameMaxLength", { max: GROUP_NAME_MAX_LENGTH }),
},
{
pattern: GROUP_NAME_PATTERN,
message: "Only letters, numbers, dot, underscore, and dash are allowed",
message: t("routingGroups.routingGroupModal.groupNamePattern"),
},
{
validator: (_, value: string) => {
if (!value) return Promise.resolve();
if (reservedNames.has(value.trim().toLowerCase())) {
return Promise.reject(new Error("A group with this name already exists"));
return Promise.reject(new Error(t("routingGroups.routingGroupModal.groupNameExists")));
}
return Promise.resolve();
},
},
]}
extra="Use this name as the model in API calls — LiteLLM routes the request to one of the group's models."
extra={t("routingGroups.routingGroupModal.groupNameExtra")}
>
<Input placeholder="fast-chat" disabled={mode === "edit"} />
<Input placeholder={t("routingGroups.routingGroupModal.groupNamePlaceholder")} disabled={mode === "edit"} />
</Form.Item>
<Form.Item
label="Models"
label={t("routingGroups.routingGroupModal.modelsLabel")}
name="models"
rules={[{ required: true, message: "Select at least one model" }]}
extra="Models from your model list that this group routes between."
rules={[{ required: true, message: t("routingGroups.routingGroupModal.modelsRequired") }]}
extra={t("routingGroups.routingGroupModal.modelsExtra")}
>
<Select
mode="multiple"
allowClear
placeholder="Select models"
placeholder={t("routingGroups.routingGroupModal.modelsPlaceholder")}
options={modelOptions.map((m) => ({ label: m, value: m }))}
optionFilterProp="label"
/>
</Form.Item>
<Form.Item
label="Routing Strategy"
label={t("routingGroups.routingGroupModal.routingStrategyLabel")}
name="routing_strategy"
rules={[{ required: true, message: "Strategy is required" }]}
rules={[{ required: true, message: t("routingGroups.routingGroupModal.routingStrategyRequired") }]}
>
<Select options={availableStrategies.map((s) => ({ label: s, value: s }))} placeholder="Select strategy" />
<Select
options={availableStrategies.map((s) => ({ label: s, value: s }))}
placeholder={t("routingGroups.routingGroupModal.strategyPlaceholder")}
/>
</Form.Item>
{selectedStrategy && strategyDescriptions[selectedStrategy] && (
@ -159,21 +175,25 @@ const RoutingGroupModal: React.FC<RoutingGroupModalProps> = ({
{STRATEGIES_WITH_ARGS.has(String(selectedStrategy)) && (
<Form.Item
label="Strategy Arguments (JSON)"
label={t("routingGroups.routingGroupModal.strategyArgsLabel")}
name="routing_strategy_args"
extra={
selectedStrategy === "latency-based-routing"
? 'Example: { "ttl": 3600, "lowest_latency_buffer": 0 }'
: 'Example: { "ttl": 60 }'
? t("routingGroups.routingGroupModal.strategyArgsExampleLatency")
: t("routingGroups.routingGroupModal.strategyArgsExampleUsage")
}
>
<Input.TextArea rows={4} placeholder='{ "ttl": 3600 }' className="font-mono text-xs" />
<Input.TextArea
rows={4}
placeholder={t("routingGroups.routingGroupModal.strategyArgsPlaceholder")}
className="font-mono text-xs"
/>
</Form.Item>
)}
<Space direction="vertical" className="w-full mt-2">
<Text type="secondary" className="text-xs">
Models not claimed by an explicit group fall through to the proxy&apos;s top-level routing strategy.
{t("routingGroups.routingGroupModal.fallbackNote")}
</Text>
</Space>
</Form>

View file

@ -4,6 +4,8 @@ import React, { useState } from "react";
import { Flex, Table, Tabs, Tag, Tooltip, Typography, Button } from "antd";
import type { ColumnsType } from "antd/es/table";
import { BranchesOutlined, DeleteOutlined, EditOutlined, CodeOutlined } from "@ant-design/icons";
import { Trans, useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import type { RoutingGroup } from "./types";
const { Text, Paragraph } = Typography;
@ -16,16 +18,16 @@ interface RoutingGroupsTableProps {
proxyBaseUrl?: string;
}
const formatStrategyLabel = (strategy: string): string => {
const formatStrategyLabel = (t: TFunction, strategy: string): string => {
switch (strategy) {
case "simple-shuffle":
return "Simple Shuffle";
return t("routingGroups.routingGroupsTable.strategySimpleShuffle");
case "least-busy":
return "Least Busy";
return t("routingGroups.routingGroupsTable.strategyLeastBusy");
case "usage-based-routing":
return "Usage Based";
return t("routingGroups.routingGroupsTable.strategyUsageBased");
case "latency-based-routing":
return "Latency Based";
return t("routingGroups.routingGroupsTable.strategyLatencyBased");
default:
return strategy;
}
@ -94,6 +96,7 @@ const SNIPPET_BLOCK_STYLE: React.CSSProperties = {
};
const RoutingGroupSnippet: React.FC<RoutingGroupSnippetProps> = ({ group, baseUrl }) => {
const { t } = useTranslation();
const snippets = {
curl: buildCurlSnippet(group, baseUrl),
python: buildPythonSnippet(group, baseUrl),
@ -103,9 +106,9 @@ const RoutingGroupSnippet: React.FC<RoutingGroupSnippetProps> = ({ group, baseUr
const [activeKey, setActiveKey] = useState<SnippetKey>("curl");
const items = [
{ key: "curl", label: "cURL" },
{ key: "python", label: "Python (OpenAI SDK)" },
{ key: "javascript", label: "JavaScript (OpenAI SDK)" },
{ key: "curl", label: t("routingGroups.routingGroupsTable.tabCurl") },
{ key: "python", label: t("routingGroups.routingGroupsTable.tabPython") },
{ key: "javascript", label: t("routingGroups.routingGroupsTable.tabJavascript") },
].map(({ key, label }) => ({
key,
label,
@ -123,19 +126,23 @@ const RoutingGroupSnippet: React.FC<RoutingGroupSnippetProps> = ({ group, baseUr
onChange={(k) => setActiveKey(k as SnippetKey)}
items={items}
tabBarExtraContent={
<Paragraph copyable={{ text: snippets[activeKey], tooltips: ["Copy", "Copied"] }} className="!mb-0" />
<Paragraph
copyable={{ text: snippets[activeKey], tooltips: [t("common.copy"), t("common.copied")] }}
className="!mb-0"
/>
}
/>
);
};
const RoutingGroupsTable: React.FC<RoutingGroupsTableProps> = ({ groups, loading, onEdit, onDelete, proxyBaseUrl }) => {
const { t } = useTranslation();
const [expandedRowKeys, setExpandedRowKeys] = useState<React.Key[]>([]);
const baseUrl = resolveBaseUrl(proxyBaseUrl);
const columns: ColumnsType<RoutingGroup> = [
{
title: "GROUP NAME",
title: t("routingGroups.routingGroupsTable.colGroupName"),
dataIndex: "group_name",
key: "group_name",
render: (name: string) => (
@ -145,7 +152,7 @@ const RoutingGroupsTable: React.FC<RoutingGroupsTableProps> = ({ groups, loading
),
},
{
title: "MODELS",
title: t("routingGroups.routingGroupsTable.colModels"),
dataIndex: "models",
key: "models",
render: (models: string[]) => (
@ -157,24 +164,24 @@ const RoutingGroupsTable: React.FC<RoutingGroupsTableProps> = ({ groups, loading
),
},
{
title: "STRATEGY",
title: t("routingGroups.routingGroupsTable.colStrategy"),
dataIndex: "routing_strategy",
key: "routing_strategy",
render: (strategy: string) => (
<span className="inline-flex items-center gap-1.5">
<BranchesOutlined className="text-gray-400" />
<Text>{formatStrategyLabel(strategy)}</Text>
<Text>{formatStrategyLabel(t, strategy)}</Text>
</span>
),
},
{
title: "ACTIONS",
title: t("common.actions"),
key: "actions",
width: 120,
align: "right",
render: (_, group) => (
<Flex justify="flex-end" align="center" gap={8}>
<Tooltip title="Edit">
<Tooltip title={t("common.edit")}>
<Button
type="text"
icon={<EditOutlined />}
@ -184,7 +191,7 @@ const RoutingGroupsTable: React.FC<RoutingGroupsTableProps> = ({ groups, loading
}}
/>
</Tooltip>
<Tooltip title="Delete">
<Tooltip title={t("common.delete")}>
<Button
type="text"
danger
@ -214,11 +221,14 @@ const RoutingGroupsTable: React.FC<RoutingGroupsTableProps> = ({ groups, loading
<div className="bg-gray-50 border border-gray-200 rounded-md p-4 my-2">
<Flex align="center" gap={8} className="mb-2">
<CodeOutlined className="text-blue-500" />
<Text strong>How routing works for this group</Text>
<Text strong>{t("routingGroups.routingGroupsTable.howRoutingWorksTitle")}</Text>
</Flex>
<Paragraph className="text-sm text-gray-600 mb-3">
Callers request any model in the group by name — LiteLLM picks a deployment behind the scenes using the{" "}
<Text strong>{formatStrategyLabel(group.routing_strategy)}</Text> strategy.
<Trans
i18nKey="routingGroups.routingGroupsTable.howRoutingWorksDesc"
values={{ strategy: formatStrategyLabel(t, group.routing_strategy) }}
components={{ strong: <Text strong /> }}
/>
</Paragraph>
<RoutingGroupSnippet group={group} baseUrl={baseUrl} />
</div>

View file

@ -3,6 +3,7 @@
import React, { useMemo, useState } from "react";
import { Button, Card, Flex, Input, Modal, Space, Typography } from "antd";
import { PlusOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons";
import { Trans, useTranslation } from "react-i18next";
import { useRoutingGroups, useSaveRoutingGroups } from "@/app/(dashboard)/hooks/routingGroups/useRoutingGroups";
import { useRouterFields } from "@/app/(dashboard)/hooks/router/useRouterFields";
import { useModelHub } from "@/app/(dashboard)/hooks/models/useModels";
@ -15,6 +16,7 @@ import type { RoutingGroup } from "./types";
const { Text } = Typography;
const RoutingGroups: React.FC = () => {
const { t } = useTranslation();
const { data, isLoading, refetch, isFetching } = useRoutingGroups();
const { data: routerFields } = useRouterFields();
const { data: modelHub } = useModelHub();
@ -76,12 +78,12 @@ const RoutingGroups: React.FC = () => {
await saveMutation.mutateAsync(next);
NotificationsManager.success(
drawerMode === "create"
? `Created routing group "${incoming.group_name}"`
: `Updated routing group "${incoming.group_name}"`,
? t("routingGroups.index.createSucceeded", { groupName: incoming.group_name })
: t("routingGroups.index.updateSucceeded", { groupName: incoming.group_name }),
);
setDrawerOpen(false);
} catch (err) {
NotificationsManager.error(err instanceof Error ? err.message : "Failed to save routing group");
NotificationsManager.error(err instanceof Error ? err.message : t("routingGroups.index.saveFailed"));
}
};
@ -90,10 +92,10 @@ const RoutingGroups: React.FC = () => {
const next = groups.filter((g) => g.group_name !== deletingGroup.group_name);
try {
await saveMutation.mutateAsync(next);
NotificationsManager.success(`Deleted routing group "${deletingGroup.group_name}"`);
NotificationsManager.success(t("routingGroups.index.deleteSucceeded", { groupName: deletingGroup.group_name }));
setDeletingGroup(null);
} catch (err) {
NotificationsManager.error(err instanceof Error ? err.message : "Failed to delete routing group");
NotificationsManager.error(err instanceof Error ? err.message : t("routingGroups.index.deleteFailed"));
}
};
@ -104,20 +106,20 @@ const RoutingGroups: React.FC = () => {
<Input
allowClear
prefix={<SearchOutlined className="text-gray-400" />}
placeholder="Search groups..."
placeholder={t("routingGroups.index.searchPlaceholder")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="max-w-sm"
/>
<Flex align="center" gap={12}>
<Button icon={<ReloadOutlined />} onClick={() => refetch()} loading={isFetching && !isLoading}>
Refresh
{t("common.refresh")}
</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
Create Group
{t("routingGroups.index.createGroup")}
</Button>
<Text type="secondary" className="text-sm whitespace-nowrap">
Showing {filteredGroups.length} {filteredGroups.length === 1 ? "result" : "results"}
{t("routingGroups.index.showingResults", { count: filteredGroups.length })}
</Text>
</Flex>
</Flex>
@ -146,16 +148,19 @@ const RoutingGroups: React.FC = () => {
<Modal
open={Boolean(deletingGroup)}
title="Delete routing group?"
okText="Delete"
title={t("routingGroups.index.deleteTitle")}
okText={t("common.delete")}
okButtonProps={{ danger: true, loading: saveMutation.isPending }}
cancelText="Cancel"
cancelText={t("common.cancel")}
onOk={confirmDelete}
onCancel={() => setDeletingGroup(null)}
>
<Text>
Models in <Text strong>{deletingGroup?.group_name}</Text> will fall back to the proxy&apos;s top-level routing
strategy. This cannot be undone.
<Trans
i18nKey="routingGroups.index.deleteFallbackNote"
values={{ groupName: deletingGroup?.group_name }}
components={{ strong: <Text strong /> }}
/>
</Text>
</Modal>
</Space>

View file

@ -7729,5 +7729,60 @@
"title": "Vector Stores",
"empty": "No vector stores configured"
}
},
"routingGroups": {
"routingGroupModal": {
"titleCreate": "Create Routing Group",
"titleEdit": "Edit {{groupName}}",
"okTextCreate": "Create Group",
"okTextEdit": "Save Changes",
"groupNameLabel": "Group Name",
"groupNameRequired": "Group name is required",
"groupNameMaxLength": "Must be {{max}} characters or fewer",
"groupNamePattern": "Only letters, numbers, dot, underscore, and dash are allowed",
"groupNameExists": "A group with this name already exists",
"groupNameExtra": "Use this name as the model in API calls — LiteLLM routes the request to one of the group's models.",
"groupNamePlaceholder": "fast-chat",
"modelsLabel": "Models",
"modelsRequired": "Select at least one model",
"modelsExtra": "Models from your model list that this group routes between.",
"modelsPlaceholder": "Select models",
"routingStrategyLabel": "Routing Strategy",
"routingStrategyRequired": "Strategy is required",
"strategyPlaceholder": "Select strategy",
"strategyArgsLabel": "Strategy Arguments (JSON)",
"strategyArgsExampleLatency": "Example: { \"ttl\": 3600, \"lowest_latency_buffer\": 0 }",
"strategyArgsExampleUsage": "Example: { \"ttl\": 60 }",
"strategyArgsMustBeJson": "Must be valid JSON",
"strategyArgsPlaceholder": "{ \"ttl\": 3600 }",
"fallbackNote": "Models not claimed by an explicit group fall through to the proxy's top-level routing strategy."
},
"routingGroupsTable": {
"colGroupName": "GROUP NAME",
"colModels": "MODELS",
"colStrategy": "STRATEGY",
"strategySimpleShuffle": "Simple Shuffle",
"strategyLeastBusy": "Least Busy",
"strategyUsageBased": "Usage Based",
"strategyLatencyBased": "Latency Based",
"howRoutingWorksTitle": "How routing works for this group",
"howRoutingWorksDesc": "Callers request any model in the group by name — LiteLLM picks a deployment behind the scenes using the <strong>{{strategy}}</strong> strategy.",
"tabCurl": "cURL",
"tabPython": "Python (OpenAI SDK)",
"tabJavascript": "JavaScript (OpenAI SDK)"
},
"index": {
"searchPlaceholder": "Search groups...",
"showingResults_one": "Showing {{count}} result",
"showingResults_other": "Showing {{count}} results",
"createGroup": "Create Group",
"deleteTitle": "Delete routing group?",
"deleteFallbackNote": "Models in <strong>{{groupName}}</strong> will fall back to the proxy's top-level routing strategy. This cannot be undone.",
"createSucceeded": "Created routing group \"{{groupName}}\"",
"updateSucceeded": "Updated routing group \"{{groupName}}\"",
"deleteSucceeded": "Deleted routing group \"{{groupName}}\"",
"saveFailed": "Failed to save routing group",
"deleteFailed": "Failed to delete routing group"
}
}
}

View file

@ -7729,5 +7729,60 @@
"title": "向量存储",
"empty": "未配置任何向量存储"
}
},
"routingGroups": {
"routingGroupModal": {
"titleCreate": "创建路由组",
"titleEdit": "编辑 {{groupName}}",
"okTextCreate": "创建组",
"okTextEdit": "保存更改",
"groupNameLabel": "组名",
"groupNameRequired": "组名不能为空",
"groupNameMaxLength": "不能超过 {{max}} 个字符",
"groupNamePattern": "只允许字母、数字、点、下划线和短横线",
"groupNameExists": "已存在同名组",
"groupNameExtra": "在 API 调用中将此名称作为模型使用 — LiteLLM 会将请求路由到组内某个模型。",
"groupNamePlaceholder": "fast-chat",
"modelsLabel": "模型",
"modelsRequired": "至少选择一个模型",
"modelsExtra": "此组路由的模型列表中的模型。",
"modelsPlaceholder": "选择模型",
"routingStrategyLabel": "路由策略",
"routingStrategyRequired": "策略不能为空",
"strategyPlaceholder": "选择策略",
"strategyArgsLabel": "策略参数(JSON)",
"strategyArgsExampleLatency": "示例:{ \"ttl\": 3600, \"lowest_latency_buffer\": 0 }",
"strategyArgsExampleUsage": "示例:{ \"ttl\": 60 }",
"strategyArgsMustBeJson": "必须是有效的 JSON",
"strategyArgsPlaceholder": "{ \"ttl\": 3600 }",
"fallbackNote": "未被明确指定到组的模型将回退到代理的顶级路由策略。"
},
"routingGroupsTable": {
"colGroupName": "组名",
"colModels": "模型",
"colStrategy": "策略",
"strategySimpleShuffle": "简单随机",
"strategyLeastBusy": "最少繁忙",
"strategyUsageBased": "基于用量",
"strategyLatencyBased": "基于延迟",
"howRoutingWorksTitle": "此组的路由方式",
"howRoutingWorksDesc": "调用方按名称请求组内任意模型 — LiteLLM 在后台使用 <strong>{{strategy}}</strong> 策略选取部署。",
"tabCurl": "cURL",
"tabPython": "Python(OpenAI SDK)",
"tabJavascript": "JavaScript(OpenAI SDK)"
},
"index": {
"searchPlaceholder": "搜索组...",
"showingResults_one": "显示 {{count}} 条结果",
"showingResults_other": "显示 {{count}} 条结果",
"createGroup": "创建组",
"deleteTitle": "删除路由组?",
"deleteFallbackNote": "<strong>{{groupName}}</strong> 中的模型将回退到代理的顶级路由策略。此操作不可撤销。",
"createSucceeded": "已创建路由组\"{{groupName}}\"",
"updateSucceeded": "已更新路由组\"{{groupName}}\"",
"deleteSucceeded": "已删除路由组\"{{groupName}}\"",
"saveFailed": "保存路由组失败",
"deleteFailed": "删除路由组失败"
}
}
}