From 7f6045e083d69d2a629fa56d28cb21781ab4c3f3 Mon Sep 17 00:00:00 2001 From: ding113 Date: Sat, 4 Apr 2026 14:30:06 +0000 Subject: [PATCH 1/4] feat: support provider model redirect rules --- .../providers/form/modelRedirect.json | 12 + .../settings/providers/form/modelSelect.json | 8 +- .../providers/form/modelRedirect.json | 14 +- .../settings/providers/form/modelSelect.json | 8 +- .../providers/form/modelRedirect.json | 14 +- .../settings/providers/form/modelSelect.json | 8 +- .../providers/form/modelRedirect.json | 12 + .../settings/providers/form/modelSelect.json | 8 +- .../providers/form/modelRedirect.json | 14 +- .../settings/providers/form/modelSelect.json | 8 +- src/actions/providers.ts | 23 +- .../batch-edit/analyze-batch-settings.ts | 5 +- .../batch-edit/build-patch-draft.ts | 3 +- .../forms/provider-form.legacy.tsx | 18 +- .../_components/forms/provider-form/index.tsx | 8 +- .../provider-form/provider-form-context.tsx | 7 +- .../provider-form/provider-form-types.ts | 5 +- .../sections/routing-section.tsx | 4 +- .../_components/model-multi-select.tsx | 472 ++++++++++++----- .../_components/model-redirect-editor.tsx | 494 +++++++++++++----- src/app/api/actions/[...route]/route.ts | 6 +- src/app/v1/_lib/proxy/model-redirector.ts | 27 +- src/drizzle/schema.ts | 6 +- src/lib/provider-model-redirect-schema.ts | 56 ++ src/lib/provider-model-redirects.ts | 138 +++++ src/lib/provider-patch-contract.ts | 13 +- src/lib/validation/schemas.ts | 5 +- src/repository/_shared/transformers.ts | 3 +- src/repository/provider.ts | 6 +- src/types/message.ts | 5 + src/types/provider.ts | 22 +- tests/api/providers-actions.test.ts | 16 +- tests/integration/batch-edit-prefill.test.ts | 10 +- .../providers-batch-field-mapping.test.ts | 22 +- .../actions/providers-patch-contract.test.ts | 30 +- .../batch-edit/analyze-batch-settings.test.ts | 22 +- .../provider-form-clone-deep-copy.test.ts | 10 +- .../unit/lib/provider-model-redirects.test.ts | 78 +++ .../providers/build-patch-draft.test.ts | 10 +- ...del-multi-select-custom-models-ui.test.tsx | 112 +++- .../providers/model-redirect-editor.test.tsx | 171 ++++++ .../provider-form-batch-context.test.ts | 2 +- 42 files changed, 1537 insertions(+), 378 deletions(-) create mode 100644 src/lib/provider-model-redirect-schema.ts create mode 100644 src/lib/provider-model-redirects.ts create mode 100644 tests/unit/lib/provider-model-redirects.test.ts create mode 100644 tests/unit/settings/providers/model-redirect-editor.test.tsx diff --git a/messages/en/settings/providers/form/modelRedirect.json b/messages/en/settings/providers/form/modelRedirect.json index 48628bb53..988f1b4d7 100644 --- a/messages/en/settings/providers/form/modelRedirect.json +++ b/messages/en/settings/providers/form/modelRedirect.json @@ -3,12 +3,24 @@ "addNewRule": "Add New Rule", "alreadyExists": "Model \"{model}\" already has a redirect rule", "currentRules": "Current Rules ({count})", + "matchTypeLabel": "Match Type", + "matchTypeExact": "Exact", + "matchTypePrefix": "Prefix", + "matchTypeSuffix": "Suffix", + "matchTypeContains": "Contains", + "matchTypeRegex": "Regex", + "orderHint": "Top rules match first", + "ruleMoved": "This rule changed while you were editing. Please try again.", "description": "Redirect Claude Code client requested models (e.g. claude-sonnet-4.5) to upstream provider supported models (e.g. glm-4.6, gemini-pro). For cost optimization or third-party AI integration.", "emptyState": "No redirect rules yet. After adding rules, the system will automatically rewrite model names in requests.", "sourceEmpty": "Source model name cannot be empty", + "sourceTooLong": "Source model name is too long", "sourceModel": "User Requested Model", "sourcePlaceholder": "e.g. claude-sonnet-4-5-20250929", + "regexInvalid": "Regex pattern is invalid", + "regexUnsafe": "Regex pattern has potential ReDoS risk", "targetEmpty": "Target model name cannot be empty", + "targetTooLong": "Target model name is too long", "targetModel": "Actual Forwarded Model", "targetPlaceholder": "e.g. glm-4.6" } diff --git a/messages/en/settings/providers/form/modelSelect.json b/messages/en/settings/providers/form/modelSelect.json index 03fbb53e6..50dadc138 100644 --- a/messages/en/settings/providers/form/modelSelect.json +++ b/messages/en/settings/providers/form/modelSelect.json @@ -10,11 +10,17 @@ "notFound": "Model not found", "openai": "OpenAI", "refresh": "Refresh model list", + "selectedEditEmpty": "Model name cannot be empty", + "selectedEditExists": "Model \"{model}\" is already selected", + "selectedGroupLabel": "Selected Models", "searchPlaceholder": "Search model name...", "selectAll": "Select All ({count})", "selectedCount": "Selected {count} models", + "selectedListHint": "Edit or remove without opening the dropdown", + "selectedListLabel": "Selected whitelist ({count})", "sourceFallback": "Local", "sourceFallbackDesc": "Using local price list (upstream unavailable or unsupported)", "sourceUpstream": "Upstream", - "sourceUpstreamDesc": "Model list from upstream provider API" + "sourceUpstreamDesc": "Model list from upstream provider API", + "availableGroupLabel": "Available Models" } diff --git a/messages/ja/settings/providers/form/modelRedirect.json b/messages/ja/settings/providers/form/modelRedirect.json index cd55a47ec..77d92db29 100644 --- a/messages/ja/settings/providers/form/modelRedirect.json +++ b/messages/ja/settings/providers/form/modelRedirect.json @@ -3,12 +3,24 @@ "addNewRule": "新規ルールを追加", "alreadyExists": "モデル \"{model}\" のリダイレクトルールは既に存在します", "currentRules": "現在のルール ({count})", + "matchTypeLabel": "一致方法", + "matchTypeExact": "完全一致", + "matchTypePrefix": "前方一致", + "matchTypeSuffix": "後方一致", + "matchTypeContains": "部分一致", + "matchTypeRegex": "正規表現", + "orderHint": "上のルールから順に適用されます", + "ruleMoved": "編集中にルールが変更されました。もう一度お試しください。", "description": "Claude Code クライアントがリクエストするモデル(例:claude-sonnet-4.5)を、上流プロバイダーが実際にサポートするモデル(例:glm-4.6、gemini-pro)にリダイレクトします。コスト最適化やサードパーティAIサービスへの接続に使用します。", "emptyState": "リダイレクトルールがありません。ルールを追加すると、システムは自動的にリクエスト内のモデル名を書き換えます。", "sourceEmpty": "ソースモデル名を入力してください", + "sourceTooLong": "ソースモデル名が長すぎます", "sourceModel": "ユーザーがリクエストするモデル", "sourcePlaceholder": "例:claude-sonnet-4-5-20250929", "targetEmpty": "ターゲットモデル名を入力してください", + "targetTooLong": "ターゲットモデル名が長すぎます", "targetModel": "実際に転送されるモデル", - "targetPlaceholder": "例:glm-4.6" + "targetPlaceholder": "例:glm-4.6", + "regexInvalid": "正規表現が無効です", + "regexUnsafe": "正規表現に ReDoS のリスクがあります" } diff --git a/messages/ja/settings/providers/form/modelSelect.json b/messages/ja/settings/providers/form/modelSelect.json index 501307f22..1d034d487 100644 --- a/messages/ja/settings/providers/form/modelSelect.json +++ b/messages/ja/settings/providers/form/modelSelect.json @@ -10,11 +10,17 @@ "notFound": "モデルが見つかりません", "openai": "OpenAI", "refresh": "モデルリストを更新", + "selectedEditEmpty": "モデル名は空にできません", + "selectedEditExists": "モデル \"{model}\" はすでに選択されています", + "selectedGroupLabel": "選択済みモデル", "searchPlaceholder": "モデル名を検索...", "selectAll": "すべて選択 ({count})", "selectedCount": "{count} 個のモデルを選択済み", + "selectedListHint": "ドロップダウンを開かずに編集・削除できます", + "selectedListLabel": "選択済みホワイトリスト ({count})", "sourceFallback": "ローカル", "sourceFallbackDesc": "ローカル価格表のモデルリストを使用(上流が利用不可または未対応)", "sourceUpstream": "上流", - "sourceUpstreamDesc": "モデルリストは上流プロバイダーAPIから取得" + "sourceUpstreamDesc": "モデルリストは上流プロバイダーAPIから取得", + "availableGroupLabel": "利用可能なモデル" } diff --git a/messages/ru/settings/providers/form/modelRedirect.json b/messages/ru/settings/providers/form/modelRedirect.json index 6ec29dce4..4652fd0e8 100644 --- a/messages/ru/settings/providers/form/modelRedirect.json +++ b/messages/ru/settings/providers/form/modelRedirect.json @@ -3,12 +3,24 @@ "addNewRule": "Добавить новое правило", "alreadyExists": "Правило перенаправления для модели \"{model}\" уже существует", "currentRules": "Текущие правила ({count})", + "matchTypeLabel": "Тип совпадения", + "matchTypeExact": "Точное совпадение", + "matchTypePrefix": "Префикс", + "matchTypeSuffix": "Суффикс", + "matchTypeContains": "Содержит", + "matchTypeRegex": "Регулярное выражение", + "orderHint": "Правила сверху применяются первыми", + "ruleMoved": "Правило изменилось во время редактирования. Повторите попытку.", "description": "Перенаправлять запросы моделей от клиента Claude Code (например, claude-sonnet-4.5) к моделям, поддерживаемым вышестоящим провайдером (например, glm-4.6, gemini-pro). Используется для оптимизации затрат или подключения сторонних AI-сервисов.", "emptyState": "Пока нет правил перенаправления. После добавления правил система автоматически переписывает названия моделей в запросах.", "sourceEmpty": "Название исходной модели не может быть пустым", + "sourceTooLong": "Название исходной модели слишком длинное", "sourceModel": "Запрашиваемая пользователем модель", "sourcePlaceholder": "Например: claude-sonnet-4-5-20250929", "targetEmpty": "Название целевой модели не может быть пустым", + "targetTooLong": "Название целевой модели слишком длинное", "targetModel": "Фактически перенаправляемая модель", - "targetPlaceholder": "Например: glm-4.6" + "targetPlaceholder": "Например: glm-4.6", + "regexInvalid": "Некорректное регулярное выражение", + "regexUnsafe": "Регулярное выражение имеет риск ReDoS" } diff --git a/messages/ru/settings/providers/form/modelSelect.json b/messages/ru/settings/providers/form/modelSelect.json index 2fb1713ef..d5393d750 100644 --- a/messages/ru/settings/providers/form/modelSelect.json +++ b/messages/ru/settings/providers/form/modelSelect.json @@ -10,11 +10,17 @@ "notFound": "Модели не найдены", "openai": "OpenAI", "refresh": "Обновить список моделей", + "selectedEditEmpty": "Имя модели не может быть пустым", + "selectedEditExists": "Модель \"{model}\" уже выбрана", + "selectedGroupLabel": "Выбранные модели", "searchPlaceholder": "Поиск по названию модели...", "selectAll": "Выбрать все ({count})", "selectedCount": "Выбрано моделей: {count}", + "selectedListHint": "Можно редактировать и удалять без открытия списка", + "selectedListLabel": "Белый список ({count})", "sourceFallback": "Локально", "sourceFallbackDesc": "Используется локальный прайс-лист (upstream недоступен или не поддерживается)", "sourceUpstream": "Upstream", - "sourceUpstreamDesc": "Список моделей из API провайдера" + "sourceUpstreamDesc": "Список моделей из API провайдера", + "availableGroupLabel": "Доступные модели" } diff --git a/messages/zh-CN/settings/providers/form/modelRedirect.json b/messages/zh-CN/settings/providers/form/modelRedirect.json index fc00c1e20..c8c73f3a5 100644 --- a/messages/zh-CN/settings/providers/form/modelRedirect.json +++ b/messages/zh-CN/settings/providers/form/modelRedirect.json @@ -1,14 +1,26 @@ { "currentRules": "当前规则 ({count})", "addNewRule": "添加新规则", + "matchTypeLabel": "匹配方式", + "matchTypeExact": "精确匹配", + "matchTypePrefix": "前缀匹配", + "matchTypeSuffix": "后缀匹配", + "matchTypeContains": "关键词匹配", + "matchTypeRegex": "正则匹配", + "orderHint": "靠前规则优先生效", + "ruleMoved": "规则在编辑期间发生了变化,请重试", "sourceModel": "用户请求的模型", "targetModel": "实际转发的模型", "sourcePlaceholder": "例如: claude-sonnet-4-5-20250929", "targetPlaceholder": "例如: glm-4.6", "add": "添加", "sourceEmpty": "源模型名称不能为空", + "sourceTooLong": "源模型名称过长", "targetEmpty": "目标模型名称不能为空", + "targetTooLong": "目标模型名称过长", "alreadyExists": "模型 \"{model}\" 已存在重定向规则", + "regexInvalid": "正则表达式无效", + "regexUnsafe": "正则表达式存在 ReDoS 风险", "description": "将 Claude Code 客户端请求的模型(如 claude-sonnet-4.5)重定向到上游供应商实际支持的模型(如 glm-4.6、gemini-pro)。用于成本优化或接入第三方 AI 服务。", "emptyState": "暂无重定向规则。添加规则后,系统将自动重写请求中的模型名称。" } diff --git a/messages/zh-CN/settings/providers/form/modelSelect.json b/messages/zh-CN/settings/providers/form/modelSelect.json index 663c2e8d2..46e1f5a82 100644 --- a/messages/zh-CN/settings/providers/form/modelSelect.json +++ b/messages/zh-CN/settings/providers/form/modelSelect.json @@ -16,5 +16,11 @@ "sourceUpstreamDesc": "模型列表来自上游服务商 API", "sourceFallback": "本地", "sourceFallbackDesc": "使用本地价格表中的模型列表(上游获取失败或不支持)", - "refresh": "刷新模型列表" + "refresh": "刷新模型列表", + "selectedEditEmpty": "模型名称不能为空", + "selectedEditExists": "模型 \"{model}\" 已在白名单中", + "selectedGroupLabel": "已选模型", + "selectedListHint": "无需展开下拉框即可直接编辑或删除", + "selectedListLabel": "已选白名单 ({count})", + "availableGroupLabel": "可选模型" } diff --git a/messages/zh-TW/settings/providers/form/modelRedirect.json b/messages/zh-TW/settings/providers/form/modelRedirect.json index 779c7bd00..899f784ef 100644 --- a/messages/zh-TW/settings/providers/form/modelRedirect.json +++ b/messages/zh-TW/settings/providers/form/modelRedirect.json @@ -3,12 +3,24 @@ "addNewRule": "新增規則", "alreadyExists": "模型「{model}」已存在重新導向規則", "currentRules": "目前規則 ({count})", + "matchTypeLabel": "匹配方式", + "matchTypeExact": "精確匹配", + "matchTypePrefix": "前綴匹配", + "matchTypeSuffix": "後綴匹配", + "matchTypeContains": "關鍵字匹配", + "matchTypeRegex": "正則匹配", + "orderHint": "越前面的規則越先命中", + "ruleMoved": "規則在編輯期間已變更,請再試一次", "description": "將 Claude Code 客戶端請求的模型(如 claude-sonnet-4.5)重新導向到上游供應商實際支援的模型(如 glm-4.6、gemini-pro)。用於成本最佳化或接入第三方 AI 服務。", "emptyState": "目前無重新導向規則。新增規則後,系統將自動重寫請求中的模型名稱。", "sourceEmpty": "來源模型名稱不能為空", + "sourceTooLong": "來源模型名稱過長", "sourceModel": "使用者請求的模型", "sourcePlaceholder": "例如:claude-sonnet-4-5-20250929", "targetEmpty": "目標模型名稱不能為空", + "targetTooLong": "目標模型名稱過長", "targetModel": "實際轉發的模型", - "targetPlaceholder": "例如:glm-4.6" + "targetPlaceholder": "例如:glm-4.6", + "regexInvalid": "正則表達式無效", + "regexUnsafe": "正則表達式存在 ReDoS 風險" } diff --git a/messages/zh-TW/settings/providers/form/modelSelect.json b/messages/zh-TW/settings/providers/form/modelSelect.json index 9a4186b49..1f0e99089 100644 --- a/messages/zh-TW/settings/providers/form/modelSelect.json +++ b/messages/zh-TW/settings/providers/form/modelSelect.json @@ -10,11 +10,17 @@ "notFound": "找不到模型", "openai": "OpenAI", "refresh": "重新整理模型列表", + "selectedEditEmpty": "模型名稱不能為空", + "selectedEditExists": "模型「{model}」已在白名單中", + "selectedGroupLabel": "已選模型", "searchPlaceholder": "搜尋模型名稱...", "selectAll": "全選 ({count})", "selectedCount": "已選擇 {count} 個模型", + "selectedListHint": "無需展開下拉框即可直接編輯或刪除", + "selectedListLabel": "已選白名單 ({count})", "sourceFallback": "本機", "sourceFallbackDesc": "使用本地價格表中的模型列表(上游獲取失敗或不支援)", "sourceUpstream": "上游來源", - "sourceUpstreamDesc": "模型列表來自上游服務商 API" + "sourceUpstreamDesc": "模型列表來自上游服務商 API", + "availableGroupLabel": "可選模型" } diff --git a/src/actions/providers.ts b/src/actions/providers.ts index d9dd1ccec..5b32f38b3 100644 --- a/src/actions/providers.ts +++ b/src/actions/providers.ts @@ -20,6 +20,8 @@ import { import { PROVIDER_GROUP, PROVIDER_TIMEOUT_DEFAULTS } from "@/lib/constants/provider.constants"; import { logger } from "@/lib/logger"; import { PROVIDER_BATCH_PATCH_ERROR_CODES } from "@/lib/provider-batch-patch-error-codes"; +import { PROVIDER_MODEL_REDIRECT_RULE_LIST_SCHEMA } from "@/lib/provider-model-redirect-schema"; +import { normalizeProviderModelRedirectRules } from "@/lib/provider-model-redirects"; import { buildProviderBatchApplyUpdates, hasProviderBatchPatchChanges, @@ -85,6 +87,7 @@ import type { ProviderBatchPatch, ProviderBatchPatchField, ProviderDisplay, + ProviderModelRedirectRule, ProviderPatchOperation, ProviderStatisticsMap, ProviderType, @@ -500,7 +503,7 @@ export async function addProvider(data: { provider_type?: ProviderType; preserve_client_ip?: boolean; disable_session_reuse?: boolean; - model_redirects?: Record | null; + model_redirects?: ProviderModelRedirectRule[] | null; active_time_start?: string | null; active_time_end?: string | null; allowed_models?: string[] | null; @@ -679,7 +682,7 @@ export async function editProvider( provider_type?: ProviderType; preserve_client_ip?: boolean; disable_session_reuse?: boolean; - model_redirects?: Record | null; + model_redirects?: ProviderModelRedirectRule[] | null; active_time_start?: string | null; active_time_end?: string | null; allowed_models?: string[] | null; @@ -2197,7 +2200,7 @@ export interface BatchUpdateProvidersParams { weight?: number; cost_multiplier?: number; group_tag?: string | null; - model_redirects?: Record | null; + model_redirects?: ProviderModelRedirectRule[] | null; allowed_models?: string[] | null; allowed_clients?: string[]; blocked_clients?: string[]; @@ -2244,7 +2247,19 @@ export async function batchUpdateProviders( repositoryUpdates.groupTag = normalizeProviderGroupTag(updates.group_tag); } if (updates.model_redirects !== undefined) { - repositoryUpdates.modelRedirects = updates.model_redirects; + if (updates.model_redirects === null) { + repositoryUpdates.modelRedirects = null; + } else { + const parsedRedirectRules = PROVIDER_MODEL_REDIRECT_RULE_LIST_SCHEMA.safeParse( + updates.model_redirects + ); + if (!parsedRedirectRules.success) { + return { ok: false, error: "模型重定向规则格式无效" }; + } + repositoryUpdates.modelRedirects = normalizeProviderModelRedirectRules( + parsedRedirectRules.data + ); + } } if (updates.allowed_models !== undefined) { repositoryUpdates.allowedModels = diff --git a/src/app/[locale]/settings/providers/_components/batch-edit/analyze-batch-settings.ts b/src/app/[locale]/settings/providers/_components/batch-edit/analyze-batch-settings.ts index 2fddf03ea..6f2c797a7 100644 --- a/src/app/[locale]/settings/providers/_components/batch-edit/analyze-batch-settings.ts +++ b/src/app/[locale]/settings/providers/_components/batch-edit/analyze-batch-settings.ts @@ -12,6 +12,7 @@ import type { GeminiGoogleSearchPreference, McpPassthroughType, ProviderDisplay, + ProviderModelRedirectRule, } from "@/types/provider"; import { deepEquals } from "./deep-equals"; @@ -30,7 +31,7 @@ export interface BatchSettingsAnalysis { groupTag: FieldAnalysisResult; preserveClientIp: FieldAnalysisResult; disableSessionReuse: FieldAnalysisResult; - modelRedirects: FieldAnalysisResult>; + modelRedirects: FieldAnalysisResult; allowedModels: FieldAnalysisResult; allowedClients: FieldAnalysisResult; blockedClients: FieldAnalysisResult; @@ -121,7 +122,7 @@ export function analyzeBatchProviderSettings(providers: ProviderDisplay[]): Batc groupTag: analyzeField(providers, (p) => parseProviderGroups(p.groupTag)), preserveClientIp: analyzeField(providers, (p) => p.preserveClientIp), disableSessionReuse: analyzeField(providers, (p) => p.disableSessionReuse), - modelRedirects: analyzeField(providers, (p) => p.modelRedirects ?? {}), + modelRedirects: analyzeField(providers, (p) => p.modelRedirects ?? []), allowedModels: analyzeField(providers, (p) => p.allowedModels ?? []), allowedClients: analyzeField(providers, (p) => p.allowedClients ?? []), blockedClients: analyzeField(providers, (p) => p.blockedClients ?? []), diff --git a/src/app/[locale]/settings/providers/_components/batch-edit/build-patch-draft.ts b/src/app/[locale]/settings/providers/_components/batch-edit/build-patch-draft.ts index b0ec4490a..51895e7b9 100644 --- a/src/app/[locale]/settings/providers/_components/batch-edit/build-patch-draft.ts +++ b/src/app/[locale]/settings/providers/_components/batch-edit/build-patch-draft.ts @@ -61,8 +61,7 @@ export function buildPatchDraftFromFormState( } } if (dirtyFields.has("routing.modelRedirects")) { - const entries = Object.keys(state.routing.modelRedirects); - if (entries.length === 0) { + if (state.routing.modelRedirects.length === 0) { draft.model_redirects = { clear: true }; } else { draft.model_redirects = { set: state.routing.modelRedirects }; diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form.legacy.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form.legacy.tsx index 916a23c9f..bb8b40371 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form.legacy.tsx +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form.legacy.tsx @@ -33,6 +33,7 @@ import { Switch } from "@/components/ui/switch"; import { TagInput } from "@/components/ui/tag-input"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { PROVIDER_DEFAULTS, PROVIDER_TIMEOUT_DEFAULTS } from "@/lib/constants/provider.constants"; +import { normalizeProviderModelRedirectRules } from "@/lib/provider-model-redirects"; import { getProviderTypeConfig } from "@/lib/provider-type-utils"; import { parseProviderGroups } from "@/lib/utils/provider-group"; import { @@ -48,6 +49,7 @@ import type { CodexTextVerbosityPreference, McpPassthroughType, ProviderDisplay, + ProviderModelRedirectRule, ProviderType, } from "@/types/provider"; import { ModelMultiSelect } from "../model-multi-select"; @@ -168,8 +170,8 @@ export function ProviderForm({ const [preserveClientIp, setPreserveClientIp] = useState( sourceProvider?.preserveClientIp ?? false ); - const [modelRedirects, setModelRedirects] = useState>( - sourceProvider?.modelRedirects ?? {} + const [modelRedirects, setModelRedirects] = useState( + normalizeProviderModelRedirectRules(sourceProvider?.modelRedirects) ?? [] ); const [priority, setPriority] = useState(sourceProvider?.priority ?? 0); const [weight, setWeight] = useState(sourceProvider?.weight ?? 1); @@ -454,8 +456,8 @@ export function ProviderForm({ // 实际提交逻辑 const performSubmit = () => { - // 处理模型重定向(空对象转为 null) - const parsedModelRedirects = Object.keys(modelRedirects).length > 0 ? modelRedirects : null; + // 处理模型重定向(空数组转为 null) + const parsedModelRedirects = modelRedirects.length > 0 ? modelRedirects : null; startTransition(async () => { try { @@ -465,7 +467,7 @@ export function ProviderForm({ url?: string; key?: string; provider_type?: ProviderType; - model_redirects?: Record | null; + model_redirects?: ProviderModelRedirectRule[] | null; allowed_models?: string[] | null; priority?: number; weight?: number; @@ -629,7 +631,7 @@ export function ProviderForm({ setKey(""); setProviderType("claude"); setPreserveClientIp(false); - setModelRedirects({}); + setModelRedirects([]); setAllowedModels([]); setPriority(0); setWeight(1); @@ -805,10 +807,10 @@ export function ProviderForm({ count: allowedModels.length, }) ); - if (Object.keys(modelRedirects).length > 0) + if (modelRedirects.length > 0) parts.push( t("sections.routing.summary.redirects", { - count: Object.keys(modelRedirects).length, + count: modelRedirects.length, }) ); return parts.length > 0 ? parts.join(", ") : t("sections.routing.summary.none"); diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/index.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form/index.tsx index 8ed6d99b5..4d06b6cd7 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form/index.tsx +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/index.tsx @@ -299,7 +299,8 @@ function ProviderFormContent({ provider_type: state.routing.providerType, preserve_client_ip: state.routing.preserveClientIp, disable_session_reuse: state.routing.disableSessionReuse, - model_redirects: state.routing.modelRedirects, + model_redirects: + state.routing.modelRedirects.length > 0 ? state.routing.modelRedirects : null, allowed_models: state.routing.allowedModels.length > 0 ? state.routing.allowedModels : null, allowed_clients: state.routing.allowedClients, @@ -488,10 +489,7 @@ function ProviderFormContent({ } // Routing - configured if models/redirects set - if ( - state.routing.allowedModels.length > 0 || - Object.keys(state.routing.modelRedirects).length > 0 - ) { + if (state.routing.allowedModels.length > 0 || state.routing.modelRedirects.length > 0) { status.routing = "configured"; } diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context.tsx index bc9f61580..4c3f396a5 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context.tsx +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-context.tsx @@ -10,6 +10,7 @@ import { useReducer, useRef, } from "react"; +import { normalizeProviderModelRedirectRules } from "@/lib/provider-model-redirects"; import { parseProviderGroups } from "@/lib/utils/provider-group"; import type { ProviderDisplay, ProviderType } from "@/types/provider"; import { analyzeBatchProviderSettings } from "../../batch-edit/analyze-batch-settings"; @@ -110,7 +111,7 @@ export function createInitialState( modelRedirects: analysis.routing.modelRedirects.status === "uniform" ? analysis.routing.modelRedirects.value - : {}, + : [], allowedModels: analysis.routing.allowedModels.status === "uniform" ? analysis.routing.allowedModels.value @@ -288,7 +289,7 @@ export function createInitialState( groupTag: [], preserveClientIp: false, disableSessionReuse: false, - modelRedirects: {}, + modelRedirects: [], allowedModels: [], allowedClients: [], blockedClients: [], @@ -363,7 +364,7 @@ export function createInitialState( groupTag: parseProviderGroups(sourceProvider?.groupTag), preserveClientIp: sourceProvider?.preserveClientIp ?? false, disableSessionReuse: sourceProvider?.disableSessionReuse ?? false, - modelRedirects: sourceProvider?.modelRedirects ?? {}, + modelRedirects: normalizeProviderModelRedirectRules(sourceProvider?.modelRedirects) ?? [], allowedModels: sourceProvider?.allowedModels ?? [], allowedClients: sourceProvider?.allowedClients ?? [], blockedClients: sourceProvider?.blockedClients ?? [], diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-types.ts b/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-types.ts index 243f89ee3..f6467b0a1 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-types.ts +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/provider-form-types.ts @@ -13,6 +13,7 @@ import type { GeminiGoogleSearchPreference, McpPassthroughType, ProviderDisplay, + ProviderModelRedirectRule, ProviderType, } from "@/types/provider"; import type { BatchSettingsAnalysis } from "../../batch-edit/analyze-batch-settings"; @@ -49,7 +50,7 @@ export interface RoutingState { groupTag: string[]; preserveClientIp: boolean; disableSessionReuse: boolean; - modelRedirects: Record; + modelRedirects: ProviderModelRedirectRule[]; allowedModels: string[]; allowedClients: string[]; blockedClients: string[]; @@ -142,7 +143,7 @@ export type ProviderFormAction = | { type: "SET_GROUP_TAG"; payload: string[] } | { type: "SET_PRESERVE_CLIENT_IP"; payload: boolean } | { type: "SET_DISABLE_SESSION_REUSE"; payload: boolean } - | { type: "SET_MODEL_REDIRECTS"; payload: Record } + | { type: "SET_MODEL_REDIRECTS"; payload: ProviderModelRedirectRule[] } | { type: "SET_ALLOWED_MODELS"; payload: string[] } | { type: "SET_ALLOWED_CLIENTS"; payload: string[] } | { type: "SET_BLOCKED_CLIENTS"; payload: string[] } diff --git a/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section.tsx b/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section.tsx index c781c4e74..e03e01676 100644 --- a/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section.tsx +++ b/src/app/[locale]/settings/providers/_components/forms/provider-form/sections/routing-section.tsx @@ -189,9 +189,7 @@ export function RoutingSection({ subSectionRefs }: RoutingSectionProps) {
) => - dispatch({ type: "SET_MODEL_REDIRECTS", payload: value }) - } + onChange={(value) => dispatch({ type: "SET_MODEL_REDIRECTS", payload: value })} disabled={state.ui.isPending} /> {isBatch && batchAnalysis?.routing.modelRedirects.status === "mixed" && ( diff --git a/src/app/[locale]/settings/providers/_components/model-multi-select.tsx b/src/app/[locale]/settings/providers/_components/model-multi-select.tsx index 41f3e9976..fea24ad79 100644 --- a/src/app/[locale]/settings/providers/_components/model-multi-select.tsx +++ b/src/app/[locale]/settings/providers/_components/model-multi-select.tsx @@ -1,5 +1,15 @@ "use client"; -import { Check, ChevronsUpDown, Cloud, Database, Loader2, Plus, RefreshCw } from "lucide-react"; +import { + Check, + ChevronsUpDown, + Cloud, + Database, + Loader2, + Pencil, + Plus, + RefreshCw, + X, +} from "lucide-react"; import { useTranslations } from "next-intl"; import { useCallback, useEffect, useMemo, useState } from "react"; import { getAvailableModelsByProviderType } from "@/actions/model-prices"; @@ -89,6 +99,9 @@ export function ModelMultiSelect({ const [loading, setLoading] = useState(true); const [modelSource, setModelSource] = useState("loading"); const [customModel, setCustomModel] = useState(""); + const [editingModel, setEditingModel] = useState(null); + const [editValue, setEditValue] = useState(""); + const [managementError, setManagementError] = useState(null); const displayedModels = useMemo(() => { const seen = new Set(); @@ -110,6 +123,16 @@ export function ModelMultiSelect({ return merged; }, [availableModels, selectedModels]); + const selectedDisplayedModels = useMemo( + () => displayedModels.filter((model) => selectedModels.includes(model)), + [displayedModels, selectedModels] + ); + + const availableDisplayedModels = useMemo( + () => displayedModels.filter((model) => !selectedModels.includes(model)), + [displayedModels, selectedModels] + ); + // 供应商类型到显示名称的映射 const getProviderTypeLabel = (type: string): string => { const typeMap: Record = { @@ -171,6 +194,7 @@ export function ModelMultiSelect({ }, [loadModels]); const toggleModel = (model: string) => { + setManagementError(null); if (selectedModels.includes(model)) { onChange(selectedModels.filter((m) => m !== model)); } else { @@ -179,11 +203,17 @@ export function ModelMultiSelect({ }; const selectAll = () => onChange(availableModels); - const clearAll = () => onChange([]); + const clearAll = () => { + setManagementError(null); + setEditingModel(null); + setEditValue(""); + onChange([]); + }; const handleAddCustomModel = () => { const trimmed = customModel.trim(); if (!trimmed) return; + setManagementError(null); if (selectedModels.includes(trimmed)) { setCustomModel(""); @@ -198,161 +228,319 @@ export function ModelMultiSelect({ const sourceLabel = isUpstream ? t("sourceUpstream") : t("sourceFallback"); const sourceDescription = isUpstream ? t("sourceUpstreamDesc") : t("sourceFallbackDesc"); + const handleRemoveSelectedModel = (model: string) => { + setManagementError(null); + if (editingModel === model) { + setEditingModel(null); + setEditValue(""); + } + onChange(selectedModels.filter((item) => item !== model)); + }; + + const handleStartEditSelectedModel = (model: string) => { + setEditingModel(model); + setEditValue(model); + setManagementError(null); + }; + + const handleCancelEditSelectedModel = () => { + setEditingModel(null); + setEditValue(""); + setManagementError(null); + }; + + const handleSaveEditSelectedModel = (originalModel: string) => { + const trimmed = editValue.trim(); + if (!trimmed) { + setManagementError(t("selectedEditEmpty")); + return; + } + + if (trimmed !== originalModel && selectedModels.includes(trimmed)) { + setManagementError(t("selectedEditExists", { model: trimmed })); + return; + } + + setManagementError(null); + onChange(selectedModels.map((model) => (model === originalModel ? trimmed : model))); + setEditingModel(null); + setEditValue(""); + }; + return ( - - - + + e.stopPropagation()} + onTouchMove={(e) => e.stopPropagation()} + > + + + + {loading ? t("loading") : t("notFound")} + + {!loading && ( + <> + +
+
+ + + + + + + +

{t("refresh")}

+
+
+
+
+
+ + +
+
+
+ + {selectedDisplayedModels.length > 0 && ( +
+ + {selectedDisplayedModels.map((model) => ( + toggleModel(model)} + className="cursor-pointer" + > + toggleModel(model)} + /> + {model} + + + ))} + +
+ )} + +
+ + {availableDisplayedModels.map((model) => ( + toggleModel(model)} + className="cursor-pointer" + > + toggleModel(model)} + /> + {model} + + ))} + +
+ + )} +
+
+ +
+ +
+ setCustomModel(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleAddCustomModel(); + } + }} + disabled={disabled} + className="font-mono text-sm flex-1" + /> +
- )} - {loading ? ( - - ) : ( - - )} - - - e.stopPropagation()} - onTouchMove={(e) => e.stopPropagation()} - > - - - - {loading ? t("loading") : t("notFound")} - - {!loading && ( - <> - {/* 数据来源指示 + 快捷操作 */} - -
-
- {t("manualDesc")}

+
+ + + + {selectedModels.length > 0 && ( +
+
+ + {t("selectedListHint")} +
+ +
+ {selectedModels.map((model) => { + const isEditing = editingModel === model; + + return ( +
+ {isEditing ? ( + <> + setEditValue(e.target.value)} + onInput={(e) => setEditValue((e.target as HTMLInputElement).value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleSaveEditSelectedModel(model); + } else if (e.key === "Escape") { + e.preventDefault(); + handleCancelEditSelectedModel(); + } + }} + className="font-mono text-sm h-8 flex-1" + autoFocus /> - - - - - - -

{t("refresh")}

-
-
-
-
-
+ + + ) : ( + <> + {model} + -
-
- - - {/* 模型列表 */} - - {displayedModels.map((model) => ( - toggleModel(model)} - className="cursor-pointer" - > - toggleModel(model)} - /> - {model} - {selectedModels.includes(model) && } - - ))} - - - )} - - - - {/* 手动输入区域 */} -
- -
- setCustomModel(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - handleAddCustomModel(); - } - }} - disabled={disabled} - className="font-mono text-sm flex-1" - /> - + + )} +
+ ); + })}
-

{t("manualDesc")}

+ + {managementError &&
{managementError}
}
- - + )} +
); } diff --git a/src/app/[locale]/settings/providers/_components/model-redirect-editor.tsx b/src/app/[locale]/settings/providers/_components/model-redirect-editor.tsx index 382a22df5..b17e7d9f7 100644 --- a/src/app/[locale]/settings/providers/_components/model-redirect-editor.tsx +++ b/src/app/[locale]/settings/providers/_components/model-redirect-editor.tsx @@ -1,108 +1,205 @@ "use client"; -import { AlertCircle, ArrowRight, Check, Pencil, Plus, X } from "lucide-react"; +import { + AlertCircle, + ArrowRight, + Check, + ChevronDown, + ChevronUp, + Pencil, + Plus, + X, +} from "lucide-react"; import { useTranslations } from "next-intl"; import { useState } from "react"; +import safeRegex from "safe-regex"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type { ProviderModelRedirectMatchType, ProviderModelRedirectRule } from "@/types/provider"; interface ModelRedirectEditorProps { - value: Record; - onChange: (value: Record) => void; + value: ProviderModelRedirectRule[]; + onChange: (value: ProviderModelRedirectRule[]) => void; disabled?: boolean; } +const DEFAULT_RULE: ProviderModelRedirectRule = { + matchType: "exact", + source: "", + target: "", +}; + +function normalizeRule(rule: ProviderModelRedirectRule): ProviderModelRedirectRule { + return { + matchType: rule.matchType, + source: rule.source.trim(), + target: rule.target.trim(), + }; +} + +function getRuleIdentity(rule: Pick): string { + return `${rule.matchType}:${rule.source.trim()}`; +} + export function ModelRedirectEditor({ value, onChange, disabled = false, }: ModelRedirectEditorProps) { const t = useTranslations("settings.providers.form.modelRedirect"); - const [newSource, setNewSource] = useState(""); - const [newTarget, setNewTarget] = useState(""); + const [newRule, setNewRule] = useState(DEFAULT_RULE); const [error, setError] = useState(null); - const [editingSource, setEditingSource] = useState(null); - const [editTarget, setEditTarget] = useState(""); + const [editingRuleKey, setEditingRuleKey] = useState(null); + const [editRule, setEditRule] = useState(DEFAULT_RULE); - // 将 Record 转换为数组用于渲染 - const redirects = Object.entries(value); + const redirects = value; - const handleAdd = () => { - setError(null); + const matchTypeOptions: Array<{ + value: ProviderModelRedirectMatchType; + label: string; + }> = [ + { value: "exact", label: t("matchTypeExact") }, + { value: "prefix", label: t("matchTypePrefix") }, + { value: "suffix", label: t("matchTypeSuffix") }, + { value: "contains", label: t("matchTypeContains") }, + { value: "regex", label: t("matchTypeRegex") }, + ]; - // 验证输入 - if (!newSource.trim()) { - setError(t("sourceEmpty")); - return; + const hasDuplicateRule = (rule: ProviderModelRedirectRule, ignoreRuleKey?: string): boolean => { + const normalized = normalizeRule(rule); + const nextRuleKey = getRuleIdentity(normalized); + + return redirects.some((item) => { + const currentKey = getRuleIdentity(item); + if (ignoreRuleKey && currentKey === ignoreRuleKey) { + return false; + } + return currentKey === nextRuleKey; + }); + }; + + const validateRule = (rule: ProviderModelRedirectRule, ignoreRuleKey?: string): string | null => { + const normalized = normalizeRule(rule); + + if (!normalized.source) { + return t("sourceEmpty"); } - if (!newTarget.trim()) { - setError(t("targetEmpty")); - return; + if (!normalized.target) { + return t("targetEmpty"); + } + if (normalized.source.length > 255) { + return t("sourceTooLong"); + } + if (normalized.target.length > 255) { + return t("targetTooLong"); + } + if (normalized.matchType === "regex") { + if (!safeRegex(normalized.source)) { + return t("regexUnsafe"); + } + + try { + new RegExp(normalized.source); + } catch { + return t("regexInvalid"); + } } + if (hasDuplicateRule(normalized, ignoreRuleKey)) { + return t("alreadyExists", { + model: `${normalized.matchType}:${normalized.source}`, + }); + } + return null; + }; - // 检查是否已存在 - if (value[newSource.trim()]) { - setError(t("alreadyExists", { model: newSource.trim() })); + const handleAdd = () => { + const nextRule = normalizeRule(newRule); + const validationError = validateRule(nextRule); + if (validationError) { + setError(validationError); return; } - // 添加新的映射 - onChange({ - ...value, - [newSource.trim()]: newTarget.trim(), - }); + setError(null); + onChange([...redirects, nextRule]); + setNewRule(DEFAULT_RULE); + }; - // 清空输入 - setNewSource(""); - setNewTarget(""); + const handleRemove = (ruleKey: string) => { + onChange(redirects.filter((rule) => getRuleIdentity(rule) !== ruleKey)); + if (editingRuleKey === ruleKey) { + setEditingRuleKey(null); + setEditRule(DEFAULT_RULE); + setError(null); + } }; - const handleRemove = (sourceModel: string) => { - const newValue = { ...value }; - delete newValue[sourceModel]; - onChange(newValue); + const handleMove = (ruleKey: string, direction: -1 | 1) => { + const index = redirects.findIndex((rule) => getRuleIdentity(rule) === ruleKey); + if (index < 0) return; + + const nextIndex = index + direction; + if (nextIndex < 0 || nextIndex >= redirects.length) { + return; + } + + const nextRules = [...redirects]; + const [item] = nextRules.splice(index, 1); + nextRules.splice(nextIndex, 0, item); + onChange(nextRules); }; - const handleStartEdit = (source: string, target: string) => { - setEditingSource(source); - setEditTarget(target); + const handleStartEdit = (rule: ProviderModelRedirectRule) => { + setEditingRuleKey(getRuleIdentity(rule)); + setEditRule(normalizeRule(rule)); setError(null); }; const handleCancelEdit = () => { - setEditingSource(null); - setEditTarget(""); + setEditingRuleKey(null); + setEditRule(DEFAULT_RULE); setError(null); }; - const handleSaveEdit = (originalSource: string) => { - setError(null); - - if (!editTarget.trim()) { - setError(t("targetEmpty")); + const handleSaveEdit = (originalRuleKey: string) => { + const nextRule = normalizeRule(editRule); + const validationError = validateRule(nextRule, originalRuleKey); + if (validationError) { + setError(validationError); return; } - const newValue = { ...value }; - newValue[originalSource] = editTarget.trim(); - onChange(newValue); + const currentIndex = redirects.findIndex((rule) => getRuleIdentity(rule) === originalRuleKey); + if (currentIndex < 0) { + setError(t("ruleMoved")); + return; + } - setEditingSource(null); - setEditTarget(""); + setError(null); + onChange(redirects.map((rule, index) => (index === currentIndex ? nextRule : rule))); + setEditingRuleKey(null); + setEditRule(DEFAULT_RULE); }; - const handleKeyDown = (e: React.KeyboardEvent) => { + const handleCreateKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter") { e.preventDefault(); handleAdd(); } }; - const handleEditKeyDown = (e: React.KeyboardEvent, originalSource: string) => { + const handleEditKeyDown = (e: React.KeyboardEvent, originalRuleKey: string) => { if (e.key === "Enter") { e.preventDefault(); - handleSaveEdit(originalSource); + handleSaveEdit(originalRuleKey); } else if (e.key === "Escape") { e.preventDefault(); handleCancelEdit(); @@ -111,88 +208,177 @@ export function ModelRedirectEditor({ return (
- {/* 现有的重定向规则列表 */} {redirects.length > 0 && (
-
- {t("currentRules", { count: redirects.length })} +
+
+ {t("currentRules", { count: redirects.length })} +
+
{t("orderHint")}
+
- {redirects.map(([source, target]) => { - const isEditing = editingSource === source; + {redirects.map((rule, index) => { + const ruleKey = getRuleIdentity(rule); + const isEditing = editingRuleKey === ruleKey; return ( -
- - {source} - - - +
{isEditing ? ( - setEditTarget(e.target.value)} - onKeyDown={(e) => handleEditKeyDown(e, source)} - disabled={disabled} - className="font-mono text-sm h-7 flex-1" - autoFocus - /> - ) : ( - - {target} - - )} +
+
+ + +
-
+
+ + + setEditRule((current) => ({ ...current, source: e.target.value })) + } + onInput={(e) => + setEditRule((current) => ({ + ...current, + source: (e.target as HTMLInputElement).value, + })) + } + onKeyDown={(e) => handleEditKeyDown(e, ruleKey)} + disabled={disabled} + className="font-mono text-sm h-8 flex-1" + autoFocus + /> +
- {isEditing ? ( -
- - +
+ +
+ +
+ + + setEditRule((current) => ({ ...current, target: e.target.value })) + } + onInput={(e) => + setEditRule((current) => ({ + ...current, + target: (e.target as HTMLInputElement).value, + })) + } + onKeyDown={(e) => handleEditKeyDown(e, ruleKey)} + disabled={disabled} + className="font-mono text-sm h-8 flex-1" + /> +
+ +
+ + +
) : ( -
- - +
+ + {matchTypeOptions.find((option) => option.value === rule.matchType)?.label} + + + {rule.source} + + + + {rule.target} + + +
+ + + + +
)}
@@ -202,26 +388,61 @@ export function ModelRedirectEditor({
)} - {/* 添加新规则表单 */}
{t("addNewRule")}
-
+ +
+
+ + +
+
setNewSource(e.target.value)} - onKeyDown={handleKeyDown} + value={newRule.source} + onChange={(e) => setNewRule((current) => ({ ...current, source: e.target.value }))} + onInput={(e) => + setNewRule((current) => ({ + ...current, + source: (e.target as HTMLInputElement).value, + })) + } + onKeyDown={handleCreateKeyDown} placeholder={t("sourcePlaceholder")} disabled={disabled} className="font-mono text-sm" />
-
+
+ +
setNewTarget(e.target.value)} - onKeyDown={handleKeyDown} + value={newRule.target} + onChange={(e) => setNewRule((current) => ({ ...current, target: e.target.value }))} + onInput={(e) => + setNewRule((current) => ({ + ...current, + target: (e.target as HTMLInputElement).value, + })) + } + onKeyDown={handleCreateKeyDown} placeholder={t("targetPlaceholder")} disabled={disabled} className="font-mono text-sm" @@ -241,7 +468,7 @@ export function ModelRedirectEditor({
- {/* 错误提示 */} {error && (
@@ -258,11 +484,9 @@ export function ModelRedirectEditor({
)} - {/* 帮助文本 */}

{t("description")}

- {/* 空状态提示 */} {redirects.length === 0 && (
{t("emptyState")} diff --git a/src/app/api/actions/[...route]/route.ts b/src/app/api/actions/[...route]/route.ts index ac20853fd..6d53e2368 100644 --- a/src/app/api/actions/[...route]/route.ts +++ b/src/app/api/actions/[...route]/route.ts @@ -35,6 +35,7 @@ import * as userActions from "@/actions/users"; import * as webhookTargetActions from "@/actions/webhook-targets"; import { createActionRoute } from "@/lib/api/action-adapter-openapi"; import { NOTIFICATION_JOB_TYPES } from "@/lib/constants/notification.constants"; +import { PROVIDER_MODEL_REDIRECT_RULE_SCHEMA } from "@/lib/provider-model-redirect-schema"; // 导入 validation schemas import { CreateProviderSchema, @@ -538,7 +539,10 @@ const { route: getProvidersRoute, handler: getProvidersHandler } = createActionR weight: z.number().describe("权重"), priority: z.number().describe("优先级"), costMultiplier: z.number().describe("成本系数"), - modelRedirects: z.record(z.string(), z.string()).nullable().describe("模型重定向映射"), + modelRedirects: z + .array(PROVIDER_MODEL_REDIRECT_RULE_SCHEMA) + .nullable() + .describe("模型重定向规则列表"), proxyUrl: z.string().nullable().describe("代理地址"), maxConcurrency: z.number().nullable().describe("最大并发数"), rpmLimit: z.number().nullable().describe("RPM 限制"), diff --git a/src/app/v1/_lib/proxy/model-redirector.ts b/src/app/v1/_lib/proxy/model-redirector.ts index da6236be0..710a1fa1a 100644 --- a/src/app/v1/_lib/proxy/model-redirector.ts +++ b/src/app/v1/_lib/proxy/model-redirector.ts @@ -1,4 +1,9 @@ import { logger } from "@/lib/logger"; +import { + findMatchingProviderModelRedirectRule, + getProviderModelRedirectTarget, + hasProviderModelRedirectRules, +} from "@/lib/provider-model-redirects"; import type { Provider } from "@/types/provider"; import type { ProxySession } from "./session"; @@ -22,7 +27,7 @@ export class ModelRedirector { const trueOriginalModel = session.getOriginalModel() || session.request.model; // 检查是否配置了模型重定向 - if (!provider.modelRedirects || Object.keys(provider.modelRedirects).length === 0) { + if (!hasProviderModelRedirectRules(provider.modelRedirects)) { // 如果新供应商没有重定向配置,且之前发生过重定向,需要重置 if (session.isModelRedirected() && trueOriginalModel) { ModelRedirector.resetToOriginal(session, trueOriginalModel, provider); @@ -41,8 +46,11 @@ export class ModelRedirector { } // 检查是否有该模型的重定向配置 - const redirectedModel = provider.modelRedirects[originalModel]; - if (!redirectedModel) { + const matchedRule = findMatchingProviderModelRedirectRule( + originalModel, + provider.modelRedirects + ); + if (!matchedRule) { // 如果新供应商对此模型没有重定向规则,且之前发生过重定向,需要重置 if (session.isModelRedirected()) { ModelRedirector.resetToOriginal(session, originalModel, provider); @@ -56,10 +64,14 @@ export class ModelRedirector { return false; } + const redirectedModel = matchedRule.target; + // 执行重定向 logger.info("[ModelRedirector] Model redirected", { originalModel, redirectedModel, + matchType: matchedRule.matchType, + matchedSource: matchedRule.source, providerId: provider.id, providerName: provider.name, providerType: provider.providerType, @@ -117,6 +129,11 @@ export class ModelRedirector { originalModel: originalModel, redirectedModel: redirectedModel, billingModel: originalModel, // 始终使用原始模型计费 + matchedRule: { + matchType: matchedRule.matchType, + source: matchedRule.source, + target: matchedRule.target, + }, }; logger.debug("[ModelRedirector] Added modelRedirect to provider chain", { providerId: provider.id, @@ -140,7 +157,7 @@ export class ModelRedirector { return originalModel; } - return provider.modelRedirects[originalModel] || originalModel; + return getProviderModelRedirectTarget(originalModel, provider.modelRedirects); } /** @@ -151,7 +168,7 @@ export class ModelRedirector { * @returns 是否配置了重定向 */ static hasRedirect(model: string, provider: Provider): boolean { - return !!(provider.modelRedirects && model && provider.modelRedirects[model]); + return !!findMatchingProviderModelRedirectRule(model, provider.modelRedirects); } /** diff --git a/src/drizzle/schema.ts b/src/drizzle/schema.ts index a0eecb8cb..91d17caba 100644 --- a/src/drizzle/schema.ts +++ b/src/drizzle/schema.ts @@ -16,7 +16,7 @@ import { import { relations, sql } from 'drizzle-orm'; import type { SpecialSetting } from '@/types/special-settings'; import type { ResponseFixerConfig } from '@/types/system-config'; -import type { ProviderType } from "@/types/provider"; +import type { ProviderModelRedirectRule, ProviderType } from "@/types/provider"; import type { FilterOperation } from "@/lib/request-filter-types"; // Enums @@ -195,7 +195,9 @@ export const providers = pgTable('providers', { disableSessionReuse: boolean('disable_session_reuse').notNull().default(false), // 模型重定向:将请求的模型名称重定向到另一个模型 - modelRedirects: jsonb('model_redirects').$type>(), + modelRedirects: jsonb('model_redirects').$type< + ProviderModelRedirectRule[] | Record | null + >(), // 模型列表:双重语义 // - Anthropic 提供商:白名单(管理员限制可调度的模型,可选) diff --git a/src/lib/provider-model-redirect-schema.ts b/src/lib/provider-model-redirect-schema.ts new file mode 100644 index 000000000..fcde89328 --- /dev/null +++ b/src/lib/provider-model-redirect-schema.ts @@ -0,0 +1,56 @@ +import safeRegex from "safe-regex"; +import { z } from "zod"; + +export const PROVIDER_MODEL_REDIRECT_MATCH_TYPE_SCHEMA = z.enum([ + "exact", + "prefix", + "suffix", + "contains", + "regex", +]); + +export const PROVIDER_MODEL_REDIRECT_RULE_SCHEMA = z + .object({ + matchType: PROVIDER_MODEL_REDIRECT_MATCH_TYPE_SCHEMA, + source: z + .string() + .trim() + .min(1, "Redirect source cannot be empty") + .max(255, "Redirect source is too long"), + target: z + .string() + .trim() + .min(1, "Redirect target cannot be empty") + .max(255, "Redirect target is too long"), + }) + .superRefine((rule, ctx) => { + if (rule.matchType !== "regex") { + return; + } + + if (!safeRegex(rule.source)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Redirect regex has potential ReDoS risk", + path: ["source"], + }); + return; + } + + try { + new RegExp(rule.source); + } catch { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Redirect regex is invalid", + path: ["source"], + }); + } + }); + +export const PROVIDER_MODEL_REDIRECT_RULE_LIST_SCHEMA = z + .array(PROVIDER_MODEL_REDIRECT_RULE_SCHEMA) + .max(100, "Redirect rules cannot exceed 100 entries"); + +export const PROVIDER_MODEL_REDIRECT_RULES_SCHEMA = + PROVIDER_MODEL_REDIRECT_RULE_LIST_SCHEMA.nullable().optional(); diff --git a/src/lib/provider-model-redirects.ts b/src/lib/provider-model-redirects.ts new file mode 100644 index 000000000..cb7c1bbd3 --- /dev/null +++ b/src/lib/provider-model-redirects.ts @@ -0,0 +1,138 @@ +import type { ProviderModelRedirectMatchType, ProviderModelRedirectRule } from "@/types/provider"; + +const PROVIDER_MODEL_REDIRECT_MATCH_TYPES = new Set([ + "exact", + "prefix", + "suffix", + "contains", + "regex", +]); + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function trimString(value: unknown): string | null { + return typeof value === "string" ? value.trim() : null; +} + +export function isProviderModelRedirectRule(value: unknown): value is ProviderModelRedirectRule { + if (!isRecord(value)) { + return false; + } + + const matchType = value.matchType; + const source = trimString(value.source); + const target = trimString(value.target); + + return ( + typeof matchType === "string" && + PROVIDER_MODEL_REDIRECT_MATCH_TYPES.has(matchType as ProviderModelRedirectMatchType) && + !!source && + !!target + ); +} + +export function isProviderModelRedirectRuleList( + value: unknown +): value is ProviderModelRedirectRule[] { + return Array.isArray(value) && value.every((rule) => isProviderModelRedirectRule(rule)); +} + +export function normalizeProviderModelRedirectRule( + value: ProviderModelRedirectRule +): ProviderModelRedirectRule { + return { + matchType: value.matchType, + source: value.source.trim(), + target: value.target.trim(), + }; +} + +export function normalizeProviderModelRedirectRules( + value: unknown +): ProviderModelRedirectRule[] | null { + if (value == null) { + return null; + } + + if (isProviderModelRedirectRuleList(value)) { + return value.map((rule) => normalizeProviderModelRedirectRule(rule)); + } + + if (!isRecord(value)) { + return null; + } + + const normalized = Object.entries(value) + .map(([source, target]): ProviderModelRedirectRule | null => { + const normalizedSource = source.trim(); + const normalizedTarget = trimString(target); + if (!normalizedSource || !normalizedTarget) { + return null; + } + + return { + matchType: "exact" as const, + source: normalizedSource, + target: normalizedTarget, + }; + }) + .filter((rule): rule is ProviderModelRedirectRule => rule !== null); + + return normalized; +} + +export function hasProviderModelRedirectRules( + rules: ProviderModelRedirectRule[] | null | undefined +): boolean { + return Array.isArray(rules) && rules.length > 0; +} + +export function matchesProviderModelRedirectRule( + model: string, + rule: ProviderModelRedirectRule +): boolean { + switch (rule.matchType) { + case "exact": + return model === rule.source; + case "prefix": + return model.startsWith(rule.source); + case "suffix": + return model.endsWith(rule.source); + case "contains": + return model.includes(rule.source); + case "regex": + try { + return new RegExp(rule.source).test(model); + } catch { + return false; + } + default: + return false; + } +} + +export function findMatchingProviderModelRedirectRule( + model: string, + rules: ProviderModelRedirectRule[] | null | undefined +): ProviderModelRedirectRule | null { + if (!model || !hasProviderModelRedirectRules(rules)) { + return null; + } + + for (const rule of rules ?? []) { + if (matchesProviderModelRedirectRule(model, rule)) { + return rule; + } + } + + return null; +} + +export function getProviderModelRedirectTarget( + model: string, + rules: ProviderModelRedirectRule[] | null | undefined +): string { + return findMatchingProviderModelRedirectRule(model, rules)?.target ?? model; +} diff --git a/src/lib/provider-patch-contract.ts b/src/lib/provider-patch-contract.ts index 0bf04181b..d244a2ebe 100644 --- a/src/lib/provider-patch-contract.ts +++ b/src/lib/provider-patch-contract.ts @@ -7,6 +7,7 @@ import type { ProviderPatchDraftInput, ProviderPatchOperation, } from "@/types/provider"; +import { PROVIDER_MODEL_REDIRECT_RULE_LIST_SCHEMA } from "./provider-model-redirect-schema"; export const PROVIDER_PATCH_ERROR_CODES = { INVALID_PATCH_SHAPE: "INVALID_PATCH_SHAPE", @@ -131,16 +132,6 @@ const CLEARABLE_FIELDS: Record = { mcp_passthrough_url: true, }; -function isStringRecord(value: unknown): value is Record { - if (!isRecord(value) || Array.isArray(value)) { - return false; - } - - return Object.entries(value).every( - ([key, entry]) => typeof key === "string" && typeof entry === "string" - ); -} - function isNumberRecord(value: unknown): value is Record { if (!isRecord(value) || Array.isArray(value)) { return false; @@ -287,7 +278,7 @@ function isValidSetValue(field: ProviderBatchPatchField, value: unknown): boolea case "mcp_passthrough_type": return value === "none" || value === "minimax" || value === "glm" || value === "custom"; case "model_redirects": - return isStringRecord(value); + return PROVIDER_MODEL_REDIRECT_RULE_LIST_SCHEMA.safeParse(value).success; case "allowed_models": return Array.isArray(value) && value.every((model) => typeof model === "string"); case "allowed_clients": diff --git a/src/lib/validation/schemas.ts b/src/lib/validation/schemas.ts index 5a256dd8f..bbe608791 100644 --- a/src/lib/validation/schemas.ts +++ b/src/lib/validation/schemas.ts @@ -5,6 +5,7 @@ import { PROVIDER_TIMEOUT_LIMITS, } from "@/lib/constants/provider.constants"; import { USER_LIMITS } from "@/lib/constants/user.constants"; +import { PROVIDER_MODEL_REDIRECT_RULES_SCHEMA } from "@/lib/provider-model-redirect-schema"; import { CURRENCY_CONFIG } from "@/lib/utils/currency"; import { isValidIANATimezone } from "@/lib/utils/timezone"; @@ -449,7 +450,7 @@ export const CreateProviderSchema = z .default("claude"), preserve_client_ip: z.boolean().optional().default(false), disable_session_reuse: z.boolean().optional().default(false), - model_redirects: z.record(z.string(), z.string()).nullable().optional(), + model_redirects: PROVIDER_MODEL_REDIRECT_RULES_SCHEMA, // Scheduled active time window (HH:mm format) active_time_start: z .string() @@ -687,7 +688,7 @@ export const UpdateProviderSchema = z .optional(), preserve_client_ip: z.boolean().optional(), disable_session_reuse: z.boolean().optional(), - model_redirects: z.record(z.string(), z.string()).nullable().optional(), + model_redirects: PROVIDER_MODEL_REDIRECT_RULES_SCHEMA, active_time_start: z .string() .regex(/^([01][0-9]|2[0-3]):[0-5][0-9]$/, "active_time_start must be HH:mm format") diff --git a/src/repository/_shared/transformers.ts b/src/repository/_shared/transformers.ts index edca6fa78..95749629d 100644 --- a/src/repository/_shared/transformers.ts +++ b/src/repository/_shared/transformers.ts @@ -1,4 +1,5 @@ import { PROVIDER_TIMEOUT_DEFAULTS } from "@/lib/constants/provider.constants"; +import { normalizeProviderModelRedirectRules } from "@/lib/provider-model-redirects"; import { formatCostForStorage } from "@/lib/utils/currency"; import type { Key } from "@/types/key"; import type { MessageRequest } from "@/types/message"; @@ -95,7 +96,7 @@ export function toProvider(dbProvider: any): Provider { providerType: dbProvider?.providerType ?? "claude", preserveClientIp: dbProvider?.preserveClientIp ?? false, disableSessionReuse: dbProvider?.disableSessionReuse ?? false, - modelRedirects: dbProvider?.modelRedirects ?? null, + modelRedirects: normalizeProviderModelRedirectRules(dbProvider?.modelRedirects), activeTimeStart: dbProvider?.activeTimeStart ?? null, activeTimeEnd: dbProvider?.activeTimeEnd ?? null, mcpPassthroughType: dbProvider?.mcpPassthroughType ?? "none", diff --git a/src/repository/provider.ts b/src/repository/provider.ts index d18c9b71d..344de1a3a 100644 --- a/src/repository/provider.ts +++ b/src/repository/provider.ts @@ -7,12 +7,14 @@ import { getCachedProviders } from "@/lib/cache/provider-cache"; import { PROVIDER_TIMEOUT_DEFAULTS } from "@/lib/constants/provider.constants"; import { resetEndpointCircuit } from "@/lib/endpoint-circuit-breaker"; import { logger } from "@/lib/logger"; +import { normalizeProviderModelRedirectRules } from "@/lib/provider-model-redirects"; import { parseProviderGroups } from "@/lib/utils/provider-group"; import { resolveSystemTimezone } from "@/lib/utils/timezone"; import type { AnthropicAdaptiveThinkingConfig, CreateProviderData, Provider, + ProviderModelRedirectRule, UpdateProviderData, } from "@/types/provider"; import { toProvider } from "./_shared/transformers"; @@ -186,7 +188,7 @@ export async function createProvider(providerData: CreateProviderData): Promise< providerType: providerData.provider_type, preserveClientIp: providerData.preserve_client_ip ?? false, disableSessionReuse: providerData.disable_session_reuse ?? false, - modelRedirects: providerData.model_redirects, + modelRedirects: normalizeProviderModelRedirectRules(providerData.model_redirects), allowedModels: providerData.allowed_models, allowedClients: providerData.allowed_clients ?? [], blockedClients: providerData.blocked_clients ?? [], @@ -1029,7 +1031,7 @@ export interface BatchProviderUpdates { weight?: number; costMultiplier?: string; groupTag?: string | null; - modelRedirects?: Record | null; + modelRedirects?: ProviderModelRedirectRule[] | null; allowedModels?: string[] | null; allowedClients?: string[] | null; blockedClients?: string[] | null; diff --git a/src/types/message.ts b/src/types/message.ts index 382f8cbd6..fe3f3bfe5 100644 --- a/src/types/message.ts +++ b/src/types/message.ts @@ -92,6 +92,11 @@ export interface ProviderChainItem { originalModel: string; // 用户请求的模型(计费依据) redirectedModel: string; // 实际转发的模型 billingModel: string; // 计费模型(通常等于 originalModel) + matchedRule?: { + matchType: "exact" | "prefix" | "suffix" | "contains" | "regex"; + source: string; + target: string; + }; }; // 错误信息(记录失败时的上游报错) diff --git a/src/types/provider.ts b/src/types/provider.ts index c86974f20..46c092efd 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -46,6 +46,14 @@ export interface AnthropicAdaptiveThinkingConfig { models: string[]; } +export type ProviderModelRedirectMatchType = "exact" | "prefix" | "suffix" | "contains" | "regex"; + +export interface ProviderModelRedirectRule { + matchType: ProviderModelRedirectMatchType; + source: string; + target: string; +} + export type ProviderPatchOperation = | { mode: "no_change" } | { mode: "set"; value: T } @@ -117,7 +125,7 @@ export interface ProviderBatchPatchDraft { weight?: ProviderPatchDraftInput; cost_multiplier?: ProviderPatchDraftInput; group_tag?: ProviderPatchDraftInput; - model_redirects?: ProviderPatchDraftInput>; + model_redirects?: ProviderPatchDraftInput; allowed_models?: ProviderPatchDraftInput; allowed_clients?: ProviderPatchDraftInput; blocked_clients?: ProviderPatchDraftInput; @@ -171,7 +179,7 @@ export interface ProviderBatchPatch { weight: ProviderPatchOperation; cost_multiplier: ProviderPatchOperation; group_tag: ProviderPatchOperation; - model_redirects: ProviderPatchOperation>; + model_redirects: ProviderPatchOperation; allowed_models: ProviderPatchOperation; allowed_clients: ProviderPatchOperation; blocked_clients: ProviderPatchOperation; @@ -225,7 +233,7 @@ export interface ProviderBatchApplyUpdates { weight?: number; cost_multiplier?: number; group_tag?: string | null; - model_redirects?: Record | null; + model_redirects?: ProviderModelRedirectRule[] | null; allowed_models?: string[] | null; allowed_clients?: string[]; blocked_clients?: string[]; @@ -305,7 +313,7 @@ export interface Provider { preserveClientIp: boolean; // 是否跳过当前供应商的 sticky session 复用 disableSessionReuse: boolean; - modelRedirects: Record | null; + modelRedirects: ProviderModelRedirectRule[] | null; // Scheduled active time window (HH:mm format, null = always active) activeTimeStart: string | null; @@ -423,7 +431,7 @@ export interface ProviderDisplay { preserveClientIp: boolean; // 是否跳过当前供应商的 sticky session 复用 disableSessionReuse: boolean; - modelRedirects: Record | null; + modelRedirects: ProviderModelRedirectRule[] | null; // Scheduled active time window activeTimeStart: string | null; activeTimeEnd: string | null; @@ -520,7 +528,7 @@ export interface CreateProviderData { provider_type?: ProviderType; preserve_client_ip?: boolean; disable_session_reuse?: boolean; - model_redirects?: Record | null; + model_redirects?: ProviderModelRedirectRule[] | null; active_time_start?: string | null; active_time_end?: string | null; allowed_models?: string[] | null; @@ -600,7 +608,7 @@ export interface UpdateProviderData { provider_type?: ProviderType; preserve_client_ip?: boolean; disable_session_reuse?: boolean; - model_redirects?: Record | null; + model_redirects?: ProviderModelRedirectRule[] | null; active_time_start?: string | null; active_time_end?: string | null; allowed_models?: string[] | null; diff --git a/tests/api/providers-actions.test.ts b/tests/api/providers-actions.test.ts index e34aaf344..78c4dcc81 100644 --- a/tests/api/providers-actions.test.ts +++ b/tests/api/providers-actions.test.ts @@ -223,10 +223,18 @@ describe.skip("供应商管理 - 添加供应商 (addProvider)", () => { url: "https://api.anthropic.com", key: "sk-test-key", provider_type: "claude", - model_redirects: { - "claude-3-opus": "claude-3-sonnet", - "gpt-4": "claude-3-opus", - }, + model_redirects: [ + { + matchType: "prefix", + source: "claude-3-opus", + target: "claude-3-sonnet", + }, + { + matchType: "contains", + source: "gpt-4", + target: "claude-3-opus", + }, + ], }); expect(response.ok).toBe(true); diff --git a/tests/integration/batch-edit-prefill.test.ts b/tests/integration/batch-edit-prefill.test.ts index 596690797..5b6475110 100644 --- a/tests/integration/batch-edit-prefill.test.ts +++ b/tests/integration/batch-edit-prefill.test.ts @@ -11,7 +11,7 @@ describe("批量编辑预填充集成测试", () => { priority: 10, weight: 5, costMultiplier: 1.5, - modelRedirects: { "model-a": "model-b" }, + modelRedirects: [{ matchType: "exact", source: "model-a", target: "model-b" }], allowedModels: ["model-1", "model-2"], limit5hUsd: 100, circuitBreakerFailureThreshold: 5, @@ -23,7 +23,7 @@ describe("批量编辑预填充集成测试", () => { priority: 10, weight: 5, costMultiplier: 1.5, - modelRedirects: { "model-a": "model-b" }, + modelRedirects: [{ matchType: "exact", source: "model-a", target: "model-b" }], allowedModels: ["model-1", "model-2"], limit5hUsd: 100, circuitBreakerFailureThreshold: 5, @@ -37,7 +37,9 @@ describe("批量编辑预填充集成测试", () => { expect(state.routing.priority).toBe(10); expect(state.routing.weight).toBe(5); expect(state.routing.costMultiplier).toBe(1.5); - expect(state.routing.modelRedirects).toEqual({ "model-a": "model-b" }); + expect(state.routing.modelRedirects).toEqual([ + { matchType: "exact", source: "model-a", target: "model-b" }, + ]); expect(state.routing.allowedModels).toEqual(["model-1", "model-2"]); expect(state.rateLimit.limit5hUsd).toBe(100); expect(state.circuitBreaker.failureThreshold).toBe(5); @@ -74,7 +76,7 @@ describe("批量编辑预填充集成测试", () => { expect(state.routing.priority).toBe(0); expect(state.routing.weight).toBe(1); expect(state.routing.costMultiplier).toBe(1.0); - expect(state.routing.modelRedirects).toEqual({}); + expect(state.routing.modelRedirects).toEqual([]); expect(state.routing.allowedModels).toEqual([]); expect(state.rateLimit.limit5hUsd).toBeNull(); expect(state.circuitBreaker.failureThreshold).toBeUndefined(); diff --git a/tests/unit/actions/providers-batch-field-mapping.test.ts b/tests/unit/actions/providers-batch-field-mapping.test.ts index 95cac0389..cfd8ba3b8 100644 --- a/tests/unit/actions/providers-batch-field-mapping.test.ts +++ b/tests/unit/actions/providers-batch-field-mapping.test.ts @@ -64,7 +64,10 @@ describe("batchUpdateProviders - advanced field mapping", () => { }); it("should map model_redirects to repository modelRedirects", async () => { - const redirects = { "claude-3-opus": "claude-3.5-sonnet", "gpt-4": "gpt-4o" }; + const redirects = [ + { matchType: "exact", source: "claude-3-opus", target: "claude-3.5-sonnet" }, + { matchType: "exact", source: "gpt-4", target: "gpt-4o" }, + ]; const { batchUpdateProviders } = await import("@/actions/providers"); const result = await batchUpdateProviders({ @@ -91,6 +94,19 @@ describe("batchUpdateProviders - advanced field mapping", () => { }); }); + it("should reject unsafe regex model_redirects in direct batchUpdateProviders", async () => { + const { batchUpdateProviders } = await import("@/actions/providers"); + const result = await batchUpdateProviders({ + providerIds: [5], + updates: { + model_redirects: [{ matchType: "regex", source: "(a+)+", target: "glm-4.6" }], + }, + }); + + expect(result.ok).toBe(false); + expect(updateProvidersBatchMock).not.toHaveBeenCalled(); + }); + it("should map allowed_models with values correctly", async () => { const { batchUpdateProviders } = await import("@/actions/providers"); const result = await batchUpdateProviders({ @@ -217,7 +233,7 @@ describe("batchUpdateProviders - advanced field mapping", () => { weight: 3, cost_multiplier: 0.8, group_tag: "mixed-batch", - model_redirects: { "old-model": "new-model" }, + model_redirects: [{ matchType: "exact", source: "old-model", target: "new-model" }], allowed_models: ["claude-3-opus"], anthropic_thinking_budget_preference: "5000", anthropic_adaptive_thinking: adaptiveConfig, @@ -234,7 +250,7 @@ describe("batchUpdateProviders - advanced field mapping", () => { weight: 3, costMultiplier: "0.8", groupTag: "mixed-batch", - modelRedirects: { "old-model": "new-model" }, + modelRedirects: [{ matchType: "exact", source: "old-model", target: "new-model" }], allowedModels: ["claude-3-opus"], anthropicThinkingBudgetPreference: "5000", anthropicAdaptiveThinking: adaptiveConfig, diff --git a/tests/unit/actions/providers-patch-contract.test.ts b/tests/unit/actions/providers-patch-contract.test.ts index 6ea46db3b..8aa6294bb 100644 --- a/tests/unit/actions/providers-patch-contract.test.ts +++ b/tests/unit/actions/providers-patch-contract.test.ts @@ -111,17 +111,39 @@ describe("provider patch contract", () => { expect(result.error.field).toBe("weight"); }); - it("rejects model_redirects arrays", () => { + it("accepts model_redirects with redirect rule array", () => { const result = normalizeProviderBatchPatchDraft({ model_redirects: { - set: ["not-a-record"], - } as never, + set: [{ matchType: "prefix", source: "claude-opus", target: "glm-4.6" }], + }, + }); + + expect(result.ok).toBe(true); + }); + + it("rejects model_redirects with unsafe regex rule", () => { + const result = normalizeProviderBatchPatchDraft({ + model_redirects: { + set: [{ matchType: "regex", source: "(a+)+", target: "glm-4.6" }], + }, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + + expect(result.error.field).toBe("model_redirects"); + }); + + it("rejects model_redirects with overlong source", () => { + const result = normalizeProviderBatchPatchDraft({ + model_redirects: { + set: [{ matchType: "exact", source: "a".repeat(256), target: "glm-4.6" }], + }, }); expect(result.ok).toBe(false); if (result.ok) return; - expect(result.error.code).toBe(PROVIDER_PATCH_ERROR_CODES.INVALID_PATCH_SHAPE); expect(result.error.field).toBe("model_redirects"); }); diff --git a/tests/unit/batch-edit/analyze-batch-settings.test.ts b/tests/unit/batch-edit/analyze-batch-settings.test.ts index 362c296cc..d453df7cf 100644 --- a/tests/unit/batch-edit/analyze-batch-settings.test.ts +++ b/tests/unit/batch-edit/analyze-batch-settings.test.ts @@ -46,15 +46,19 @@ describe("analyzeBatchProviderSettings", () => { it("应该识别所有供应商有相同的对象值", () => { const providers: ProviderDisplay[] = [ - { modelRedirects: { "model-a": "model-b" } } as ProviderDisplay, - { modelRedirects: { "model-a": "model-b" } } as ProviderDisplay, + { + modelRedirects: [{ matchType: "exact", source: "model-a", target: "model-b" }], + } as ProviderDisplay, + { + modelRedirects: [{ matchType: "exact", source: "model-a", target: "model-b" }], + } as ProviderDisplay, ]; const result = analyzeBatchProviderSettings(providers); expect(result.routing.modelRedirects).toEqual({ status: "uniform", - value: { "model-a": "model-b" }, + value: [{ matchType: "exact", source: "model-a", target: "model-b" }], }); }); @@ -106,8 +110,12 @@ describe("analyzeBatchProviderSettings", () => { it("应该识别供应商有不同的对象值", () => { const providers: ProviderDisplay[] = [ - { modelRedirects: { "model-a": "model-b" } } as ProviderDisplay, - { modelRedirects: { "model-c": "model-d" } } as ProviderDisplay, + { + modelRedirects: [{ matchType: "exact", source: "model-a", target: "model-b" }], + } as ProviderDisplay, + { + modelRedirects: [{ matchType: "exact", source: "model-c", target: "model-d" }], + } as ProviderDisplay, ]; const result = analyzeBatchProviderSettings(providers); @@ -115,8 +123,8 @@ describe("analyzeBatchProviderSettings", () => { expect(result.routing.modelRedirects.status).toBe("mixed"); if (result.routing.modelRedirects.status === "mixed") { expect(result.routing.modelRedirects.values).toEqual([ - { "model-a": "model-b" }, - { "model-c": "model-d" }, + [{ matchType: "exact", source: "model-a", target: "model-b" }], + [{ matchType: "exact", source: "model-c", target: "model-d" }], ]); } }); diff --git a/tests/unit/dashboard/provider-form-clone-deep-copy.test.ts b/tests/unit/dashboard/provider-form-clone-deep-copy.test.ts index d5a0932ba..53faabc00 100644 --- a/tests/unit/dashboard/provider-form-clone-deep-copy.test.ts +++ b/tests/unit/dashboard/provider-form-clone-deep-copy.test.ts @@ -17,7 +17,7 @@ function makeProvider(overrides?: Partial): ProviderDisplay { providerType: "claude", providerVendorId: null, preserveClientIp: false, - modelRedirects: { "claude-3": "claude-3.5" }, + modelRedirects: [{ matchType: "exact", source: "claude-3", target: "claude-3.5" }], allowedModels: ["claude-3", "claude-3.5"], mcpPassthroughType: "none", mcpPassthroughUrl: null, @@ -66,7 +66,7 @@ function makeProvider(overrides?: Partial): ProviderDisplay { describe("createInitialState deep-copy safety", () => { describe("clone mode", () => { - it("modelRedirects is a distinct object with equal values", () => { + it("modelRedirects is a distinct array with equal values", () => { const source = makeProvider(); const state = createInitialState("create", undefined, source); expect(state.routing.modelRedirects).toEqual(source.modelRedirects); @@ -103,10 +103,10 @@ describe("createInitialState deep-copy safety", () => { expect(state.routing.anthropicAdaptiveThinking).toBeNull(); }); - it("null modelRedirects falls back to empty object", () => { + it("null modelRedirects falls back to empty array", () => { const source = makeProvider({ modelRedirects: null }); const state = createInitialState("create", undefined, source); - expect(state.routing.modelRedirects).toEqual({}); + expect(state.routing.modelRedirects).toEqual([]); }); it("null allowedModels falls back to empty array", () => { @@ -149,7 +149,7 @@ describe("createInitialState deep-copy safety", () => { describe("create mode without clone source", () => { it("nested objects use fresh defaults", () => { const state = createInitialState("create"); - expect(state.routing.modelRedirects).toEqual({}); + expect(state.routing.modelRedirects).toEqual([]); expect(state.routing.allowedModels).toEqual([]); expect(state.routing.groupPriorities).toEqual({}); expect(state.routing.anthropicAdaptiveThinking).toBeNull(); diff --git a/tests/unit/lib/provider-model-redirects.test.ts b/tests/unit/lib/provider-model-redirects.test.ts new file mode 100644 index 000000000..00b6faf59 --- /dev/null +++ b/tests/unit/lib/provider-model-redirects.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import type { ProviderModelRedirectRule } from "@/types/provider"; +import { + findMatchingProviderModelRedirectRule, + normalizeProviderModelRedirectRules, +} from "@/lib/provider-model-redirects"; + +describe("provider model redirect rules", () => { + it("supports prefix suffix contains and regex matching in rule order", () => { + const rules: ProviderModelRedirectRule[] = [ + { + matchType: "contains", + source: "opus", + target: "contains-opus", + }, + { + matchType: "prefix", + source: "claude-opus", + target: "prefix-opus", + }, + { + matchType: "suffix", + source: "20251001", + target: "suffix-version", + }, + { + matchType: "regex", + source: "^claude-opus-4-.*$", + target: "regex-opus", + }, + ]; + + expect(findMatchingProviderModelRedirectRule("claude-opus-4-5-20251001", rules)?.target).toBe( + "contains-opus" + ); + expect(findMatchingProviderModelRedirectRule("claude-opus-4-5", rules)?.target).toBe( + "contains-opus" + ); + expect(findMatchingProviderModelRedirectRule("foo-20251001", rules)?.target).toBe( + "suffix-version" + ); + expect(findMatchingProviderModelRedirectRule("claude-opus-4-6", rules)?.target).toBe( + "contains-opus" + ); + }); + + it("returns null when no rule matches", () => { + const rules: ProviderModelRedirectRule[] = [ + { + matchType: "prefix", + source: "claude-opus", + target: "glm-4.6", + }, + ]; + + expect(findMatchingProviderModelRedirectRule("claude-sonnet-4-5", rules)).toBeNull(); + }); + + it("normalizes legacy exact redirect maps into exact-match rules", () => { + expect( + normalizeProviderModelRedirectRules({ + "claude-opus-4-5": "glm-4.6", + "gpt-4": "gpt-4o", + }) + ).toEqual([ + { + matchType: "exact", + source: "claude-opus-4-5", + target: "glm-4.6", + }, + { + matchType: "exact", + source: "gpt-4", + target: "gpt-4o", + }, + ]); + }); +}); diff --git a/tests/unit/settings/providers/build-patch-draft.test.ts b/tests/unit/settings/providers/build-patch-draft.test.ts index f568662fe..41c0ad42d 100644 --- a/tests/unit/settings/providers/build-patch-draft.test.ts +++ b/tests/unit/settings/providers/build-patch-draft.test.ts @@ -14,7 +14,7 @@ function createBatchState(): ProviderFormState { groupTag: [], preserveClientIp: false, disableSessionReuse: false, - modelRedirects: {}, + modelRedirects: [], allowedModels: [], allowedClients: [], blockedClients: [], @@ -164,7 +164,7 @@ describe("buildPatchDraftFromFormState", () => { expect(draft.group_tag).toEqual({ set: "tagA,tagB" }); }); - it("clears modelRedirects when dirty and empty object", () => { + it("clears modelRedirects when dirty and empty list", () => { const state = createBatchState(); const dirty = new Set(["routing.modelRedirects"]); @@ -175,12 +175,14 @@ describe("buildPatchDraftFromFormState", () => { it("sets modelRedirects when dirty and has entries", () => { const state = createBatchState(); - state.routing.modelRedirects = { "model-a": "model-b" }; + state.routing.modelRedirects = [{ matchType: "exact", source: "model-a", target: "model-b" }]; const dirty = new Set(["routing.modelRedirects"]); const draft = buildPatchDraftFromFormState(state, dirty); - expect(draft.model_redirects).toEqual({ set: { "model-a": "model-b" } }); + expect(draft.model_redirects).toEqual({ + set: [{ matchType: "exact", source: "model-a", target: "model-b" }], + }); }); it("clears allowedModels when dirty and empty array", () => { diff --git a/tests/unit/settings/providers/model-multi-select-custom-models-ui.test.tsx b/tests/unit/settings/providers/model-multi-select-custom-models-ui.test.tsx index d6aacfedc..9f14a1c48 100644 --- a/tests/unit/settings/providers/model-multi-select-custom-models-ui.test.tsx +++ b/tests/unit/settings/providers/model-multi-select-custom-models-ui.test.tsx @@ -3,7 +3,7 @@ */ import type { ReactNode } from "react"; -import { act } from "react"; +import { act, useState } from "react"; import { createRoot } from "react-dom/client"; import { NextIntlClientProvider } from "next-intl"; import { beforeEach, describe, expect, test, vi } from "vitest"; @@ -109,4 +109,114 @@ describe("ModelMultiSelect: 自定义白名单模型应可在列表中取消选 unmount(); }); + + test("无需展开下拉框也应显示完整已选白名单,并支持直接删除与编辑", async () => { + const messages = loadMessages(); + + function StatefulHarness() { + const [selectedModels, setSelectedModels] = useState([ + "custom-model-x", + "claude-opus-4-5-20251001", + ]); + + return ( + + + + ); + } + + const { unmount } = render(); + + await flushTicks(5); + + expect(document.body.textContent || "").toContain("custom-model-x"); + expect(document.body.textContent || "").toContain("claude-opus-4-5-20251001"); + + const removeButton = document.querySelector( + '[data-model-remove="custom-model-x"]' + ) as HTMLButtonElement | null; + expect(removeButton).toBeTruthy(); + + await act(async () => { + removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + await flushTicks(2); + expect(document.body.textContent || "").not.toContain("custom-model-x"); + expect(document.body.textContent || "").toContain("claude-opus-4-5-20251001"); + + const editButton = document.querySelector( + '[data-model-edit="claude-opus-4-5-20251001"]' + ) as HTMLButtonElement | null; + expect(editButton).toBeTruthy(); + + await act(async () => { + editButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + const editInput = document.querySelector( + '[data-model-edit-input="claude-opus-4-5-20251001"]' + ) as HTMLInputElement | null; + expect(editInput).toBeTruthy(); + + await act(async () => { + if (editInput) { + editInput.value = "claude-opus-4-6-latest"; + editInput.dispatchEvent(new Event("input", { bubbles: true })); + editInput.dispatchEvent(new Event("change", { bubbles: true })); + } + }); + + const saveButton = document.querySelector( + '[data-model-edit-save="claude-opus-4-5-20251001"]' + ) as HTMLButtonElement | null; + expect(saveButton).toBeTruthy(); + + await act(async () => { + saveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + await flushTicks(2); + expect(document.body.textContent || "").toContain("claude-opus-4-6-latest"); + expect(document.body.textContent || "").not.toContain("claude-opus-4-5-20251001"); + + unmount(); + }); + + test("下拉框应把已选模型单独置顶显示", async () => { + const messages = loadMessages(); + + const { unmount } = render( + + + + ); + + await flushTicks(5); + + const trigger = document.querySelector("button[role='combobox']") as HTMLButtonElement | null; + expect(trigger).toBeTruthy(); + + await act(async () => { + trigger?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })); + trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + await flushTicks(5); + + expect(document.body.textContent || "").toContain("Selected Models"); + expect(document.querySelector('[data-model-group="selected"]')).toBeTruthy(); + expect(document.querySelector('[data-model-group="available"]')).toBeTruthy(); + + unmount(); + }); }); diff --git a/tests/unit/settings/providers/model-redirect-editor.test.tsx b/tests/unit/settings/providers/model-redirect-editor.test.tsx new file mode 100644 index 000000000..d649b3a87 --- /dev/null +++ b/tests/unit/settings/providers/model-redirect-editor.test.tsx @@ -0,0 +1,171 @@ +/** + * @vitest-environment happy-dom + */ + +import type { ReactNode } from "react"; +import { act, useState } from "react"; +import { createRoot } from "react-dom/client"; +import { NextIntlClientProvider } from "next-intl"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { ModelRedirectEditor } from "@/app/[locale]/settings/providers/_components/model-redirect-editor"; +import type { ProviderModelRedirectRule } from "@/types/provider"; +import commonMessages from "../../../../messages/en/common.json"; +import errorsMessages from "../../../../messages/en/errors.json"; +import formsMessages from "../../../../messages/en/forms.json"; +import settingsMessages from "../../../../messages/en/settings"; +import uiMessages from "../../../../messages/en/ui.json"; + +function loadMessages() { + return { + common: commonMessages, + errors: errorsMessages, + ui: uiMessages, + forms: formsMessages, + settings: settingsMessages, + }; +} + +function render(node: ReactNode) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(node); + }); + + return { + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +async function flushTicks(times = 3) { + for (let i = 0; i < times; i++) { + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + } +} + +describe("ModelRedirectEditor", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("编辑中的规则在前一条被删除后仍应保存到原规则,而不是错位到其他行", async () => { + const messages = loadMessages(); + + const initialRules: ProviderModelRedirectRule[] = [ + { matchType: "exact", source: "model-a", target: "target-a" }, + { matchType: "prefix", source: "model-b", target: "target-b" }, + ]; + + function StatefulHarness() { + const [rules, setRules] = useState(initialRules); + + return ( + + + + ); + } + + const { unmount } = render(); + + await flushTicks(3); + + const editButton = document.querySelector( + '[data-redirect-edit="prefix:model-b"]' + ) as HTMLButtonElement | null; + expect(editButton).toBeTruthy(); + + await act(async () => { + editButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + const editSourceInput = document.querySelector( + '[data-redirect-edit-source="prefix:model-b"]' + ) as HTMLInputElement | null; + expect(editSourceInput).toBeTruthy(); + + await act(async () => { + if (editSourceInput) { + editSourceInput.value = "model-b-updated"; + editSourceInput.dispatchEvent(new Event("input", { bubbles: true })); + editSourceInput.dispatchEvent(new Event("change", { bubbles: true })); + } + }); + + const removeFirstButton = document.querySelector( + '[data-redirect-remove="exact:model-a"]' + ) as HTMLButtonElement | null; + expect(removeFirstButton).toBeTruthy(); + + await act(async () => { + removeFirstButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + const saveButton = document.querySelector( + '[data-redirect-save="prefix:model-b"]' + ) as HTMLButtonElement | null; + expect(saveButton).toBeTruthy(); + + await act(async () => { + saveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + await flushTicks(2); + + expect(document.body.textContent || "").toContain("model-b-updated"); + expect(document.body.textContent || "").not.toContain("target-a"); + + unmount(); + }); + + test("新增规则时应在本地拦截超长 source,避免直到提交 provider 才失败", async () => { + const messages = loadMessages(); + const onChange = vi.fn(); + + const { unmount } = render( + + + + ); + + const sourceInput = document.querySelector("#new-source") as HTMLInputElement | null; + const targetInput = document.querySelector("#new-target") as HTMLInputElement | null; + expect(sourceInput).toBeTruthy(); + expect(targetInput).toBeTruthy(); + + await act(async () => { + if (sourceInput) { + sourceInput.value = "a".repeat(256); + sourceInput.dispatchEvent(new Event("input", { bubbles: true })); + sourceInput.dispatchEvent(new Event("change", { bubbles: true })); + } + + if (targetInput) { + targetInput.value = "glm-4.6"; + targetInput.dispatchEvent(new Event("input", { bubbles: true })); + targetInput.dispatchEvent(new Event("change", { bubbles: true })); + } + }); + + const addButton = Array.from(document.querySelectorAll("button")).find((button) => + (button.textContent || "").includes("Add") + ); + expect(addButton).toBeTruthy(); + + await act(async () => { + addButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onChange).not.toHaveBeenCalled(); + expect(document.body.textContent || "").toContain("Source model name is too long"); + + unmount(); + }); +}); diff --git a/tests/unit/settings/providers/provider-form-batch-context.test.ts b/tests/unit/settings/providers/provider-form-batch-context.test.ts index de0bd281d..cc0599fb8 100644 --- a/tests/unit/settings/providers/provider-form-batch-context.test.ts +++ b/tests/unit/settings/providers/provider-form-batch-context.test.ts @@ -23,7 +23,7 @@ describe("createInitialState - batch mode", () => { expect(state.routing.costMultiplier).toBe(1.0); expect(state.routing.groupTag).toEqual([]); expect(state.routing.preserveClientIp).toBe(false); - expect(state.routing.modelRedirects).toEqual({}); + expect(state.routing.modelRedirects).toEqual([]); expect(state.routing.allowedModels).toEqual([]); expect(state.routing.cacheTtlPreference).toBe("inherit"); expect(state.routing.swapCacheTtlBilling).toBe(false); From 69d7f52ee6d5182d8385c7ba47b5de3212a0444d Mon Sep 17 00:00:00 2001 From: ding113 Date: Sat, 4 Apr 2026 14:51:08 +0000 Subject: [PATCH 2/4] fix: tighten provider redirect rule validation --- .../providers/form/modelRedirect.json | 7 ++++ .../providers/form/modelRedirect.json | 7 ++++ .../providers/form/modelRedirect.json | 7 ++++ .../providers/form/modelRedirect.json | 7 ++++ .../providers/form/modelRedirect.json | 19 +++++++--- .../_components/model-redirect-editor.tsx | 20 +++++++++- .../actions/providers-patch-contract.test.ts | 6 +++ .../unit/lib/provider-model-redirects.test.ts | 37 ++++++++++--------- .../providers/model-redirect-editor.test.tsx | 8 ++-- 9 files changed, 90 insertions(+), 28 deletions(-) diff --git a/messages/en/settings/providers/form/modelRedirect.json b/messages/en/settings/providers/form/modelRedirect.json index 988f1b4d7..157fcd5be 100644 --- a/messages/en/settings/providers/form/modelRedirect.json +++ b/messages/en/settings/providers/form/modelRedirect.json @@ -10,6 +10,13 @@ "matchTypeContains": "Contains", "matchTypeRegex": "Regex", "orderHint": "Top rules match first", + "maxRules": "You can add up to 100 redirect rules", + "saveRule": "Save rule", + "cancelEdit": "Cancel edit", + "moveRuleUp": "Move rule up", + "moveRuleDown": "Move rule down", + "editRule": "Edit rule", + "deleteRule": "Delete rule", "ruleMoved": "This rule changed while you were editing. Please try again.", "description": "Redirect Claude Code client requested models (e.g. claude-sonnet-4.5) to upstream provider supported models (e.g. glm-4.6, gemini-pro). For cost optimization or third-party AI integration.", "emptyState": "No redirect rules yet. After adding rules, the system will automatically rewrite model names in requests.", diff --git a/messages/ja/settings/providers/form/modelRedirect.json b/messages/ja/settings/providers/form/modelRedirect.json index 77d92db29..f631af6ea 100644 --- a/messages/ja/settings/providers/form/modelRedirect.json +++ b/messages/ja/settings/providers/form/modelRedirect.json @@ -10,6 +10,13 @@ "matchTypeContains": "部分一致", "matchTypeRegex": "正規表現", "orderHint": "上のルールから順に適用されます", + "maxRules": "追加できるリダイレクトルールは 100 件までです", + "saveRule": "ルールを保存", + "cancelEdit": "編集をキャンセル", + "moveRuleUp": "ルールを上へ移動", + "moveRuleDown": "ルールを下へ移動", + "editRule": "ルールを編集", + "deleteRule": "ルールを削除", "ruleMoved": "編集中にルールが変更されました。もう一度お試しください。", "description": "Claude Code クライアントがリクエストするモデル(例:claude-sonnet-4.5)を、上流プロバイダーが実際にサポートするモデル(例:glm-4.6、gemini-pro)にリダイレクトします。コスト最適化やサードパーティAIサービスへの接続に使用します。", "emptyState": "リダイレクトルールがありません。ルールを追加すると、システムは自動的にリクエスト内のモデル名を書き換えます。", diff --git a/messages/ru/settings/providers/form/modelRedirect.json b/messages/ru/settings/providers/form/modelRedirect.json index 4652fd0e8..79a7c9f67 100644 --- a/messages/ru/settings/providers/form/modelRedirect.json +++ b/messages/ru/settings/providers/form/modelRedirect.json @@ -10,6 +10,13 @@ "matchTypeContains": "Содержит", "matchTypeRegex": "Регулярное выражение", "orderHint": "Правила сверху применяются первыми", + "maxRules": "Можно добавить не более 100 правил перенаправления", + "saveRule": "Сохранить правило", + "cancelEdit": "Отменить редактирование", + "moveRuleUp": "Переместить правило вверх", + "moveRuleDown": "Переместить правило вниз", + "editRule": "Редактировать правило", + "deleteRule": "Удалить правило", "ruleMoved": "Правило изменилось во время редактирования. Повторите попытку.", "description": "Перенаправлять запросы моделей от клиента Claude Code (например, claude-sonnet-4.5) к моделям, поддерживаемым вышестоящим провайдером (например, glm-4.6, gemini-pro). Используется для оптимизации затрат или подключения сторонних AI-сервисов.", "emptyState": "Пока нет правил перенаправления. После добавления правил система автоматически переписывает названия моделей в запросах.", diff --git a/messages/zh-CN/settings/providers/form/modelRedirect.json b/messages/zh-CN/settings/providers/form/modelRedirect.json index c8c73f3a5..b6e0da055 100644 --- a/messages/zh-CN/settings/providers/form/modelRedirect.json +++ b/messages/zh-CN/settings/providers/form/modelRedirect.json @@ -8,6 +8,13 @@ "matchTypeContains": "关键词匹配", "matchTypeRegex": "正则匹配", "orderHint": "靠前规则优先生效", + "maxRules": "最多只能添加 100 条重定向规则", + "saveRule": "保存规则", + "cancelEdit": "取消编辑", + "moveRuleUp": "上移规则", + "moveRuleDown": "下移规则", + "editRule": "编辑规则", + "deleteRule": "删除规则", "ruleMoved": "规则在编辑期间发生了变化,请重试", "sourceModel": "用户请求的模型", "targetModel": "实际转发的模型", diff --git a/messages/zh-TW/settings/providers/form/modelRedirect.json b/messages/zh-TW/settings/providers/form/modelRedirect.json index 899f784ef..9a4f7b8b4 100644 --- a/messages/zh-TW/settings/providers/form/modelRedirect.json +++ b/messages/zh-TW/settings/providers/form/modelRedirect.json @@ -3,13 +3,20 @@ "addNewRule": "新增規則", "alreadyExists": "模型「{model}」已存在重新導向規則", "currentRules": "目前規則 ({count})", - "matchTypeLabel": "匹配方式", - "matchTypeExact": "精確匹配", - "matchTypePrefix": "前綴匹配", - "matchTypeSuffix": "後綴匹配", - "matchTypeContains": "關鍵字匹配", - "matchTypeRegex": "正則匹配", + "matchTypeLabel": "比對方式", + "matchTypeExact": "精確比對", + "matchTypePrefix": "前綴比對", + "matchTypeSuffix": "後綴比對", + "matchTypeContains": "關鍵字比對", + "matchTypeRegex": "正則比對", "orderHint": "越前面的規則越先命中", + "maxRules": "最多只能新增 100 條重新導向規則", + "saveRule": "儲存規則", + "cancelEdit": "取消編輯", + "moveRuleUp": "上移規則", + "moveRuleDown": "下移規則", + "editRule": "編輯規則", + "deleteRule": "刪除規則", "ruleMoved": "規則在編輯期間已變更,請再試一次", "description": "將 Claude Code 客戶端請求的模型(如 claude-sonnet-4.5)重新導向到上游供應商實際支援的模型(如 glm-4.6、gemini-pro)。用於成本最佳化或接入第三方 AI 服務。", "emptyState": "目前無重新導向規則。新增規則後,系統將自動重寫請求中的模型名稱。", diff --git a/src/app/[locale]/settings/providers/_components/model-redirect-editor.tsx b/src/app/[locale]/settings/providers/_components/model-redirect-editor.tsx index b17e7d9f7..0dc984be6 100644 --- a/src/app/[locale]/settings/providers/_components/model-redirect-editor.tsx +++ b/src/app/[locale]/settings/providers/_components/model-redirect-editor.tsx @@ -121,6 +121,11 @@ export function ModelRedirectEditor({ }; const handleAdd = () => { + if (redirects.length >= 100) { + setError(t("maxRules")); + return; + } + const nextRule = normalizeRule(newRule); const validationError = validateRule(nextRule); if (validationError) { @@ -305,6 +310,8 @@ export function ModelRedirectEditor({ onClick={() => handleSaveEdit(ruleKey)} disabled={disabled} className="h-8 w-8 p-0" + aria-label={t("saveRule")} + title={t("saveRule")} > @@ -315,6 +322,8 @@ export function ModelRedirectEditor({ onClick={handleCancelEdit} disabled={disabled} className="h-8 w-8 p-0" + aria-label={t("cancelEdit")} + title={t("cancelEdit")} > @@ -342,6 +351,8 @@ export function ModelRedirectEditor({ onClick={() => handleMove(ruleKey, -1)} disabled={disabled || index === 0} className="h-7 w-7 p-0" + aria-label={t("moveRuleUp")} + title={t("moveRuleUp")} > @@ -353,6 +364,8 @@ export function ModelRedirectEditor({ onClick={() => handleMove(ruleKey, 1)} disabled={disabled || index === redirects.length - 1} className="h-7 w-7 p-0" + aria-label={t("moveRuleDown")} + title={t("moveRuleDown")} > @@ -364,6 +377,8 @@ export function ModelRedirectEditor({ onClick={() => handleStartEdit(rule)} disabled={disabled} className="h-7 w-7 p-0" + aria-label={t("editRule")} + title={t("editRule")} > @@ -375,6 +390,8 @@ export function ModelRedirectEditor({ onClick={() => handleRemove(ruleKey)} disabled={disabled} className="h-7 w-7 p-0" + aria-label={t("deleteRule")} + title={t("deleteRule")} > @@ -467,6 +484,7 @@ export function ModelRedirectEditor({