From 5b8ba79a605b7e3f80838da10a74caa7fdbcba21 Mon Sep 17 00:00:00 2001 From: NieiR <31194814+NieiR@users.noreply.github.com> Date: Fri, 9 Jan 2026 00:43:50 +0800 Subject: [PATCH 01/17] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=AF=86?= =?UTF-8?q?=E9=92=A5=E8=A1=A8=E5=8D=95=E4=BE=9B=E5=BA=94=E5=95=86=E5=88=86?= =?UTF-8?q?=E7=BB=84=E9=80=89=E6=8B=A9=E6=97=B6=20default=20=E4=B8=8D?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E7=A7=BB=E9=99=A4=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- biome.json | 2 +- .../_components/user/forms/add-key-form.tsx | 21 +++++++++++++++++-- .../_components/user/forms/edit-key-form.tsx | 21 +++++++++++++++++-- .../messages/_components/session-stats.tsx | 1 - src/components/form/form-layout.tsx | 2 +- 5 files changed, 40 insertions(+), 7 deletions(-) diff --git a/biome.json b/biome.json index 87362d2ac..107110c0f 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.3.10/schema.json", + "$schema": "https://biomejs.dev/schemas/2.3.11/schema.json", "vcs": { "enabled": true, "clientKind": "git", diff --git a/src/app/[locale]/dashboard/_components/user/forms/add-key-form.tsx b/src/app/[locale]/dashboard/_components/user/forms/add-key-form.tsx index c63eea78c..218493c9e 100644 --- a/src/app/[locale]/dashboard/_components/user/forms/add-key-form.tsx +++ b/src/app/[locale]/dashboard/_components/user/forms/add-key-form.tsx @@ -1,7 +1,7 @@ "use client"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; -import { useEffect, useState, useTransition } from "react"; +import { useCallback, useEffect, useState, useTransition } from "react"; import { toast } from "sonner"; import { addKey } from "@/actions/keys"; import { getAvailableProviderGroups } from "@/actions/providers"; @@ -119,6 +119,23 @@ export function AddKeyForm({ userId, user, isAdmin = false, onSuccess }: AddKeyF }, }); + // 选择分组时,自动移除 default(当有多个分组时) + const handleProviderGroupChange = useCallback( + (newValue: string) => { + const groups = newValue + .split(",") + .map((g) => g.trim()) + .filter(Boolean); + if (groups.length > 1 && groups.includes(PROVIDER_GROUP.DEFAULT)) { + const withoutDefault = groups.filter((g) => g !== PROVIDER_GROUP.DEFAULT); + form.setValue("providerGroup", withoutDefault.join(",")); + } else { + form.setValue("providerGroup", newValue); + } + }, + [form] + ); + return ( diff --git a/src/app/[locale]/dashboard/_components/user/forms/edit-key-form.tsx b/src/app/[locale]/dashboard/_components/user/forms/edit-key-form.tsx index cabddee3e..a3a569601 100644 --- a/src/app/[locale]/dashboard/_components/user/forms/edit-key-form.tsx +++ b/src/app/[locale]/dashboard/_components/user/forms/edit-key-form.tsx @@ -1,7 +1,7 @@ "use client"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; -import { useEffect, useState, useTransition } from "react"; +import { useCallback, useEffect, useState, useTransition } from "react"; import { toast } from "sonner"; import { editKey } from "@/actions/keys"; import { getAvailableProviderGroups } from "@/actions/providers"; @@ -138,6 +138,23 @@ export function EditKeyForm({ keyData, user, isAdmin = false, onSuccess }: EditK }, }); + // 选择分组时,自动移除 default(当有多个分组时) + const handleProviderGroupChange = useCallback( + (newValue: string) => { + const groups = newValue + .split(",") + .map((g) => g.trim()) + .filter(Boolean); + if (groups.length > 1 && groups.includes(PROVIDER_GROUP.DEFAULT)) { + const withoutDefault = groups.filter((g) => g !== PROVIDER_GROUP.DEFAULT); + form.setValue("providerGroup", withoutDefault.join(",")); + } else { + form.setValue("providerGroup", newValue); + } + }, + [form] + ); + return ( {config.description}} -
+
{children} From 0d323161475e7edb45c19e4fbf6c9924f27ef7a6 Mon Sep 17 00:00:00 2001 From: miraserver <20286838+miraserver@users.noreply.github.com> Date: Thu, 8 Jan 2026 19:45:04 +0300 Subject: [PATCH 02/17] feat(my-usage): Statistics Summary with auto-refresh, collapsible logs (#559) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(my-usage): add Statistics Summary with auto-refresh, improve UX - Add StatisticsSummaryCard with date range picker and 30s auto-refresh - Add getMyStatsSummary API for fetching stats by time range - Show expiration date in parentheses for Expired status - Remove Today's Statistics (functionality merged into Statistics Summary) - Remove subtitle from header - Add translations for stats section (en/ru/ja/zh-CN/zh-TW) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * test(my-usage): add unit tests for getMyStatsSummary - Register getMyStatsSummary endpoint in Actions API with OpenAPI schema - Add 3 tests: unauthorized 401, basic aggregation with warmup exclusion, date range filtering - Tests verify key/user breakdown separation and data isolation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * fix(ui): logs table and heading style improvements - Use currency symbol instead of code in logs table - Improve Russian translations - Unify heading style in provider-group-info 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * feat(my-usage): collapsible usage logs with header summary Make Usage Logs section collapsible like Quota card with informative header showing: - Last request status with relative time (color-coded) - Success rate percentage with ✓/✗ indicator - Active filters count badge - Auto-refresh indicator Header adapts for mobile with compact layout. Collapsed by default. - Replace Card with Radix Collapsible component - Add metrics calculation (lastLog, successRate, activeFiltersCount) - Add responsive header (desktop/mobile variants) - Add logsCollapsible translations for all 5 locales 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * fix(ui): correct RefreshCw animation in usage-logs-section Animation should trigger on isRefreshing state, not on autoRefreshSeconds config value. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * fix(ui): address CodeRabbit review feedback - Add optional chaining for CURRENCY_CONFIG to prevent runtime errors - Use ∞ symbol for unlimited RPM instead of "neverExpires" text - Add index to React keys in model breakdown to prevent duplicates 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * chore: format code (fix-my-usage-43169ad) --------- Co-authored-by: John Doe Co-authored-by: Claude Opus 4.5 Co-authored-by: github-actions[bot] --- messages/en/dashboard.json | 4 +- messages/en/myUsage.json | 56 +- messages/ja/dashboard.json | 4 +- messages/ja/myUsage.json | 56 +- messages/ru/dashboard.json | 4 +- messages/ru/myUsage.json | 60 ++- messages/zh-CN/myUsage.json | 56 +- messages/zh-TW/myUsage.json | 56 +- src/actions/my-usage.ts | 127 +++++ .../_components/collapsible-quota-card.tsx | 191 +++++++ .../my-usage/_components/expiration-info.tsx | 22 +- .../_components/loading-states.test.tsx | 13 - .../my-usage/_components/my-usage-header.tsx | 5 +- .../_components/provider-group-info.tsx | 67 ++- .../_components/statistics-summary-card.tsx | 297 +++++++++++ .../my-usage/_components/today-usage-card.tsx | 131 ----- .../_components/usage-logs-section.tsx | 482 ++++++++++++------ .../my-usage/_components/usage-logs-table.tsx | 14 +- src/app/[locale]/my-usage/page.tsx | 98 +--- src/app/api/actions/[...route]/route.ts | 48 ++ tests/api/my-usage-readonly.test.ts | 248 +++++++++ 21 files changed, 1545 insertions(+), 494 deletions(-) create mode 100644 src/app/[locale]/my-usage/_components/collapsible-quota-card.tsx create mode 100644 src/app/[locale]/my-usage/_components/statistics-summary-card.tsx delete mode 100644 src/app/[locale]/my-usage/_components/today-usage-card.tsx diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index 1b4325b18..077a63746 100644 --- a/messages/en/dashboard.json +++ b/messages/en/dashboard.json @@ -79,8 +79,8 @@ "minRetryCountPlaceholder": "Enter minimum retries", "apply": "Apply Filter", "reset": "Reset", - "last7days": "Last 7 Days", - "last30days": "Last 30 Days", + "last7days": "7d", + "last30days": "30d", "customRange": "Custom Range", "export": "Export", "exporting": "Exporting...", diff --git a/messages/en/myUsage.json b/messages/en/myUsage.json index f1df3378c..03e01c831 100644 --- a/messages/en/myUsage.json +++ b/messages/en/myUsage.json @@ -1,7 +1,7 @@ { "header": { "title": "My Usage", - "subtitle": "View your quotas and usage logs", + "welcome": "Welcome, {name}", "logout": "Logout", "keyLabel": "Key", "userLabel": "User", @@ -29,21 +29,6 @@ "unlimited": "Unlimited", "empty": "No quota data" }, - "today": { - "title": "Today's Usage", - "autoRefresh": "Auto refresh every {seconds}s", - "refresh": "Refresh", - "calls": "Calls", - "tokensIn": "Input tokens", - "tokensOut": "Output tokens", - "cost": "{currency} cost", - "modelBreakdown": "By model", - "unknownModel": "Unknown model", - "billingModel": "Billing model: {model}", - "callsShort": "{count} calls", - "tokensShort": "In {in} / Out {out}", - "noData": "No data today" - }, "logs": { "title": "Usage Logs", "autoRefresh": "Auto refresh every {seconds}s", @@ -64,7 +49,7 @@ "tokens": "Tokens (in/out)", "cacheWrite": "Cache Write", "cacheRead": "Cache Read", - "cost": "{currency} cost", + "cost": "Cost", "status": "Status", "endpoint": "Endpoint" }, @@ -79,15 +64,52 @@ "title": "Expiration", "keyExpires": "Key Expires", "userExpires": "User Expires", + "rpmLimit": "RPM Limit", "neverExpires": "Never", "expired": "Expired", "expiresIn": "in {time}", "expiringWarning": "Expiring Soon" }, "providerGroup": { + "title": "Provider Groups", "keyGroup": "Key Group", "userGroup": "User Group", "allProviders": "All Providers", "inheritedFromUser": "Inherited from User" + }, + "stats": { + "title": "Statistics Summary", + "autoRefresh": "Auto refresh every {seconds}s", + "totalRequests": "Total Requests", + "totalCost": "Total Cost", + "totalTokens": "Total Tokens", + "cacheTokens": "Cache Tokens", + "input": "Input", + "output": "Output", + "write": "Write", + "read": "Read", + "modelBreakdown": "Model Breakdown", + "keyStats": "Key", + "userStats": "User", + "noData": "No data for selected period", + "unknownModel": "Unknown" + }, + "accessRestrictions": { + "title": "Access Restrictions", + "models": "Models", + "clients": "Clients", + "noRestrictions": "No restrictions" + }, + "quotaCollapsible": { + "title": "Quota Usage", + "daily": "Daily", + "monthly": "Monthly", + "total": "Total" + }, + "logsCollapsible": { + "title": "Usage Logs", + "lastStatus": "Last: {code} ({time})", + "successRate": "{rate}%", + "noData": "No data" } } diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json index 889cd4bcc..8c6340a00 100644 --- a/messages/ja/dashboard.json +++ b/messages/ja/dashboard.json @@ -79,8 +79,8 @@ "minRetryCountPlaceholder": "回数を入力(0 で制限なし)", "apply": "フィルターを適用", "reset": "リセット", - "last7days": "過去7日間", - "last30days": "過去30日間", + "last7days": "7日", + "last30days": "30日", "customRange": "カスタム範囲", "export": "エクスポート", "exporting": "エクスポート中...", diff --git a/messages/ja/myUsage.json b/messages/ja/myUsage.json index 79879f77a..916f9ac39 100644 --- a/messages/ja/myUsage.json +++ b/messages/ja/myUsage.json @@ -1,7 +1,7 @@ { "header": { "title": "マイ利用状況", - "subtitle": "クォータと利用ログを確認", + "welcome": "ようこそ、{name}さん", "logout": "ログアウト", "keyLabel": "キー", "userLabel": "ユーザー", @@ -29,21 +29,6 @@ "unlimited": "無制限", "empty": "クォータ情報がありません" }, - "today": { - "title": "本日の利用", - "autoRefresh": "{seconds}秒ごとに自動更新", - "refresh": "更新", - "calls": "リクエスト数", - "tokensIn": "入力トークン", - "tokensOut": "出力トークン", - "cost": "{currency} コスト", - "modelBreakdown": "モデル別", - "unknownModel": "不明なモデル", - "billingModel": "課金モデル: {model}", - "callsShort": "{count} 回", - "tokensShort": "入力 {in} / 出力 {out}", - "noData": "本日のデータはありません" - }, "logs": { "title": "利用ログ", "autoRefresh": "{seconds}秒ごとに自動更新", @@ -64,7 +49,7 @@ "tokens": "トークン (入/出)", "cacheWrite": "キャッシュ書込", "cacheRead": "キャッシュ読取", - "cost": "{currency} コスト", + "cost": "コスト", "status": "ステータス", "endpoint": "エンドポイント" }, @@ -79,15 +64,52 @@ "title": "有効期限", "keyExpires": "キーの期限", "userExpires": "ユーザーの期限", + "rpmLimit": "RPM制限", "neverExpires": "期限なし", "expired": "期限切れ", "expiresIn": "{time} で期限", "expiringWarning": "まもなく期限" }, "providerGroup": { + "title": "プロバイダーグループ", "keyGroup": "キーグループ", "userGroup": "ユーザーグループ", "allProviders": "すべてのプロバイダー", "inheritedFromUser": "ユーザーから継承" + }, + "stats": { + "title": "統計サマリー", + "autoRefresh": "{seconds}秒ごとに自動更新", + "totalRequests": "リクエスト総数", + "totalCost": "総コスト", + "totalTokens": "トークン総数", + "cacheTokens": "キャッシュトークン", + "input": "入力", + "output": "出力", + "write": "書込", + "read": "読取", + "modelBreakdown": "モデル別", + "keyStats": "キー", + "userStats": "ユーザー", + "noData": "選択期間のデータがありません", + "unknownModel": "不明" + }, + "accessRestrictions": { + "title": "アクセス制限", + "models": "モデル", + "clients": "クライアント", + "noRestrictions": "制限なし" + }, + "quotaCollapsible": { + "title": "クォータ使用状況", + "daily": "日次", + "monthly": "月次", + "total": "合計" + }, + "logsCollapsible": { + "title": "使用ログ", + "lastStatus": "最終: {code} ({time})", + "successRate": "{rate}%", + "noData": "データなし" } } diff --git a/messages/ru/dashboard.json b/messages/ru/dashboard.json index fdf2d0594..add9280f6 100644 --- a/messages/ru/dashboard.json +++ b/messages/ru/dashboard.json @@ -79,8 +79,8 @@ "minRetryCountPlaceholder": "Введите минимум (0 — без ограничения)", "apply": "Применить фильтр", "reset": "Сброс", - "last7days": "Последние 7 дней", - "last30days": "Последние 30 дней", + "last7days": "7д", + "last30days": "30д", "customRange": "Произвольный диапазон", "export": "Экспорт", "exporting": "Экспорт...", diff --git a/messages/ru/myUsage.json b/messages/ru/myUsage.json index 18bfe393a..5f744bb21 100644 --- a/messages/ru/myUsage.json +++ b/messages/ru/myUsage.json @@ -1,7 +1,7 @@ { "header": { "title": "Мои расходы", - "subtitle": "Лимиты и журналы использования", + "welcome": "Добро пожаловать, {name}", "logout": "Выйти", "keyLabel": "Ключ", "userLabel": "Пользователь", @@ -29,21 +29,6 @@ "unlimited": "Без лимита", "empty": "Нет данных о лимитах" }, - "today": { - "title": "Использование сегодня", - "autoRefresh": "Автообновление каждые {seconds}с", - "refresh": "Обновить", - "calls": "Запросы", - "tokensIn": "Входные токены", - "tokensOut": "Выходные токены", - "cost": "Стоимость {currency}", - "modelBreakdown": "По моделям", - "unknownModel": "Неизвестная модель", - "billingModel": "Биллинговая модель: {model}", - "callsShort": "{count} раз", - "tokensShort": "Вх {in} / Вых {out}", - "noData": "Нет данных за сегодня" - }, "logs": { "title": "Журнал использования", "autoRefresh": "Автообновление каждые {seconds}с", @@ -62,9 +47,9 @@ "time": "Время", "model": "Модель", "tokens": "Токены (вх/вых)", - "cacheWrite": "Запись кэша", - "cacheRead": "Чтение кэша", - "cost": "Стоимость {currency}", + "cacheWrite": "Запись кэш", + "cacheRead": "Чтение кэш", + "cost": "Цена", "status": "Статус", "endpoint": "API Endpoint" }, @@ -79,15 +64,52 @@ "title": "Срок действия", "keyExpires": "Срок ключа", "userExpires": "Срок пользователя", + "rpmLimit": "Лимит RPM", "neverExpires": "Бессрочно", "expired": "Истёк", "expiresIn": "через {time}", "expiringWarning": "Скоро истечёт" }, "providerGroup": { + "title": "Группы провайдеров", "keyGroup": "Группа ключа", "userGroup": "Группа пользователя", "allProviders": "Все провайдеры", "inheritedFromUser": "Наследовано от пользователя" + }, + "stats": { + "title": "Сводка статистики", + "autoRefresh": "Автообновление каждые {seconds}с", + "totalRequests": "Всего запросов", + "totalCost": "Общая стоимость", + "totalTokens": "Всего токенов", + "cacheTokens": "Токены кэша", + "input": "Вход", + "output": "Выход", + "write": "Запись", + "read": "Чтение", + "modelBreakdown": "По моделям", + "keyStats": "Ключ", + "userStats": "Пользователь", + "noData": "Нет данных за выбранный период", + "unknownModel": "Неизвестно" + }, + "accessRestrictions": { + "title": "Ограничения доступа", + "models": "Модели", + "clients": "Клиенты", + "noRestrictions": "Без ограничений" + }, + "quotaCollapsible": { + "title": "Использование квоты", + "daily": "День", + "monthly": "Месяц", + "total": "Всего" + }, + "logsCollapsible": { + "title": "Журнал запросов", + "lastStatus": "Посл.: {code} ({time})", + "successRate": "{rate}%", + "noData": "Нет данных" } } diff --git a/messages/zh-CN/myUsage.json b/messages/zh-CN/myUsage.json index 33921cd5c..643048ea4 100644 --- a/messages/zh-CN/myUsage.json +++ b/messages/zh-CN/myUsage.json @@ -1,7 +1,7 @@ { "header": { "title": "我的用量", - "subtitle": "查看额度与使用记录", + "welcome": "欢迎,{name}", "logout": "退出登录", "keyLabel": "密钥", "userLabel": "用户", @@ -29,21 +29,6 @@ "unlimited": "不限", "empty": "暂无额度数据" }, - "today": { - "title": "今日使用", - "autoRefresh": "每{seconds}s自动刷新", - "refresh": "刷新", - "calls": "调用次数", - "tokensIn": "输入 Tokens", - "tokensOut": "输出 Tokens", - "cost": "{currency} 消耗", - "modelBreakdown": "按模型", - "unknownModel": "未知模型", - "billingModel": "计费模型:{model}", - "callsShort": "{count} 次", - "tokensShort": "入 {in} / 出 {out}", - "noData": "今日暂无数据" - }, "logs": { "title": "使用日志", "autoRefresh": "每 {seconds} 秒自动刷新", @@ -64,7 +49,7 @@ "tokens": "Tokens (入/出)", "cacheWrite": "缓存写入", "cacheRead": "缓存读取", - "cost": "{currency} 消耗", + "cost": "消耗", "status": "状态", "endpoint": "API 端点" }, @@ -79,15 +64,52 @@ "title": "过期时间", "keyExpires": "密钥过期", "userExpires": "用户过期", + "rpmLimit": "RPM限制", "neverExpires": "永不过期", "expired": "已过期", "expiresIn": "剩余 {time}", "expiringWarning": "即将过期" }, "providerGroup": { + "title": "供应商分组", "keyGroup": "密钥分组", "userGroup": "用户分组", "allProviders": "全部供应商", "inheritedFromUser": "继承自用户" + }, + "stats": { + "title": "统计摘要", + "autoRefresh": "每{seconds}秒自动刷新", + "totalRequests": "总请求数", + "totalCost": "总费用", + "totalTokens": "总Token数", + "cacheTokens": "缓存Token", + "input": "输入", + "output": "输出", + "write": "写入", + "read": "读取", + "modelBreakdown": "按模型", + "keyStats": "密钥", + "userStats": "用户", + "noData": "所选时段无数据", + "unknownModel": "未知" + }, + "accessRestrictions": { + "title": "访问限制", + "models": "模型", + "clients": "客户端", + "noRestrictions": "无限制" + }, + "quotaCollapsible": { + "title": "配额使用", + "daily": "日", + "monthly": "月", + "total": "总计" + }, + "logsCollapsible": { + "title": "使用日志", + "lastStatus": "最近: {code} ({time})", + "successRate": "{rate}%", + "noData": "无数据" } } diff --git a/messages/zh-TW/myUsage.json b/messages/zh-TW/myUsage.json index be23f69eb..0fa88c279 100644 --- a/messages/zh-TW/myUsage.json +++ b/messages/zh-TW/myUsage.json @@ -1,7 +1,7 @@ { "header": { "title": "我的用量", - "subtitle": "查看額度與使用記錄", + "welcome": "歡迎,{name}", "logout": "登出", "keyLabel": "金鑰", "userLabel": "使用者", @@ -29,21 +29,6 @@ "unlimited": "不限", "empty": "暫無額度資料" }, - "today": { - "title": "今日使用", - "autoRefresh": "每{seconds}s自動刷新", - "refresh": "刷新", - "calls": "呼叫次數", - "tokensIn": "輸入 Tokens", - "tokensOut": "輸出 Tokens", - "cost": "{currency} 花費", - "modelBreakdown": "按模型", - "unknownModel": "未知模型", - "billingModel": "計費模型:{model}", - "callsShort": "{count} 次", - "tokensShort": "入 {in} / 出 {out}", - "noData": "今日無資料" - }, "logs": { "title": "使用紀錄", "autoRefresh": "每 {seconds} 秒自動刷新", @@ -64,7 +49,7 @@ "tokens": "Tokens (入/出)", "cacheWrite": "快取寫入", "cacheRead": "快取讀取", - "cost": "{currency} 花費", + "cost": "花費", "status": "狀態", "endpoint": "API 端點" }, @@ -79,15 +64,52 @@ "title": "到期時間", "keyExpires": "金鑰到期", "userExpires": "使用者到期", + "rpmLimit": "RPM限制", "neverExpires": "永不過期", "expired": "已過期", "expiresIn": "剩餘 {time}", "expiringWarning": "即將到期" }, "providerGroup": { + "title": "供應商分組", "keyGroup": "金鑰分組", "userGroup": "使用者分組", "allProviders": "全部供應商", "inheritedFromUser": "繼承自使用者" + }, + "stats": { + "title": "統計摘要", + "autoRefresh": "每{seconds}秒自動刷新", + "totalRequests": "總請求數", + "totalCost": "總費用", + "totalTokens": "總Token數", + "cacheTokens": "快取Token", + "input": "輸入", + "output": "輸出", + "write": "寫入", + "read": "讀取", + "modelBreakdown": "按模型", + "keyStats": "金鑰", + "userStats": "使用者", + "noData": "所選時段無資料", + "unknownModel": "未知" + }, + "accessRestrictions": { + "title": "存取限制", + "models": "模型", + "clients": "客戶端", + "noRestrictions": "無限制" + }, + "quotaCollapsible": { + "title": "配額使用", + "daily": "日", + "monthly": "月", + "total": "總計" + }, + "logsCollapsible": { + "title": "使用記錄", + "lastStatus": "最近: {code} ({time})", + "successRate": "{rate}%", + "noData": "無資料" } } diff --git a/src/actions/my-usage.ts b/src/actions/my-usage.ts index 17f12bf21..1f10f4278 100644 --- a/src/actions/my-usage.ts +++ b/src/actions/my-usage.ts @@ -12,11 +12,13 @@ import type { CurrencyCode } from "@/lib/utils"; import { EXCLUDE_WARMUP_CONDITION } from "@/repository/_shared/message-request-conditions"; import { getSystemSettings } from "@/repository/system-config"; import { + findUsageLogsStats, findUsageLogsWithDetails, getDistinctEndpointsForKey, getDistinctModelsForKey, getTotalUsageForKey, type UsageLogFilters, + type UsageLogSummary, } from "@/repository/usage-logs"; import type { BillingModelSource } from "@/types/system-config"; import type { ActionResult } from "./types"; @@ -54,6 +56,7 @@ export interface MyUsageQuota { userLimitMonthlyUsd: number | null; userLimitTotalUsd: number | null; userLimitConcurrentSessions: number | null; + userRpmLimit: number | null; userCurrent5hUsd: number; userCurrentDailyUsd: number; userCurrentWeeklyUsd: number; @@ -71,6 +74,9 @@ export interface MyUsageQuota { keyName: string; keyIsEnabled: boolean; + userAllowedModels: string[]; + userAllowedClients: string[]; + expiresAt: Date | null; dailyResetMode: "fixed" | "rolling"; dailyResetTime: string; @@ -246,6 +252,7 @@ export async function getMyQuota(): Promise> { userLimitMonthlyUsd: user.limitMonthlyUsd ?? null, userLimitTotalUsd: user.limitTotalUsd ?? null, userLimitConcurrentSessions: user.limitConcurrentSessions ?? null, + userRpmLimit: user.rpm ?? null, userCurrent5hUsd: userCost5h, userCurrentDailyUsd: userCostDaily, userCurrentWeeklyUsd: userCostWeekly, @@ -263,6 +270,9 @@ export async function getMyQuota(): Promise> { keyName: key.name, keyIsEnabled: key.isEnabled ?? true, + userAllowedModels: user.allowedModels ?? [], + userAllowedClients: user.allowedClients ?? [], + expiresAt: key.expiresAt ?? null, dailyResetMode: key.dailyResetMode ?? "fixed", dailyResetTime: key.dailyResetTime ?? "00:00", @@ -488,3 +498,120 @@ async function getUserConcurrentSessions(userId: number): Promise { return 0; } } + +export interface MyStatsSummaryFilters { + startDate?: string; // "YYYY-MM-DD" + endDate?: string; // "YYYY-MM-DD" +} + +export interface ModelBreakdownItem { + model: string | null; + requests: number; + cost: number; + inputTokens: number; + outputTokens: number; +} + +export interface MyStatsSummary extends UsageLogSummary { + keyModelBreakdown: ModelBreakdownItem[]; + userModelBreakdown: ModelBreakdownItem[]; + currencyCode: CurrencyCode; +} + +/** + * Get aggregated statistics for a date range + * Uses findUsageLogsStats for efficient aggregation + */ +export async function getMyStatsSummary( + filters: MyStatsSummaryFilters = {} +): Promise> { + try { + const session = await getSession({ allowReadOnlyAccess: true }); + if (!session) return { ok: false, error: "Unauthorized" }; + + const settings = await getSystemSettings(); + const currencyCode = settings.currencyDisplay; + + const startTime = filters.startDate + ? new Date(`${filters.startDate}T00:00:00`).getTime() + : undefined; + const endTime = filters.endDate + ? new Date(`${filters.endDate}T23:59:59.999`).getTime() + : undefined; + + // Get aggregated stats using existing repository function + const stats = await findUsageLogsStats({ + keyId: session.key.id, + startTime, + endTime, + }); + + // Get model breakdown for current key + const keyBreakdown = await db + .select({ + model: messageRequest.model, + requests: sql`count(*)::int`, + cost: sql`COALESCE(sum(${messageRequest.costUsd}), 0)`, + inputTokens: sql`COALESCE(sum(${messageRequest.inputTokens}), 0)::int`, + outputTokens: sql`COALESCE(sum(${messageRequest.outputTokens}), 0)::int`, + }) + .from(messageRequest) + .where( + and( + eq(messageRequest.key, session.key.key), + isNull(messageRequest.deletedAt), + EXCLUDE_WARMUP_CONDITION, + startTime ? gte(messageRequest.createdAt, new Date(startTime)) : undefined, + endTime ? lt(messageRequest.createdAt, new Date(endTime)) : undefined + ) + ) + .groupBy(messageRequest.model) + .orderBy(sql`sum(${messageRequest.costUsd}) DESC`); + + // Get model breakdown for user (all keys) + const userBreakdown = await db + .select({ + model: messageRequest.model, + requests: sql`count(*)::int`, + cost: sql`COALESCE(sum(${messageRequest.costUsd}), 0)`, + inputTokens: sql`COALESCE(sum(${messageRequest.inputTokens}), 0)::int`, + outputTokens: sql`COALESCE(sum(${messageRequest.outputTokens}), 0)::int`, + }) + .from(messageRequest) + .where( + and( + eq(messageRequest.userId, session.user.id), + isNull(messageRequest.deletedAt), + EXCLUDE_WARMUP_CONDITION, + startTime ? gte(messageRequest.createdAt, new Date(startTime)) : undefined, + endTime ? lt(messageRequest.createdAt, new Date(endTime)) : undefined + ) + ) + .groupBy(messageRequest.model) + .orderBy(sql`sum(${messageRequest.costUsd}) DESC`); + + const result: MyStatsSummary = { + ...stats, + keyModelBreakdown: keyBreakdown.map((row) => ({ + model: row.model, + requests: row.requests, + cost: Number(row.cost ?? 0), + inputTokens: row.inputTokens, + outputTokens: row.outputTokens, + })), + userModelBreakdown: userBreakdown.map((row) => ({ + model: row.model, + requests: row.requests, + cost: Number(row.cost ?? 0), + inputTokens: row.inputTokens, + outputTokens: row.outputTokens, + })), + currencyCode, + }; + + return { ok: true, data: result }; + } catch (error) { + logger.error("[my-usage] getMyStatsSummary failed", error); + return { ok: false, error: "Failed to get statistics summary" }; + } +} diff --git a/src/app/[locale]/my-usage/_components/collapsible-quota-card.tsx b/src/app/[locale]/my-usage/_components/collapsible-quota-card.tsx new file mode 100644 index 000000000..2a22f4ebe --- /dev/null +++ b/src/app/[locale]/my-usage/_components/collapsible-quota-card.tsx @@ -0,0 +1,191 @@ +"use client"; + +import { AlertTriangle, ChevronDown, Infinity, PieChart } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useState } from "react"; +import type { MyUsageQuota } from "@/actions/my-usage"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import type { CurrencyCode } from "@/lib/utils"; +import { cn } from "@/lib/utils"; +import { calculateUsagePercent } from "@/lib/utils/limit-helpers"; +import { QuotaCards } from "./quota-cards"; + +interface CollapsibleQuotaCardProps { + quota: MyUsageQuota | null; + loading?: boolean; + currencyCode?: CurrencyCode; + keyExpiresAt?: Date | null; + userExpiresAt?: Date | null; + defaultOpen?: boolean; +} + +export function CollapsibleQuotaCard({ + quota, + loading = false, + currencyCode = "USD", + keyExpiresAt, + userExpiresAt, + defaultOpen = false, +}: CollapsibleQuotaCardProps) { + const [isOpen, setIsOpen] = useState(defaultOpen); + const t = useTranslations("myUsage.quotaCollapsible"); + + // Calculate summary metrics + const keyDailyPct = calculateUsagePercent( + quota?.keyCurrentDailyUsd ?? 0, + quota?.keyLimitDailyUsd ?? null + ); + const userDailyPct = calculateUsagePercent( + quota?.userCurrentDailyUsd ?? 0, + quota?.userLimitDailyUsd ?? null + ); + const keyMonthlyPct = calculateUsagePercent( + quota?.keyCurrentMonthlyUsd ?? 0, + quota?.keyLimitMonthlyUsd ?? null + ); + const userMonthlyPct = calculateUsagePercent( + quota?.userCurrentMonthlyUsd ?? 0, + quota?.userLimitMonthlyUsd ?? null + ); + const keyTotalPct = calculateUsagePercent( + quota?.keyCurrentTotalUsd ?? 0, + quota?.keyLimitTotalUsd ?? null + ); + const userTotalPct = calculateUsagePercent( + quota?.userCurrentTotalUsd ?? 0, + quota?.userLimitTotalUsd ?? null + ); + + // Use user-level percentages for summary display (null = unlimited) + const dailyPct = userDailyPct; + const monthlyPct = userMonthlyPct; + const totalPct = userTotalPct; + + const hasWarning = + (dailyPct !== null && dailyPct >= 80) || + (monthlyPct !== null && monthlyPct >= 80) || + (totalPct !== null && totalPct >= 80); + const hasDanger = + (dailyPct !== null && dailyPct >= 95) || + (monthlyPct !== null && monthlyPct >= 95) || + (totalPct !== null && totalPct >= 95); + + const getPercentColor = (pct: number | null) => { + if (pct === null) return "text-muted-foreground"; + if (pct >= 95) return "text-destructive"; + if (pct >= 80) return "text-amber-600 dark:text-amber-400"; + return "text-foreground"; + }; + + return ( + +
+ + + + + +
+ +
+
+
+
+ ); +} diff --git a/src/app/[locale]/my-usage/_components/expiration-info.tsx b/src/app/[locale]/my-usage/_components/expiration-info.tsx index 6eaa31dc7..5f75f7837 100644 --- a/src/app/[locale]/my-usage/_components/expiration-info.tsx +++ b/src/app/[locale]/my-usage/_components/expiration-info.tsx @@ -9,6 +9,7 @@ import { formatDate, getLocaleDateFormat } from "@/lib/utils/date-format"; interface ExpirationInfoProps { keyExpiresAt: Date | null; userExpiresAt: Date | null; + userRpmLimit?: number | null; className?: string; } @@ -17,7 +18,12 @@ const ONE_DAY_IN_SECONDS = 24 * 60 * 60; type ExpireStatus = "none" | "normal" | "warning" | "danger" | "expired"; -export function ExpirationInfo({ keyExpiresAt, userExpiresAt, className }: ExpirationInfoProps) { +export function ExpirationInfo({ + keyExpiresAt, + userExpiresAt, + userRpmLimit, + className, +}: ExpirationInfoProps) { const t = useTranslations("myUsage.expiration"); const locale = useLocale(); @@ -67,7 +73,9 @@ export function ExpirationInfo({ keyExpiresAt, userExpiresAt, className }: Expir

{label}

- {status === "expired" ? t("expired") : formatExpiry(value)} + {status === "expired" + ? `${t("expired")} (${formatExpiry(value)})` + : formatExpiry(value)}
{showCountdown ? ( @@ -81,9 +89,17 @@ export function ExpirationInfo({ keyExpiresAt, userExpiresAt, className }: Expir }; return ( -
+
{renderItem(t("keyExpires"), keyExpiresAt, keyCountdown)} {renderItem(t("userExpires"), userExpiresAt, userCountdown)} +
+

{t("rpmLimit")}

+
+ + {userRpmLimit != null ? userRpmLimit.toLocaleString() : "∞"} + +
+
); } diff --git a/src/app/[locale]/my-usage/_components/loading-states.test.tsx b/src/app/[locale]/my-usage/_components/loading-states.test.tsx index 62a458af6..a7f170da8 100644 --- a/src/app/[locale]/my-usage/_components/loading-states.test.tsx +++ b/src/app/[locale]/my-usage/_components/loading-states.test.tsx @@ -3,18 +3,11 @@ import { renderToStaticMarkup } from "react-dom/server"; import { NextIntlClientProvider } from "next-intl"; import { describe, expect, test } from "vitest"; import { QuotaCards } from "./quota-cards"; -import { TodayUsageCard } from "./today-usage-card"; const messages = { myUsage: { quota: {}, expiration: {}, - today: { - title: "Today", - autoRefresh: "Auto refresh {seconds}s", - refresh: "Refresh", - modelBreakdown: "Model breakdown", - }, }, common: { loading: "Loading...", @@ -35,10 +28,4 @@ describe("my-usage loading states", () => { expect(html).toContain("Loading..."); expect(html).toContain('data-slot="skeleton"'); }); - - test("TodayUsageCard renders skeletons and loading label when loading", () => { - const html = renderWithIntl(); - expect(html).toContain("Loading..."); - expect(html).toContain('data-slot="skeleton"'); - }); }); diff --git a/src/app/[locale]/my-usage/_components/my-usage-header.tsx b/src/app/[locale]/my-usage/_components/my-usage-header.tsx index 5131a032e..b3258e2c1 100644 --- a/src/app/[locale]/my-usage/_components/my-usage-header.tsx +++ b/src/app/[locale]/my-usage/_components/my-usage-header.tsx @@ -71,7 +71,9 @@ export function MyUsageHeader({
-

{t("title")}

+

+ {userName ? t("welcome", { name: userName }) : t("title")} +

{renderCountdownChip(tExpiration("keyExpires"), keyExpiresAt, keyCountdown)} {renderCountdownChip(tExpiration("userExpires"), userExpiresAt, userCountdown)}
@@ -85,7 +87,6 @@ export function MyUsageHeader({ {userName ?? "—"}
-

{t("subtitle")}

+
+ + + {loading ? ( +
+ {Array.from({ length: 4 }).map((_, index) => ( +
+ + +
+ ))} +
+ ) : stats ? ( + <> + {/* Main metrics */} +
+ {/* Total Requests */} +
+
{t("totalRequests")}
+
+ {stats.totalRequests.toLocaleString()} +
+
+ + {/* Total Cost */} +
+
{t("totalCost")}
+
+ {formatCurrency(stats.totalCost, currencyCode)} +
+
+ + {/* Total Tokens */} +
+
{t("totalTokens")}
+
+ {formatTokenAmount(stats.totalTokens)} +
+
+
+ {t("input")}: + {formatTokenAmount(stats.totalInputTokens)} +
+
+ {t("output")}: + {formatTokenAmount(stats.totalOutputTokens)} +
+
+
+ + {/* Cache Tokens */} +
+
{t("cacheTokens")}
+
+ {formatTokenAmount(stats.totalCacheCreationTokens + stats.totalCacheReadTokens)} +
+
+
+ {t("write")}: + + {formatTokenAmount(stats.totalCacheCreationTokens)} + +
+
+ {t("read")}: + + {formatTokenAmount(stats.totalCacheReadTokens)} + +
+
+
+
+ + + + {/* Model Breakdown - 2 columns: Key | User */} +
+

{t("modelBreakdown")}

+
+ {/* Key Stats */} +
+

+ {t("keyStats")} +

+ {stats.keyModelBreakdown.length > 0 ? ( +
+ {stats.keyModelBreakdown.map((item, index) => ( + + ))} +
+ ) : ( +

{t("noData")}

+ )} +
+ + {/* User Stats */} +
+

+ {t("userStats")} +

+ {stats.userModelBreakdown.length > 0 ? ( +
+ {stats.userModelBreakdown.map((item, index) => ( + + ))} +
+ ) : ( +

{t("noData")}

+ )} +
+
+
+ + ) : ( +

{t("noData")}

+ )} +
+ + ); +} + +interface ModelBreakdownRowProps { + model: string | null; + requests: number; + cost: number; + inputTokens: number; + outputTokens: number; + currencyCode: CurrencyCode; +} + +function ModelBreakdownRow({ + model, + requests, + cost, + inputTokens, + outputTokens, + currencyCode, +}: ModelBreakdownRowProps) { + const t = useTranslations("myUsage.stats"); + + return ( +
+
+ {model || t("unknownModel")} + + {requests.toLocaleString()} req · {formatTokenAmount(inputTokens + outputTokens)} tok + +
+
+ {formatCurrency(cost, currencyCode)} +
+
+ ); +} diff --git a/src/app/[locale]/my-usage/_components/today-usage-card.tsx b/src/app/[locale]/my-usage/_components/today-usage-card.tsx deleted file mode 100644 index ce6c2726c..000000000 --- a/src/app/[locale]/my-usage/_components/today-usage-card.tsx +++ /dev/null @@ -1,131 +0,0 @@ -"use client"; - -import { Loader2, RefreshCw } from "lucide-react"; -import { useTranslations } from "next-intl"; -import type { MyTodayStats } from "@/actions/my-usage"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Separator } from "@/components/ui/separator"; -import { Skeleton } from "@/components/ui/skeleton"; - -interface TodayUsageCardProps { - stats: MyTodayStats | null; - loading?: boolean; - refreshing?: boolean; - onRefresh?: () => void; - autoRefreshSeconds?: number; -} - -export function TodayUsageCard({ - stats, - loading = false, - refreshing = false, - onRefresh, - autoRefreshSeconds = 30, -}: TodayUsageCardProps) { - const t = useTranslations("myUsage.today"); - const tCommon = useTranslations("common"); - const isInitialLoading = loading && !stats; - const isButtonLoading = loading || refreshing; - - return ( - - - {t("title")} -
- {t("autoRefresh", { seconds: autoRefreshSeconds })} - -
-
- - {isInitialLoading ? ( -
- {Array.from({ length: 4 }).map((_, index) => ( -
- - -
- ))} -
- ) : ( -
- - - - -
- )} - - - -
-

{t("modelBreakdown")}

- {isInitialLoading ? ( -
- {Array.from({ length: 3 }).map((_, index) => ( -
- - -
- ))} -
- - {tCommon("loading")} -
-
- ) : stats && stats.modelBreakdown.length > 0 ? ( -
- {stats.modelBreakdown.map((item) => ( -
-
- - {item.model || t("unknownModel")} - - {item.billingModel && item.billingModel !== item.model ? ( - - {t("billingModel", { model: item.billingModel })} - - ) : null} -
-
-
{t("callsShort", { count: item.calls })}
-
{t("tokensShort", { in: item.inputTokens, out: item.outputTokens })}
-
- {`${stats.currencyCode || "USD"} ${Number(item.costUsd ?? 0).toFixed(4)}`} -
-
-
- ))} -
- ) : ( -

{t("noData")}

- )} -
-
-
- ); -} - -function Metric({ label, value }: { label: string; value: number | string }) { - return ( -
-

{label}

-

{value}

-
- ); -} diff --git a/src/app/[locale]/my-usage/_components/usage-logs-section.tsx b/src/app/[locale]/my-usage/_components/usage-logs-section.tsx index a07a13e4f..63e518718 100644 --- a/src/app/[locale]/my-usage/_components/usage-logs-section.tsx +++ b/src/app/[locale]/my-usage/_components/usage-logs-section.tsx @@ -1,8 +1,8 @@ "use client"; -import { Loader2 } from "lucide-react"; +import { Check, ChevronDown, Filter, Loader2, RefreshCw, ScrollText, X } from "lucide-react"; import { useTranslations } from "next-intl"; -import { useCallback, useEffect, useRef, useState, useTransition } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, useTransition } from "react"; import { getMyAvailableEndpoints, getMyAvailableModels, @@ -10,8 +10,9 @@ import { type MyUsageLogsResult, } from "@/actions/my-usage"; import { LogsDateRangePicker } from "@/app/[locale]/dashboard/logs/_components/logs-date-range-picker"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { @@ -21,12 +22,14 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { cn } from "@/lib/utils"; import { UsageLogsTable } from "./usage-logs-table"; interface UsageLogsSectionProps { initialData?: MyUsageLogsResult | null; loading?: boolean; autoRefreshSeconds?: number; + defaultOpen?: boolean; } interface Filters { @@ -44,10 +47,13 @@ export function UsageLogsSection({ initialData = null, loading = false, autoRefreshSeconds, + defaultOpen = false, }: UsageLogsSectionProps) { const t = useTranslations("myUsage.logs"); + const tCollapsible = useTranslations("myUsage.logsCollapsible"); const tDashboard = useTranslations("dashboard"); const tCommon = useTranslations("common"); + const [isOpen, setIsOpen] = useState(defaultOpen); const [models, setModels] = useState([]); const [endpoints, setEndpoints] = useState([]); const [isModelsLoading, setIsModelsLoading] = useState(true); @@ -58,6 +64,51 @@ export function UsageLogsSection({ const [isPending, startTransition] = useTransition(); const [error, setError] = useState(null); + // Compute metrics for header summary + const logs = data?.logs ?? []; + + const activeFiltersCount = useMemo(() => { + let count = 0; + if (appliedFilters.startDate || appliedFilters.endDate) count++; + if (appliedFilters.model) count++; + if (appliedFilters.endpoint) count++; + if (appliedFilters.statusCode || appliedFilters.excludeStatusCode200) count++; + if (appliedFilters.minRetryCount) count++; + return count; + }, [appliedFilters]); + + const lastLog = useMemo(() => { + if (!logs || logs.length === 0) return null; + return logs[0]; // First log is the most recent (sorted by createdAt DESC) + }, [logs]); + + const lastStatusText = useMemo(() => { + if (!lastLog?.createdAt) return null; + const now = new Date(); + const logTime = new Date(lastLog.createdAt); + const diffMs = now.getTime() - logTime.getTime(); + const diffMins = Math.floor(diffMs / 60000); + + if (diffMins < 1) return "now"; + if (diffMins < 60) return `${diffMins}m ago`; + const diffHours = Math.floor(diffMins / 60); + if (diffHours < 24) return `${diffHours}h ago`; + return `${Math.floor(diffHours / 24)}d ago`; + }, [lastLog]); + + const successRate = useMemo(() => { + if (!logs || logs.length === 0) return null; + const successCount = logs.filter((log) => log.statusCode && log.statusCode < 400).length; + return Math.round((successCount / logs.length) * 100); + }, [logs]); + + const lastStatusColor = useMemo(() => { + if (!lastLog?.statusCode) return ""; + if (lastLog.statusCode === 200) return "text-green-600 dark:text-green-400"; + if (lastLog.statusCode >= 400) return "text-red-600 dark:text-red-400"; + return ""; + }, [lastLog]); + // Sync initialData from parent when it becomes available // (useState only uses initialData on first mount, not on subsequent updates) useEffect(() => { @@ -187,159 +238,286 @@ export function UsageLogsSection({ const isRefreshing = isPending && Boolean(data); return ( - - - {t("title")} - {autoRefreshSeconds ? ( - - {t("autoRefresh", { seconds: autoRefreshSeconds })} - - ) : null} - - -
-
- - -
-
- - -
-
- - + handleFilterChange({ + model: value === "__all__" ? undefined : value, + }) + } + disabled={isModelsLoading} + > + + + + + {t("filters.allModels")} + {models.map((model) => ( + + {model} + + ))} + + +
+
+ + +
+
+ + +
+
+ + + handleFilterChange({ + minRetryCount: e.target.value ? parseInt(e.target.value, 10) : undefined, + }) } /> - - - {tDashboard("logs.filters.allEndpoints")} - {endpoints.map((endpoint) => ( - - {endpoint} - - ))} - - -
-
- - -
-
- - - handleFilterChange({ - minRetryCount: e.target.value ? parseInt(e.target.value, 10) : undefined, - }) - } +
+
+ +
+ + +
+ + {error ?

{error}

: null} + + {isRefreshing ? ( +
+ + {tCommon("loading")} +
+ ) : null} + +
-
- -
- - -
- - {error ?

{error}

: null} - - {isRefreshing ? ( -
- - {tCommon("loading")} -
- ) : null} - - - - + +
+ ); } diff --git a/src/app/[locale]/my-usage/_components/usage-logs-table.tsx b/src/app/[locale]/my-usage/_components/usage-logs-table.tsx index 09b1494ca..e9a7434b7 100644 --- a/src/app/[locale]/my-usage/_components/usage-logs-table.tsx +++ b/src/app/[locale]/my-usage/_components/usage-logs-table.tsx @@ -13,7 +13,7 @@ import { TableRow, } from "@/components/ui/table"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; -import type { CurrencyCode } from "@/lib/utils"; +import { CURRENCY_CONFIG, type CurrencyCode } from "@/lib/utils"; interface UsageLogsTableProps { logs: MyUsageLogEntry[]; @@ -55,9 +55,7 @@ export function UsageLogsTable({ {t("table.tokens")} {t("table.cacheWrite")} {t("table.cacheRead")} - - {t("table.cost", { currency: currencyCode })} - + {t("table.cost")} {t("table.status")} @@ -129,11 +127,17 @@ export function UsageLogsTable({ {formatTokenAmount(log.cacheReadInputTokens)} - {currencyCode} {Number(log.cost ?? 0).toFixed(4)} + {CURRENCY_CONFIG[currencyCode]?.symbol ?? currencyCode} + {Number(log.cost ?? 0).toFixed(4)} = 400 ? "destructive" : "outline"} + className={ + log.statusCode === 200 + ? "border-green-500 text-green-600 dark:text-green-400" + : undefined + } > {log.statusCode ?? "-"} diff --git a/src/app/[locale]/my-usage/page.tsx b/src/app/[locale]/my-usage/page.tsx index 6e0dc0bdb..6d7e6c4b0 100644 --- a/src/app/[locale]/my-usage/page.tsx +++ b/src/app/[locale]/my-usage/page.tsx @@ -1,38 +1,30 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { getMyQuota, - getMyTodayStats, getMyUsageLogs, - type MyTodayStats, type MyUsageLogsResult, type MyUsageQuota, } from "@/actions/my-usage"; import { useRouter } from "@/i18n/routing"; +import { CollapsibleQuotaCard } from "./_components/collapsible-quota-card"; import { ExpirationInfo } from "./_components/expiration-info"; import { MyUsageHeader } from "./_components/my-usage-header"; import { ProviderGroupInfo } from "./_components/provider-group-info"; -import { QuotaCards } from "./_components/quota-cards"; -import { TodayUsageCard } from "./_components/today-usage-card"; +import { StatisticsSummaryCard } from "./_components/statistics-summary-card"; import { UsageLogsSection } from "./_components/usage-logs-section"; export default function MyUsagePage() { const router = useRouter(); const [quota, setQuota] = useState(null); - const [todayStats, setTodayStats] = useState(null); const [logsData, setLogsData] = useState(null); const [isQuotaLoading, setIsQuotaLoading] = useState(true); - const [isStatsLoading, setIsStatsLoading] = useState(true); const [isLogsLoading, setIsLogsLoading] = useState(true); - const [isStatsRefreshing, setIsStatsRefreshing] = useState(false); - - const intervalRef = useRef(null); const loadInitial = useCallback(() => { setIsQuotaLoading(true); - setIsStatsLoading(true); setIsLogsLoading(true); void getMyQuota() @@ -41,12 +33,6 @@ export default function MyUsagePage() { }) .finally(() => setIsQuotaLoading(false)); - void getMyTodayStats() - .then((statsResult) => { - if (statsResult.ok) setTodayStats(statsResult.data); - }) - .finally(() => setIsStatsLoading(false)); - void getMyUsageLogs({ page: 1 }) .then((logsResult) => { if (logsResult.ok) setLogsData(logsResult.data ?? null); @@ -54,57 +40,10 @@ export default function MyUsagePage() { .finally(() => setIsLogsLoading(false)); }, []); - const refreshToday = useCallback(async () => { - setIsStatsRefreshing(true); - const stats = await getMyTodayStats(); - if (stats.ok) setTodayStats(stats.data); - setIsStatsRefreshing(false); - }, []); - useEffect(() => { loadInitial(); }, [loadInitial]); - useEffect(() => { - const POLL_INTERVAL = 30000; - - const startPolling = () => { - if (intervalRef.current) { - clearInterval(intervalRef.current); - } - - intervalRef.current = setInterval(() => { - refreshToday(); - // Note: logs polling is handled internally by UsageLogsSection - // to preserve pagination state - }, POLL_INTERVAL); - }; - - const stopPolling = () => { - if (intervalRef.current) { - clearInterval(intervalRef.current); - intervalRef.current = null; - } - }; - - const handleVisibilityChange = () => { - if (document.hidden) { - stopPolling(); - } else { - refreshToday(); - startPolling(); - } - }; - - startPolling(); - document.addEventListener("visibilitychange", handleVisibilityChange); - - return () => { - stopPolling(); - document.removeEventListener("visibilitychange", handleVisibilityChange); - }; - }, [refreshToday]); - const handleLogout = async () => { await fetch("/api/auth/logout", { method: "POST" }); router.push("/login"); @@ -113,7 +52,6 @@ export default function MyUsagePage() { const keyExpiresAt = quota?.expiresAt ?? null; const userExpiresAt = quota?.userExpiresAt ?? null; - const currencyCode = todayStats?.currencyCode ?? "USD"; return (
@@ -125,32 +63,32 @@ export default function MyUsagePage() { userExpiresAt={userExpiresAt} /> - - + {/* Provider Group and Expiration info */} {quota ? (
- +
) : null} - + +
); diff --git a/src/app/api/actions/[...route]/route.ts b/src/app/api/actions/[...route]/route.ts index 8f763e4c9..2eabe3c42 100644 --- a/src/app/api/actions/[...route]/route.ts +++ b/src/app/api/actions/[...route]/route.ts @@ -804,6 +804,54 @@ const { route: getMyAvailableEndpointsRoute, handler: getMyAvailableEndpointsHan }); app.openapi(getMyAvailableEndpointsRoute, getMyAvailableEndpointsHandler); +const { route: getMyStatsSummaryRoute, handler: getMyStatsSummaryHandler } = createActionRoute( + "my-usage", + "getMyStatsSummary", + myUsageActions.getMyStatsSummary, + { + requestSchema: z.object({ + startDate: z.string().optional().describe("开始日期(YYYY-MM-DD,可为空)"), + endDate: z.string().optional().describe("结束日期(YYYY-MM-DD,可为空)"), + }), + responseSchema: z.object({ + totalRequests: z.number().describe("总请求数"), + totalCost: z.number().describe("总费用"), + totalInputTokens: z.number().describe("总输入 Token"), + totalOutputTokens: z.number().describe("总输出 Token"), + totalCacheCreationTokens: z.number().describe("缓存创建 Token"), + totalCacheReadTokens: z.number().describe("缓存读取 Token"), + keyModelBreakdown: z + .array( + z.object({ + model: z.string().nullable(), + requests: z.number(), + cost: z.number(), + inputTokens: z.number(), + outputTokens: z.number(), + }) + ) + .describe("当前 Key 的模型分布"), + userModelBreakdown: z + .array( + z.object({ + model: z.string().nullable(), + requests: z.number(), + cost: z.number(), + inputTokens: z.number(), + outputTokens: z.number(), + }) + ) + .describe("用户所有 Key 的模型分布"), + currencyCode: z.string().describe("货币代码"), + }), + description: "获取指定日期范围内的聚合统计(仅返回自己的数据)", + summary: "获取我的统计摘要", + tags: ["统计分析"], + allowReadOnlyAccess: true, + } +); +app.openapi(getMyStatsSummaryRoute, getMyStatsSummaryHandler); + // ==================== 概览数据 ==================== const { route: getOverviewDataRoute, handler: getOverviewDataHandler } = createActionRoute( diff --git a/tests/api/my-usage-readonly.test.ts b/tests/api/my-usage-readonly.test.ts index 7c938d492..50b1248de 100644 --- a/tests/api/my-usage-readonly.test.ts +++ b/tests/api/my-usage-readonly.test.ts @@ -433,4 +433,252 @@ describe("my-usage API:只读 Key 自助查询", () => { expect.arrayContaining(["/v1/messages", "/v1/chat/completions"]) ); }); + + test("getMyStatsSummary:未认证返回 401", async () => { + const { response, json } = await callActionsRoute({ + method: "POST", + pathname: "/api/actions/my-usage/getMyStatsSummary", + body: {}, + }); + + expect(response.status).toBe(401); + expect(json).toMatchObject({ ok: false }); + }); + + test("getMyStatsSummary:基础聚合统计,排除 warmup,区分 key/user breakdown", async () => { + const unique = `stats-summary-${Date.now()}-${Math.random().toString(16).slice(2)}`; + + // 创建两个用户,每个用户一个 key + const userA = await createTestUser(`Test ${unique}-A`); + createdUserIds.push(userA.id); + const keyA = await createTestKey({ + userId: userA.id, + key: `test-stats-key-A-${unique}`, + name: `stats-A-${unique}`, + canLoginWebUi: false, + }); + createdKeyIds.push(keyA.id); + + // 用户 A 的第二个 key(用于测试 user breakdown 聚合多个 key) + const keyA2 = await createTestKey({ + userId: userA.id, + key: `test-stats-key-A2-${unique}`, + name: `stats-A2-${unique}`, + canLoginWebUi: false, + }); + createdKeyIds.push(keyA2.id); + + const userB = await createTestUser(`Test ${unique}-B`); + createdUserIds.push(userB.id); + const keyB = await createTestKey({ + userId: userB.id, + key: `test-stats-key-B-${unique}`, + name: `stats-B-${unique}`, + canLoginWebUi: false, + }); + createdKeyIds.push(keyB.id); + + const now = new Date(); + const today = now.toISOString().split("T")[0]; + const t0 = new Date(now.getTime() - 60 * 1000); + + // Key A 的请求 + const a1 = await createMessage({ + userId: userA.id, + key: keyA.key, + model: "claude-3-opus", + endpoint: "/v1/messages", + costUsd: "0.1000", + inputTokens: 500, + outputTokens: 200, + createdAt: t0, + }); + const a2 = await createMessage({ + userId: userA.id, + key: keyA.key, + model: "claude-3-sonnet", + endpoint: "/v1/messages", + costUsd: "0.0500", + inputTokens: 300, + outputTokens: 100, + createdAt: t0, + }); + + // Key A 的 warmup(应被排除) + const warmupA = await createMessage({ + userId: userA.id, + key: keyA.key, + model: "claude-3-opus", + endpoint: "/v1/messages", + costUsd: "0.9999", + inputTokens: 9999, + outputTokens: 9999, + blockedBy: "warmup", + createdAt: t0, + }); + + // Key A2 的请求(同一用户的不同 key,应在 userBreakdown 中聚合) + const a2_1 = await createMessage({ + userId: userA.id, + key: keyA2.key, + model: "claude-3-opus", + endpoint: "/v1/messages", + costUsd: "0.0800", + inputTokens: 400, + outputTokens: 150, + createdAt: t0, + }); + + // Key B 的请求(不应泄漏给 A) + const b1 = await createMessage({ + userId: userB.id, + key: keyB.key, + model: "gpt-4", + endpoint: "/v1/chat/completions", + costUsd: "0.5000", + inputTokens: 2000, + outputTokens: 1000, + createdAt: t0, + }); + + createdMessageIds.push(a1, a2, warmupA, a2_1, b1); + + currentAuthToken = keyA.key; + + // 调用 getMyStatsSummary + const { response, json } = await callActionsRoute({ + method: "POST", + pathname: "/api/actions/my-usage/getMyStatsSummary", + authToken: keyA.key, + body: { startDate: today, endDate: today }, + }); + + expect(response.status).toBe(200); + expect(json).toMatchObject({ ok: true }); + + const data = (json as any).data as { + totalRequests: number; + totalCost: number; + totalInputTokens: number; + totalOutputTokens: number; + keyModelBreakdown: Array<{ + model: string | null; + requests: number; + cost: number; + inputTokens: number; + outputTokens: number; + }>; + userModelBreakdown: Array<{ + model: string | null; + requests: number; + cost: number; + inputTokens: number; + outputTokens: number; + }>; + currencyCode: string; + }; + + // 验证总计(仅 key A,排除 warmup) + expect(data.totalRequests).toBe(2); // a1, a2 + expect(data.totalInputTokens).toBe(800); // 500 + 300 + expect(data.totalOutputTokens).toBe(300); // 200 + 100 + expect(data.totalCost).toBeCloseTo(0.15, 4); // 0.1 + 0.05 + + // 验证 keyModelBreakdown(仅当前 key A 的数据) + const keyBreakdownMap = new Map(data.keyModelBreakdown.map((r) => [r.model, r])); + expect(keyBreakdownMap.get("claude-3-opus")?.requests).toBe(1); + expect(keyBreakdownMap.get("claude-3-opus")?.cost).toBeCloseTo(0.1, 4); + expect(keyBreakdownMap.get("claude-3-sonnet")?.requests).toBe(1); + expect(keyBreakdownMap.get("claude-3-sonnet")?.cost).toBeCloseTo(0.05, 4); + // warmup 不应出现(blockedBy = 'warmup') + // 其他用户的模型不应出现 + expect(keyBreakdownMap.has("gpt-4")).toBe(false); + + // 验证 userModelBreakdown(用户 A 的所有 key,包括 keyA2) + const userBreakdownMap = new Map(data.userModelBreakdown.map((r) => [r.model, r])); + // claude-3-opus: a1 (0.1) + a2_1 (0.08) = 0.18, requests = 2 + expect(userBreakdownMap.get("claude-3-opus")?.requests).toBe(2); + expect(userBreakdownMap.get("claude-3-opus")?.cost).toBeCloseTo(0.18, 4); + // claude-3-sonnet: a2 only + expect(userBreakdownMap.get("claude-3-sonnet")?.requests).toBe(1); + // 其他用户的模型不应出现 + expect(userBreakdownMap.has("gpt-4")).toBe(false); + + // 验证 currencyCode 存在 + expect(data.currencyCode).toBeDefined(); + }); + + test("getMyStatsSummary:日期范围过滤", async () => { + const unique = `stats-date-${Date.now()}-${Math.random().toString(16).slice(2)}`; + + const user = await createTestUser(`Test ${unique}`); + createdUserIds.push(user.id); + const key = await createTestKey({ + userId: user.id, + key: `test-stats-date-key-${unique}`, + name: `stats-date-${unique}`, + canLoginWebUi: false, + }); + createdKeyIds.push(key.id); + + const today = new Date(); + const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000); + const todayStr = today.toISOString().split("T")[0]; + const yesterdayStr = yesterday.toISOString().split("T")[0]; + + // 昨天的请求 + const m1 = await createMessage({ + userId: user.id, + key: key.key, + model: "old-model", + endpoint: "/v1/messages", + costUsd: "0.0100", + inputTokens: 100, + outputTokens: 50, + createdAt: yesterday, + }); + + // 今天的请求 + const m2 = await createMessage({ + userId: user.id, + key: key.key, + model: "new-model", + endpoint: "/v1/messages", + costUsd: "0.0200", + inputTokens: 200, + outputTokens: 100, + createdAt: today, + }); + + createdMessageIds.push(m1, m2); + + currentAuthToken = key.key; + + // 仅查询今天 + const todayOnly = await callActionsRoute({ + method: "POST", + pathname: "/api/actions/my-usage/getMyStatsSummary", + authToken: key.key, + body: { startDate: todayStr, endDate: todayStr }, + }); + + expect(todayOnly.response.status).toBe(200); + const todayData = (todayOnly.json as any).data; + expect(todayData.totalRequests).toBe(1); + expect(todayData.keyModelBreakdown.length).toBe(1); + expect(todayData.keyModelBreakdown[0].model).toBe("new-model"); + + // 查询昨天到今天 + const bothDays = await callActionsRoute({ + method: "POST", + pathname: "/api/actions/my-usage/getMyStatsSummary", + authToken: key.key, + body: { startDate: yesterdayStr, endDate: todayStr }, + }); + + expect(bothDays.response.status).toBe(200); + const bothData = (bothDays.json as any).data; + expect(bothData.totalRequests).toBe(2); + expect(bothData.keyModelBreakdown.length).toBe(2); + }); }); From 8f986d96d6090a1ec1b9f5478de52726592b87f6 Mon Sep 17 00:00:00 2001 From: Ding <44717411+ding113@users.noreply.github.com> Date: Fri, 9 Jan 2026 16:20:21 +0800 Subject: [PATCH 03/17] feat(error-rules): add rule for "Too much media" error (#572) Add new error rule to handle Claude API's media count limit error when total media (document pages + images) exceeds 100. - Pattern: "Too much media" (contains match) - Category: media_limit - Priority: 79 (after PDF limit rules) - Override message explains the limit and suggests reducing media count Closes #571 --- src/repository/error-rules.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/repository/error-rules.ts b/src/repository/error-rules.ts index a60a26e56..1a4e41a2b 100644 --- a/src/repository/error-rules.ts +++ b/src/repository/error-rules.ts @@ -640,6 +640,23 @@ const DEFAULT_ERROR_RULES = [ }, }, }, + // Issue #571: Total media count (document pages + images) exceeds limit + { + pattern: "Too much media", + category: "media_limit", + description: "Total media count (document pages + images) exceeds API limit", + matchType: "contains" as const, + isDefault: true, + isEnabled: true, + priority: 79, + overrideResponse: { + type: "error", + error: { + type: "media_limit", + message: "媒体数量超过限制(文档页数 + 图片数量 > 100),请减少图片或文档页数后重试", + }, + }, + }, { pattern: "thinking.*format.*invalid|Expected.*thinking.*but found|clear_thinking.*requires.*thinking.*enabled", From 33c5247e3443fb7ddd8c9e3109d5500d00856f6a Mon Sep 17 00:00:00 2001 From: Ding <44717411+ding113@users.noreply.github.com> Date: Fri, 9 Jan 2026 20:51:21 +0800 Subject: [PATCH 04/17] feat: port FluxFix response fixer (#570) * feat: port FluxFix response fixer * fix: polish response fixer and usage date parsing --- biome.json | 2 +- drizzle/0050_flippant_jack_flag.sql | 2 + drizzle/meta/0050_snapshot.json | 2352 +++++++++++++++++ drizzle/meta/_journal.json | 7 + messages/en/settings.json | 8 + messages/ja/settings.json | 8 + messages/ru/settings.json | 8 + messages/zh-CN/settings.json | 8 + messages/zh-TW/settings.json | 8 + src/actions/my-usage.ts | 31 +- src/actions/system-config.ts | 6 +- .../session-messages-client-actions.test.tsx | 16 +- .../session-messages-client.test.tsx | 5 +- .../_components/session-messages-client.tsx | 4 + .../_components/collapsible-quota-card.tsx | 12 - .../_components/system-settings-form.tsx | 90 + src/app/[locale]/settings/config/page.tsx | 2 + src/app/v1/_lib/proxy/forwarder.ts | 6 +- .../response-fixer/encoding-fixer.test.ts | 59 + .../proxy/response-fixer/encoding-fixer.ts | 95 + src/app/v1/_lib/proxy/response-fixer/index.ts | 558 ++++ .../proxy/response-fixer/json-fixer.test.ts | 87 + .../_lib/proxy/response-fixer/json-fixer.ts | 185 ++ .../response-fixer/response-fixer.test.ts | 183 ++ .../proxy/response-fixer/sse-fixer.test.ts | 81 + .../v1/_lib/proxy/response-fixer/sse-fixer.ts | 342 +++ src/app/v1/_lib/proxy/response-fixer/types.ts | 5 + src/app/v1/_lib/proxy/response-handler.ts | 23 +- src/drizzle/schema.ts | 13 + src/lib/config/system-settings-cache.ts | 15 +- src/lib/validation/schemas.ts | 17 + src/repository/_shared/transformers.ts | 15 +- src/repository/system-config.ts | 24 + src/types/special-settings.ts | 15 +- src/types/system-config.ts | 16 + .../integration/billing-model-source.test.ts | 8 + .../lib/config/system-settings-cache.test.ts | 8 + tests/unit/proxy/session.test.ts | 8 + 38 files changed, 4298 insertions(+), 34 deletions(-) create mode 100644 drizzle/0050_flippant_jack_flag.sql create mode 100644 drizzle/meta/0050_snapshot.json create mode 100644 src/app/v1/_lib/proxy/response-fixer/encoding-fixer.test.ts create mode 100644 src/app/v1/_lib/proxy/response-fixer/encoding-fixer.ts create mode 100644 src/app/v1/_lib/proxy/response-fixer/index.ts create mode 100644 src/app/v1/_lib/proxy/response-fixer/json-fixer.test.ts create mode 100644 src/app/v1/_lib/proxy/response-fixer/json-fixer.ts create mode 100644 src/app/v1/_lib/proxy/response-fixer/response-fixer.test.ts create mode 100644 src/app/v1/_lib/proxy/response-fixer/sse-fixer.test.ts create mode 100644 src/app/v1/_lib/proxy/response-fixer/sse-fixer.ts create mode 100644 src/app/v1/_lib/proxy/response-fixer/types.ts diff --git a/biome.json b/biome.json index 107110c0f..87362d2ac 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.3.11/schema.json", + "$schema": "https://biomejs.dev/schemas/2.3.10/schema.json", "vcs": { "enabled": true, "clientKind": "git", diff --git a/drizzle/0050_flippant_jack_flag.sql b/drizzle/0050_flippant_jack_flag.sql new file mode 100644 index 000000000..505093d5f --- /dev/null +++ b/drizzle/0050_flippant_jack_flag.sql @@ -0,0 +1,2 @@ +ALTER TABLE "system_settings" ADD COLUMN "enable_response_fixer" boolean DEFAULT true NOT NULL;--> statement-breakpoint +ALTER TABLE "system_settings" ADD COLUMN "response_fixer_config" jsonb DEFAULT '{"fixTruncatedJson":true,"fixSseFormat":true,"fixEncoding":true,"maxJsonDepth":200,"maxFixSize":1048576}'::jsonb; \ No newline at end of file diff --git a/drizzle/meta/0050_snapshot.json b/drizzle/meta/0050_snapshot.json new file mode 100644 index 000000000..cd675aa78 --- /dev/null +++ b/drizzle/meta/0050_snapshot.json @@ -0,0 +1,2352 @@ +{ + "id": "708bddf0-5d35-4367-ab3f-a50a84d57c5d", + "prevId": "9805fe46-f6bb-4a4e-8948-7f0785cdc3b4", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.error_rules": { + "name": "error_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'regex'" + }, + "category": { + "name": "category", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "override_response": { + "name": "override_response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "override_status_code": { + "name": "override_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_error_rules_enabled": { + "name": "idx_error_rules_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unique_pattern": { + "name": "unique_pattern", + "columns": [ + { + "expression": "pattern", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_category": { + "name": "idx_category", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_match_type": { + "name": "idx_match_type", + "columns": [ + { + "expression": "match_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.keys": { + "name": "keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "can_login_web_ui": { + "name": "can_login_web_ui", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_daily_usd": { + "name": "limit_daily_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "provider_group": { + "name": "provider_group", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'default'" + }, + "cache_ttl_preference": { + "name": "cache_ttl_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_keys_user_id": { + "name": "idx_keys_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_created_at": { + "name": "idx_keys_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_deleted_at": { + "name": "idx_keys_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message_request": { + "name": "message_request", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "numeric(21, 15)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "request_sequence": { + "name": "request_sequence", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "provider_chain": { + "name": "provider_chain", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "api_type": { + "name": "api_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "endpoint": { + "name": "endpoint", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "original_model": { + "name": "original_model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ttfb_ms": { + "name": "ttfb_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_creation_input_tokens": { + "name": "cache_creation_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_read_input_tokens": { + "name": "cache_read_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_creation_5m_input_tokens": { + "name": "cache_creation_5m_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_creation_1h_input_tokens": { + "name": "cache_creation_1h_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_applied": { + "name": "cache_ttl_applied", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "context_1m_applied": { + "name": "context_1m_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "special_settings": { + "name": "special_settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_stack": { + "name": "error_stack", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_cause": { + "name": "error_cause", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "blocked_by": { + "name": "blocked_by", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "blocked_reason": { + "name": "blocked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "messages_count": { + "name": "messages_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_message_request_user_date_cost": { + "name": "idx_message_request_user_date_cost", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_query": { + "name": "idx_message_request_user_query", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_id": { + "name": "idx_message_request_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_seq": { + "name": "idx_message_request_session_seq", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_sequence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_endpoint": { + "name": "idx_message_request_endpoint", + "columns": [ + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_blocked_by": { + "name": "idx_message_request_blocked_by", + "columns": [ + { + "expression": "blocked_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_provider_id": { + "name": "idx_message_request_provider_id", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_id": { + "name": "idx_message_request_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key": { + "name": "idx_message_request_key", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_created_at": { + "name": "idx_message_request_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_deleted_at": { + "name": "idx_message_request_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model_prices": { + "name": "model_prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "model_name": { + "name": "model_name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "price_data": { + "name": "price_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_model_prices_latest": { + "name": "idx_model_prices_latest", + "columns": [ + { + "expression": "model_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_model_name": { + "name": "idx_model_prices_model_name", + "columns": [ + { + "expression": "model_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_created_at": { + "name": "idx_model_prices_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_settings": { + "name": "notification_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "use_legacy_mode": { + "name": "use_legacy_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "circuit_breaker_enabled": { + "name": "circuit_breaker_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "circuit_breaker_webhook": { + "name": "circuit_breaker_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "daily_leaderboard_enabled": { + "name": "daily_leaderboard_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "daily_leaderboard_webhook": { + "name": "daily_leaderboard_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "daily_leaderboard_time": { + "name": "daily_leaderboard_time", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false, + "default": "'09:00'" + }, + "daily_leaderboard_top_n": { + "name": "daily_leaderboard_top_n", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "cost_alert_enabled": { + "name": "cost_alert_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cost_alert_webhook": { + "name": "cost_alert_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "cost_alert_threshold": { + "name": "cost_alert_threshold", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": false, + "default": "'0.80'" + }, + "cost_alert_check_interval": { + "name": "cost_alert_check_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 60 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_target_bindings": { + "name": "notification_target_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "notification_type": { + "name": "notification_type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "schedule_cron": { + "name": "schedule_cron", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "schedule_timezone": { + "name": "schedule_timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'Asia/Shanghai'" + }, + "template_override": { + "name": "template_override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "unique_notification_target_binding": { + "name": "unique_notification_target_binding", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notification_bindings_type": { + "name": "idx_notification_bindings_type", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notification_bindings_target": { + "name": "idx_notification_bindings_target", + "columns": [ + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_target_bindings_target_id_webhook_targets_id_fk": { + "name": "notification_target_bindings_target_id_webhook_targets_id_fk", + "tableFrom": "notification_target_bindings", + "tableTo": "webhook_targets", + "columnsFrom": [ + "target_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.providers": { + "name": "providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "weight": { + "name": "weight", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false, + "default": "'1.0'" + }, + "group_tag": { + "name": "group_tag", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "provider_type": { + "name": "provider_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'claude'" + }, + "preserve_client_ip": { + "name": "preserve_client_ip", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "model_redirects": { + "name": "model_redirects", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "allowed_models": { + "name": "allowed_models", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "join_claude_pool": { + "name": "join_claude_pool", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "codex_instructions_strategy": { + "name": "codex_instructions_strategy", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'auto'" + }, + "mcp_passthrough_type": { + "name": "mcp_passthrough_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "mcp_passthrough_url": { + "name": "mcp_passthrough_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_daily_usd": { + "name": "limit_daily_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "total_cost_reset_at": { + "name": "total_cost_reset_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "max_retry_attempts": { + "name": "max_retry_attempts", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "circuit_breaker_failure_threshold": { + "name": "circuit_breaker_failure_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "circuit_breaker_open_duration": { + "name": "circuit_breaker_open_duration", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1800000 + }, + "circuit_breaker_half_open_success_threshold": { + "name": "circuit_breaker_half_open_success_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 2 + }, + "proxy_url": { + "name": "proxy_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "proxy_fallback_to_direct": { + "name": "proxy_fallback_to_direct", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "first_byte_timeout_streaming_ms": { + "name": "first_byte_timeout_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "streaming_idle_timeout_ms": { + "name": "streaming_idle_timeout_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "request_timeout_non_streaming_ms": { + "name": "request_timeout_non_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_preference": { + "name": "cache_ttl_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "context_1m_preference": { + "name": "context_1m_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_reasoning_effort_preference": { + "name": "codex_reasoning_effort_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_reasoning_summary_preference": { + "name": "codex_reasoning_summary_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_text_verbosity_preference": { + "name": "codex_text_verbosity_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "codex_parallel_tool_calls_preference": { + "name": "codex_parallel_tool_calls_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "tpm": { + "name": "tpm", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "rpm": { + "name": "rpm", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "rpd": { + "name": "rpd", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "cc": { + "name": "cc", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_providers_enabled_priority": { + "name": "idx_providers_enabled_priority", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "weight", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_group": { + "name": "idx_providers_group", + "columns": [ + { + "expression": "group_tag", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_created_at": { + "name": "idx_providers_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_deleted_at": { + "name": "idx_providers_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.request_filters": { + "name": "request_filters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "replacement": { + "name": "replacement", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "binding_type": { + "name": "binding_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'global'" + }, + "provider_ids": { + "name": "provider_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "group_tags": { + "name": "group_tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_request_filters_enabled": { + "name": "idx_request_filters_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_scope": { + "name": "idx_request_filters_scope", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_action": { + "name": "idx_request_filters_action", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_binding": { + "name": "idx_request_filters_binding", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "binding_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sensitive_words": { + "name": "sensitive_words", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "word": { + "name": "word", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'contains'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_sensitive_words_enabled": { + "name": "idx_sensitive_words_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "match_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_sensitive_words_created_at": { + "name": "idx_sensitive_words_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_settings": { + "name": "system_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "site_title": { + "name": "site_title", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "default": "'Claude Code Hub'" + }, + "allow_global_usage_view": { + "name": "allow_global_usage_view", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "currency_display": { + "name": "currency_display", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "billing_model_source": { + "name": "billing_model_source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'original'" + }, + "enable_auto_cleanup": { + "name": "enable_auto_cleanup", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "cleanup_retention_days": { + "name": "cleanup_retention_days", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30 + }, + "cleanup_schedule": { + "name": "cleanup_schedule", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'0 2 * * *'" + }, + "cleanup_batch_size": { + "name": "cleanup_batch_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10000 + }, + "enable_client_version_check": { + "name": "enable_client_version_check", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verbose_provider_error": { + "name": "verbose_provider_error", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_http2": { + "name": "enable_http2", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "intercept_anthropic_warmup_requests": { + "name": "intercept_anthropic_warmup_requests", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_response_fixer": { + "name": "enable_response_fixer", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "response_fixer_config": { + "name": "response_fixer_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{\"fixTruncatedJson\":true,\"fixSseFormat\":true,\"fixEncoding\":true,\"maxJsonDepth\":200,\"maxFixSize\":1048576}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "rpm_limit": { + "name": "rpm_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "daily_limit_usd": { + "name": "daily_limit_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "provider_group": { + "name": "provider_group", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'default'" + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "allowed_clients": { + "name": "allowed_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "allowed_models": { + "name": "allowed_models", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_users_active_role_sort": { + "name": "idx_users_active_role_sort", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_enabled_expires_at": { + "name": "idx_users_enabled_expires_at", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_created_at": { + "name": "idx_users_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_deleted_at": { + "name": "idx_users_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_targets": { + "name": "webhook_targets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "webhook_provider_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "webhook_url": { + "name": "webhook_url", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false + }, + "telegram_bot_token": { + "name": "telegram_bot_token", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "dingtalk_secret": { + "name": "dingtalk_secret", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "custom_template": { + "name": "custom_template", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "custom_headers": { + "name": "custom_headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "proxy_url": { + "name": "proxy_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "proxy_fallback_to_direct": { + "name": "proxy_fallback_to_direct", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_test_at": { + "name": "last_test_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_test_result": { + "name": "last_test_result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.daily_reset_mode": { + "name": "daily_reset_mode", + "schema": "public", + "values": [ + "fixed", + "rolling" + ] + }, + "public.notification_type": { + "name": "notification_type", + "schema": "public", + "values": [ + "circuit_breaker", + "daily_leaderboard", + "cost_alert" + ] + }, + "public.webhook_provider_type": { + "name": "webhook_provider_type", + "schema": "public", + "values": [ + "wechat", + "feishu", + "dingtalk", + "telegram", + "custom" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 9f11c2090..e2a568734 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -351,6 +351,13 @@ "when": 1767764802281, "tag": "0049_shocking_ultimatum", "breakpoints": true + }, + { + "idx": 50, + "version": "7", + "when": 1767894561890, + "tag": "0050_flippant_jack_flag", + "breakpoints": true } ] } \ No newline at end of file diff --git a/messages/en/settings.json b/messages/en/settings.json index 57cb1a097..388c99495 100644 --- a/messages/en/settings.json +++ b/messages/en/settings.json @@ -104,6 +104,14 @@ "enableHttp2Desc": "When enabled, proxy requests will prefer HTTP/2 protocol. Automatically falls back to HTTP/1.1 on failure.", "interceptAnthropicWarmupRequests": "Intercept Warmup Requests (Anthropic)", "interceptAnthropicWarmupRequestsDesc": "When enabled, Claude Code warmup probe requests will be answered by CCH directly to avoid upstream provider calls; the request is logged for audit but is not billed, not rate-limited, and excluded from statistics.", + "enableResponseFixer": "Enable Response Fixer", + "enableResponseFixerDesc": "Automatically repairs common upstream response issues (encoding, SSE, truncated JSON). Enabled by default.", + "responseFixerFixEncoding": "Fix encoding issues", + "responseFixerFixEncodingDesc": "Removes BOM/null bytes and normalizes invalid UTF-8.", + "responseFixerFixSseFormat": "Fix SSE format", + "responseFixerFixSseFormatDesc": "Adds missing data: prefix, normalizes line endings, and fixes common field formatting.", + "responseFixerFixTruncatedJson": "Fix truncated JSON", + "responseFixerFixTruncatedJsonDesc": "Closes unclosed braces/quotes, removes trailing commas, and fills missing values with null when needed.", "cleanupSchedule": "Cleanup Schedule", "cleanupScheduleDesc": "Select the execution schedule for automatic cleanup", "configUpdated": "System settings updated. The page will refresh to apply currency display changes.", diff --git a/messages/ja/settings.json b/messages/ja/settings.json index f3464d831..571f8f063 100644 --- a/messages/ja/settings.json +++ b/messages/ja/settings.json @@ -102,6 +102,14 @@ "verboseProviderErrorDesc": "有効にすると、すべてのプロバイダーが利用不可の場合に詳細なエラーメッセージ(プロバイダー数、レート制限の理由など)を返します。無効の場合は簡潔なエラーコードのみを返します。", "interceptAnthropicWarmupRequests": "Warmup リクエストを遮断(Anthropic)", "interceptAnthropicWarmupRequestsDesc": "有効にすると、Claude Code の Warmup プローブ要求は CCH が直接短い応答を返し、上流プロバイダーへのリクエストを回避します。ログには残りますが、課金/レート制限/統計には含まれません。", + "enableResponseFixer": "レスポンス整流を有効化", + "enableResponseFixerDesc": "上流応答の一般的な形式問題(エンコーディング、SSE、途切れた JSON)を自動修復します(既定で有効)。", + "responseFixerFixEncoding": "エンコーディングを修復", + "responseFixerFixEncodingDesc": "BOM/NULL バイトを除去し、無効な UTF-8 を正規化します。", + "responseFixerFixSseFormat": "SSE 形式を修復", + "responseFixerFixSseFormatDesc": "不足している data: 前置きを補い、改行を正規化し、よくあるフィールド形式を修正します。", + "responseFixerFixTruncatedJson": "途切れた JSON を修復", + "responseFixerFixTruncatedJsonDesc": "未閉じの括弧/引用符を補い、末尾カンマを除去し、必要に応じて null を補完します。", "cleanupSchedule": "クリーンアップスケジュール", "cleanupScheduleDesc": "自動クリーンアップの実行スケジュールを選択します", "configUpdated": "システム設定が更新されました。ページが更新され、通貨表示の変更が適用されます。", diff --git a/messages/ru/settings.json b/messages/ru/settings.json index afbc5940e..efb1967f8 100644 --- a/messages/ru/settings.json +++ b/messages/ru/settings.json @@ -102,6 +102,14 @@ "verboseProviderErrorDesc": "При включении возвращает подробные сообщения об ошибках при недоступности всех провайдеров (количество провайдеров, причины ограничений и т.д.); при отключении возвращает только простой код ошибки.", "interceptAnthropicWarmupRequests": "Перехватывать Warmup-запросы (Anthropic)", "interceptAnthropicWarmupRequestsDesc": "Если включено, Warmup-пробные запросы Claude Code будут отвечены самим CCH без обращения к провайдерам; запрос сохраняется в логах, но не тарифицируется, не учитывается в лимитах и исключается из статистики.", + "enableResponseFixer": "Включить исправление ответов", + "enableResponseFixerDesc": "Автоматически исправляет распространённые проблемы ответа у провайдеров (кодировка, SSE, обрезанный JSON). Включено по умолчанию.", + "responseFixerFixEncoding": "Исправлять кодировку", + "responseFixerFixEncodingDesc": "Удаляет BOM/нулевые байты и нормализует невалидный UTF-8.", + "responseFixerFixSseFormat": "Исправлять формат SSE", + "responseFixerFixSseFormatDesc": "Добавляет отсутствующий префикс data:, нормализует переводы строк и исправляет распространённые поля.", + "responseFixerFixTruncatedJson": "Исправлять обрезанный JSON", + "responseFixerFixTruncatedJsonDesc": "Закрывает незакрытые скобки/кавычки, удаляет завершающие запятые и при необходимости дополняет null.", "cleanupSchedule": "График очистки", "cleanupScheduleDesc": "Выбрать расписание автоматической очистки", "configUpdated": "Параметры системы обновлены. Страница обновится для применения изменений валюты.", diff --git a/messages/zh-CN/settings.json b/messages/zh-CN/settings.json index fed1cfe4f..942619612 100644 --- a/messages/zh-CN/settings.json +++ b/messages/zh-CN/settings.json @@ -85,6 +85,14 @@ "enableHttp2Desc": "启用后,代理请求将优先使用 HTTP/2 协议。如果 HTTP/2 失败,将自动降级到 HTTP/1.1。", "interceptAnthropicWarmupRequests": "拦截 Warmup 请求(Anthropic)", "interceptAnthropicWarmupRequestsDesc": "开启后,识别到 Claude Code 的 Warmup 探测请求将由 CCH 直接抢答短响应,避免访问上游供应商;该请求会记录在日志中,但不计费、不限流、不计入统计。", + "enableResponseFixer": "启用响应整流", + "enableResponseFixerDesc": "自动修复上游响应中常见的编码、SSE 与 JSON 格式问题(默认开启)。", + "responseFixerFixEncoding": "修复编码问题", + "responseFixerFixEncodingDesc": "移除 BOM 与空字节,并对无效 UTF-8 做兼容处理。", + "responseFixerFixSseFormat": "修复 SSE 格式", + "responseFixerFixSseFormatDesc": "补齐 data: 前缀、统一换行符,并修复常见字段格式。", + "responseFixerFixTruncatedJson": "修复截断的 JSON", + "responseFixerFixTruncatedJsonDesc": "补齐未闭合的括号/引号,移除尾随逗号,必要时补 null。", "saveSettings": "保存设置", "keepDays": "保留天数", "keepDaysDesc": "清理超过此天数的历史日志", diff --git a/messages/zh-TW/settings.json b/messages/zh-TW/settings.json index bce5b5019..4ee371969 100644 --- a/messages/zh-TW/settings.json +++ b/messages/zh-TW/settings.json @@ -102,6 +102,14 @@ "verboseProviderErrorDesc": "開啟後,當所有供應商不可用時返回詳細錯誤資訊(包含供應商數量、限流原因等);關閉後僅返回簡潔錯誤碼。", "interceptAnthropicWarmupRequests": "攔截 Warmup 請求(Anthropic)", "interceptAnthropicWarmupRequestsDesc": "開啟後,識別到 Claude Code 的 Warmup 探測請求將由 CCH 直接搶答短回應,避免存取上游供應商;該請求會記錄在日誌中,但不計費、不限流、不計入統計。", + "enableResponseFixer": "啟用回應整流", + "enableResponseFixerDesc": "自動修復上游回應中常見的編碼、SSE 與 JSON 格式問題(預設開啟)。", + "responseFixerFixEncoding": "修復編碼問題", + "responseFixerFixEncodingDesc": "移除 BOM 與空字節,並對無效 UTF-8 做相容處理。", + "responseFixerFixSseFormat": "修復 SSE 格式", + "responseFixerFixSseFormatDesc": "補齊 data: 前綴、統一換行符,並修復常見欄位格式。", + "responseFixerFixTruncatedJson": "修復截斷的 JSON", + "responseFixerFixTruncatedJsonDesc": "補齊未閉合的括號/引號,移除尾隨逗號,必要時補 null。", "cleanupSchedule": "清理週期", "cleanupScheduleDesc": "選擇自動清理的執行週期", "configUpdated": "系統設定已更新,頁面將重新整理以應用貨幣顯示變更。", diff --git a/src/actions/my-usage.ts b/src/actions/my-usage.ts index 1f10f4278..17252e516 100644 --- a/src/actions/my-usage.ts +++ b/src/actions/my-usage.ts @@ -395,12 +395,21 @@ export async function getMyUsageLogs( const pageSize = Math.min(rawPageSize, 100); const page = filters.page && filters.page > 0 ? filters.page : 1; + const parsedStart = filters.startDate + ? new Date(`${filters.startDate}T00:00:00`).getTime() + : Number.NaN; + const parsedEnd = filters.endDate + ? new Date(`${filters.endDate}T00:00:00`).getTime() + : Number.NaN; + + const startTime = Number.isFinite(parsedStart) ? parsedStart : undefined; + // endTime 使用“次日零点”作为排他上界(created_at < endTime),避免 23:59:59.999 的边界问题 + const endTime = Number.isFinite(parsedEnd) ? parsedEnd + 24 * 60 * 60 * 1000 : undefined; + const usageFilters: UsageLogFilters = { keyId: session.key.id, - startTime: filters.startDate - ? new Date(`${filters.startDate}T00:00:00`).getTime() - : undefined, - endTime: filters.endDate ? new Date(`${filters.endDate}T23:59:59.999`).getTime() : undefined, + startTime, + endTime, model: filters.model, statusCode: filters.statusCode, excludeStatusCode200: filters.excludeStatusCode200, @@ -532,12 +541,16 @@ export async function getMyStatsSummary( const settings = await getSystemSettings(); const currencyCode = settings.currencyDisplay; - const startTime = filters.startDate + const parsedStart = filters.startDate ? new Date(`${filters.startDate}T00:00:00`).getTime() - : undefined; - const endTime = filters.endDate - ? new Date(`${filters.endDate}T23:59:59.999`).getTime() - : undefined; + : Number.NaN; + const parsedEnd = filters.endDate + ? new Date(`${filters.endDate}T00:00:00`).getTime() + : Number.NaN; + + const startTime = Number.isFinite(parsedStart) ? parsedStart : undefined; + // endTime 使用“次日零点”作为排他上界(created_at < endTime),避免 23:59:59.999 的边界问题 + const endTime = Number.isFinite(parsedEnd) ? parsedEnd + 24 * 60 * 60 * 1000 : undefined; // Get aggregated stats using existing repository function const stats = await findUsageLogsStats({ diff --git a/src/actions/system-config.ts b/src/actions/system-config.ts index f79d636ab..7ef36cdd2 100644 --- a/src/actions/system-config.ts +++ b/src/actions/system-config.ts @@ -6,7 +6,7 @@ import { invalidateSystemSettingsCache } from "@/lib/config"; import { logger } from "@/lib/logger"; import { UpdateSystemSettingsSchema } from "@/lib/validation/schemas"; import { getSystemSettings, updateSystemSettings } from "@/repository/system-config"; -import type { SystemSettings } from "@/types/system-config"; +import type { ResponseFixerConfig, SystemSettings } from "@/types/system-config"; import type { ActionResult } from "./types"; export async function fetchSystemSettings(): Promise> { @@ -38,6 +38,8 @@ export async function saveSystemSettings(formData: { verboseProviderError?: boolean; enableHttp2?: boolean; interceptAnthropicWarmupRequests?: boolean; + enableResponseFixer?: boolean; + responseFixerConfig?: Partial; }): Promise> { try { const session = await getSession(); @@ -59,6 +61,8 @@ export async function saveSystemSettings(formData: { verboseProviderError: validated.verboseProviderError, enableHttp2: validated.enableHttp2, interceptAnthropicWarmupRequests: validated.interceptAnthropicWarmupRequests, + enableResponseFixer: validated.enableResponseFixer, + responseFixerConfig: validated.responseFixerConfig, }); // Invalidate the system settings cache so proxy requests get fresh settings diff --git a/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client-actions.test.tsx b/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client-actions.test.tsx index 9611f1456..50b64654c 100644 --- a/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client-actions.test.tsx +++ b/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client-actions.test.tsx @@ -75,7 +75,21 @@ vi.mock("./request-list-sidebar", () => { vi.mock("./session-details-tabs", () => { return { - SessionMessagesDetailsTabs: () =>
, + SessionMessagesDetailsTabs: (props: { + response: string | null; + onCopyResponse?: () => void; + isResponseCopied?: boolean; + }) => { + return ( +
+ {props.response && props.onCopyResponse ? ( + + ) : null} +
+ ); + }, }; }); diff --git a/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client.test.tsx b/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client.test.tsx index d5f257b2e..1c3465276 100644 --- a/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client.test.tsx +++ b/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client.test.tsx @@ -21,6 +21,7 @@ const messages = { responseBody: "Response Body", noHeaders: "No data", noData: "No Data", + storageTip: "Storage Tip", }, codeDisplay: { raw: "Raw", @@ -187,13 +188,13 @@ describe("SessionMessagesDetailsTabs", () => { /> ); - expect(container.textContent).toContain("No Data"); + expect(container.textContent).toContain("Storage Tip"); const requestHeadersTrigger = container.querySelector( "[data-testid='session-tab-trigger-request-headers']" ) as HTMLElement; click(requestHeadersTrigger); - expect(container.textContent).toContain("No data"); + expect(container.textContent).toContain("Storage Tip"); const specialSettingsTrigger = container.querySelector( "[data-testid='session-tab-trigger-special-settings']" diff --git a/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client.tsx b/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client.tsx index 780420c6b..aba97ea85 100644 --- a/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client.tsx +++ b/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client.tsx @@ -373,12 +373,14 @@ export function SessionMessagesClient() { size="icon" className="h-8 w-8" onClick={handleCopyRequest} + aria-label={t("actions.copyMessages")} > {copiedRequest ? ( ) : ( )} + {t("actions.copyMessages")} {t("actions.copyMessages")} @@ -393,8 +395,10 @@ export function SessionMessagesClient() { size="icon" className="h-8 w-8" onClick={handleDownloadRequest} + aria-label={t("actions.downloadMessages")} > + {t("actions.downloadMessages")} {t("actions.downloadMessages")} diff --git a/src/app/[locale]/my-usage/_components/collapsible-quota-card.tsx b/src/app/[locale]/my-usage/_components/collapsible-quota-card.tsx index 2a22f4ebe..a8cce1838 100644 --- a/src/app/[locale]/my-usage/_components/collapsible-quota-card.tsx +++ b/src/app/[locale]/my-usage/_components/collapsible-quota-card.tsx @@ -31,26 +31,14 @@ export function CollapsibleQuotaCard({ const t = useTranslations("myUsage.quotaCollapsible"); // Calculate summary metrics - const keyDailyPct = calculateUsagePercent( - quota?.keyCurrentDailyUsd ?? 0, - quota?.keyLimitDailyUsd ?? null - ); const userDailyPct = calculateUsagePercent( quota?.userCurrentDailyUsd ?? 0, quota?.userLimitDailyUsd ?? null ); - const keyMonthlyPct = calculateUsagePercent( - quota?.keyCurrentMonthlyUsd ?? 0, - quota?.keyLimitMonthlyUsd ?? null - ); const userMonthlyPct = calculateUsagePercent( quota?.userCurrentMonthlyUsd ?? 0, quota?.userLimitMonthlyUsd ?? null ); - const keyTotalPct = calculateUsagePercent( - quota?.keyCurrentTotalUsd ?? 0, - quota?.keyLimitTotalUsd ?? null - ); const userTotalPct = calculateUsagePercent( quota?.userCurrentTotalUsd ?? 0, quota?.userLimitTotalUsd ?? null diff --git a/src/app/[locale]/settings/config/_components/system-settings-form.tsx b/src/app/[locale]/settings/config/_components/system-settings-form.tsx index 8210c2b80..719565c3a 100644 --- a/src/app/[locale]/settings/config/_components/system-settings-form.tsx +++ b/src/app/[locale]/settings/config/_components/system-settings-form.tsx @@ -30,6 +30,8 @@ interface SystemSettingsFormProps { | "verboseProviderError" | "enableHttp2" | "interceptAnthropicWarmupRequests" + | "enableResponseFixer" + | "responseFixerConfig" >; } @@ -54,6 +56,12 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps) const [interceptAnthropicWarmupRequests, setInterceptAnthropicWarmupRequests] = useState( initialSettings.interceptAnthropicWarmupRequests ); + const [enableResponseFixer, setEnableResponseFixer] = useState( + initialSettings.enableResponseFixer + ); + const [responseFixerConfig, setResponseFixerConfig] = useState( + initialSettings.responseFixerConfig + ); const [isPending, startTransition] = useTransition(); const handleSubmit = (event: React.FormEvent) => { @@ -73,6 +81,8 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps) verboseProviderError, enableHttp2, interceptAnthropicWarmupRequests, + enableResponseFixer, + responseFixerConfig, }); if (!result.ok) { @@ -88,6 +98,8 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps) setVerboseProviderError(result.data.verboseProviderError); setEnableHttp2(result.data.enableHttp2); setInterceptAnthropicWarmupRequests(result.data.interceptAnthropicWarmupRequests); + setEnableResponseFixer(result.data.enableResponseFixer); + setResponseFixerConfig(result.data.responseFixerConfig); } toast.success(t("configUpdated")); @@ -215,6 +227,84 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps) />
+
+
+
+ +

{t("enableResponseFixerDesc")}

+
+ setEnableResponseFixer(checked)} + disabled={isPending} + /> +
+ + {enableResponseFixer && ( +
+
+
+ +

+ {t("responseFixerFixEncodingDesc")} +

+
+ + setResponseFixerConfig((prev) => ({ ...prev, fixEncoding: checked })) + } + disabled={isPending} + /> +
+ +
+
+ +

+ {t("responseFixerFixSseFormatDesc")} +

+
+ + setResponseFixerConfig((prev) => ({ ...prev, fixSseFormat: checked })) + } + disabled={isPending} + /> +
+ +
+
+ +

+ {t("responseFixerFixTruncatedJsonDesc")} +

+
+ + setResponseFixerConfig((prev) => ({ ...prev, fixTruncatedJson: checked })) + } + disabled={isPending} + /> +
+
+ )} +
+
diff --git a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx index 0f27deffe..2638493fe 100644 --- a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx +++ b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx @@ -578,6 +578,7 @@ export function VirtualizedLogsTable({ messagesCount={log.messagesCount} endpoint={log.endpoint} billingModelSource={billingModelSource} + specialSettings={log.specialSettings} inputTokens={log.inputTokens} outputTokens={log.outputTokens} cacheCreationInputTokens={log.cacheCreationInputTokens} diff --git a/src/lib/utils/special-settings.ts b/src/lib/utils/special-settings.ts new file mode 100644 index 000000000..b66cb07a7 --- /dev/null +++ b/src/lib/utils/special-settings.ts @@ -0,0 +1,126 @@ +import { CONTEXT_1M_BETA_HEADER } from "@/lib/special-attributes"; +import type { SpecialSetting } from "@/types/special-settings"; + +type BuildUnifiedSpecialSettingsParams = { + /** + * 已有 specialSettings(通常来自 DB special_settings 或 Session Redis 缓存) + */ + existing?: SpecialSetting[] | null; + /** + * 拦截类型(如 warmup / sensitive_word) + */ + blockedBy?: string | null; + /** + * 拦截原因(通常为 JSON 字符串) + */ + blockedReason?: string | null; + /** + * HTTP 状态码(用于补齐守卫拦截信息) + */ + statusCode?: number | null; + /** + * Cache TTL 实际应用值(用于展示 TTL/标头覆写命中) + */ + cacheTtlApplied?: string | null; + /** + * 1M 上下文是否应用(用于展示 1M 标头覆写命中) + */ + context1mApplied?: boolean | null; +}; + +function buildSettingKey(setting: SpecialSetting): string { + switch (setting.type) { + case "provider_parameter_override": + return JSON.stringify([ + setting.type, + setting.providerId ?? null, + setting.providerType ?? null, + setting.hit, + setting.changed, + [...setting.changes] + .map((change) => [change.path, change.before, change.after, change.changed] as const) + .sort((a, b) => a[0].localeCompare(b[0])), + ]); + case "response_fixer": + return JSON.stringify([ + setting.type, + setting.hit, + [...setting.fixersApplied] + .map((fixer) => [fixer.fixer, fixer.applied] as const) + .sort((a, b) => a[0].localeCompare(b[0])), + ]); + case "guard_intercept": + return JSON.stringify([setting.type, setting.guard, setting.action, setting.statusCode]); + case "anthropic_cache_ttl_header_override": + return JSON.stringify([setting.type, setting.ttl]); + case "anthropic_context_1m_header_override": + return JSON.stringify([setting.type, setting.header, setting.flag]); + default: { + // 兜底:保证即使未来扩展类型也不会导致运行时崩溃 + const _exhaustive: never = setting; + return JSON.stringify(_exhaustive); + } + } +} + +/** + * 构建“统一特殊设置”展示数据 + * + * 目标:把 DB 字段(blockedBy/cacheTtlApplied/context1mApplied)与既有 special_settings 合并, + * 统一在以下位置展示:日志列表/日志详情弹窗/Session 详情页。 + */ +export function buildUnifiedSpecialSettings( + params: BuildUnifiedSpecialSettingsParams +): SpecialSetting[] | null { + const base = params.existing ?? []; + const derived: SpecialSetting[] = []; + + if (params.blockedBy) { + const guard = params.blockedBy; + const action = guard === "warmup" ? "intercept_response" : "block_request"; + + derived.push({ + type: "guard_intercept", + scope: "guard", + hit: true, + guard, + action, + statusCode: params.statusCode ?? null, + reason: params.blockedReason ?? null, + }); + } + + if (params.cacheTtlApplied) { + derived.push({ + type: "anthropic_cache_ttl_header_override", + scope: "request_header", + hit: true, + ttl: params.cacheTtlApplied, + }); + } + + if (params.context1mApplied === true) { + derived.push({ + type: "anthropic_context_1m_header_override", + scope: "request_header", + hit: true, + header: "anthropic-beta", + flag: CONTEXT_1M_BETA_HEADER, + }); + } + + if (base.length === 0 && derived.length === 0) { + return null; + } + + const seen = new Set(); + const result: SpecialSetting[] = []; + for (const item of [...base, ...derived]) { + const key = buildSettingKey(item); + if (seen.has(key)) continue; + seen.add(key); + result.push(item); + } + + return result.length > 0 ? result : null; +} diff --git a/src/repository/message.ts b/src/repository/message.ts index 2bdacf5c8..50255c685 100644 --- a/src/repository/message.ts +++ b/src/repository/message.ts @@ -6,6 +6,7 @@ import { keys as keysTable, messageRequest, providers, users } from "@/drizzle/s import { getEnvConfig } from "@/lib/config/env.schema"; import { formatCostForStorage } from "@/lib/utils/currency"; import type { CreateMessageRequestData, MessageRequest } from "@/types/message"; +import type { SpecialSetting } from "@/types/special-settings"; import { EXCLUDE_WARMUP_CONDITION } from "./_shared/message-request-conditions"; import { toMessageRequest } from "./_shared/transformers"; import { enqueueMessageRequestUpdate } from "./message-write-buffer"; @@ -270,6 +271,52 @@ export async function findMessageRequestBySessionId( return toMessageRequest(result); } +/** + * 按 (sessionId, requestSequence) 获取请求的审计字段(用于 Session 详情页补齐特殊设置展示) + */ +export async function findMessageRequestAuditBySessionIdAndSequence( + sessionId: string, + requestSequence: number +): Promise<{ + statusCode: number | null; + blockedBy: string | null; + blockedReason: string | null; + cacheTtlApplied: string | null; + context1mApplied: boolean | null; + specialSettings: SpecialSetting[] | null; +} | null> { + const [row] = await db + .select({ + statusCode: messageRequest.statusCode, + blockedBy: messageRequest.blockedBy, + blockedReason: messageRequest.blockedReason, + cacheTtlApplied: messageRequest.cacheTtlApplied, + context1mApplied: messageRequest.context1mApplied, + specialSettings: messageRequest.specialSettings, + }) + .from(messageRequest) + .where( + and( + eq(messageRequest.sessionId, sessionId), + eq(messageRequest.requestSequence, requestSequence), + isNull(messageRequest.deletedAt) + ) + ) + .limit(1); + + if (!row) return null; + return { + statusCode: row.statusCode, + blockedBy: row.blockedBy, + blockedReason: row.blockedReason, + cacheTtlApplied: row.cacheTtlApplied, + context1mApplied: row.context1mApplied, + specialSettings: Array.isArray(row.specialSettings) + ? (row.specialSettings as SpecialSetting[]) + : null, + }; +} + /** * 聚合查询指定 session 的所有请求数据 * 返回总成本、总 Token、请求次数、供应商列表等 diff --git a/src/repository/usage-logs.ts b/src/repository/usage-logs.ts index 7d9f8dcde..1098107ce 100644 --- a/src/repository/usage-logs.ts +++ b/src/repository/usage-logs.ts @@ -3,6 +3,7 @@ import { and, desc, eq, isNull, sql } from "drizzle-orm"; import { db } from "@/drizzle/db"; import { keys as keysTable, messageRequest, providers, users } from "@/drizzle/schema"; +import { buildUnifiedSpecialSettings } from "@/lib/utils/special-settings"; import type { ProviderChainItem } from "@/types/message"; import type { SpecialSetting } from "@/types/special-settings"; import { EXCLUDE_WARMUP_CONDITION } from "./_shared/message-request-conditions"; @@ -241,6 +242,19 @@ export async function findUsageLogsBatch( (row.cacheCreationInputTokens ?? 0) + (row.cacheReadInputTokens ?? 0); + const existingSpecialSettings = Array.isArray(row.specialSettings) + ? (row.specialSettings as SpecialSetting[]) + : null; + + const unifiedSpecialSettings = buildUnifiedSpecialSettings({ + existing: existingSpecialSettings, + blockedBy: row.blockedBy, + blockedReason: row.blockedReason, + statusCode: row.statusCode, + cacheTtlApplied: row.cacheTtlApplied, + context1mApplied: row.context1mApplied, + }); + return { ...row, requestSequence: row.requestSequence ?? null, @@ -251,6 +265,7 @@ export async function findUsageLogsBatch( costUsd: row.costUsd?.toString() ?? null, providerChain: row.providerChain as ProviderChainItem[] | null, endpoint: row.endpoint, + specialSettings: unifiedSpecialSettings, }; }); @@ -447,6 +462,19 @@ export async function findUsageLogsWithDetails(filters: UsageLogFilters): Promis (row.cacheCreationInputTokens ?? 0) + (row.cacheReadInputTokens ?? 0); + const existingSpecialSettings = Array.isArray(row.specialSettings) + ? (row.specialSettings as SpecialSetting[]) + : null; + + const unifiedSpecialSettings = buildUnifiedSpecialSettings({ + existing: existingSpecialSettings, + blockedBy: row.blockedBy, + blockedReason: row.blockedReason, + statusCode: row.statusCode, + cacheTtlApplied: row.cacheTtlApplied, + context1mApplied: row.context1mApplied, + }); + return { ...row, requestSequence: row.requestSequence ?? null, @@ -457,6 +485,7 @@ export async function findUsageLogsWithDetails(filters: UsageLogFilters): Promis costUsd: row.costUsd?.toString() ?? null, providerChain: row.providerChain as ProviderChainItem[] | null, endpoint: row.endpoint, + specialSettings: unifiedSpecialSettings, }; }); diff --git a/src/types/special-settings.ts b/src/types/special-settings.ts index db06ac784..7ef0d2cca 100644 --- a/src/types/special-settings.ts +++ b/src/types/special-settings.ts @@ -5,7 +5,12 @@ * 便于在请求记录与请求详情中展示,支持后续扩展更多类型。 */ -export type SpecialSetting = ProviderParameterOverrideSpecialSetting | ResponseFixerSpecialSetting; +export type SpecialSetting = + | ProviderParameterOverrideSpecialSetting + | ResponseFixerSpecialSetting + | GuardInterceptSpecialSetting + | AnthropicCacheTtlHeaderOverrideSpecialSetting + | AnthropicContext1mHeaderOverrideSpecialSetting; export type SpecialSettingChangeValue = string | number | boolean | null; @@ -37,3 +42,46 @@ export type ResponseFixerSpecialSetting = { totalBytesProcessed: number; processingTimeMs: number; }; + +/** + * 守卫拦截/阻断审计 + * + * 用于把 warmup 抢答、敏感词拦截等“请求未进入上游”但会影响请求/响应结果的行为, + * 统一纳入 specialSettings 展示区域,方便在日志详情与 Session 详情中排查。 + */ +export type GuardInterceptSpecialSetting = { + type: "guard_intercept"; + scope: "guard"; + hit: boolean; + guard: string; + action: "intercept_response" | "block_request"; + statusCode: number | null; + /** + * 原始原因(通常为 JSON 字符串),保持原样以便前端与日志一致展示。 + */ + reason: string | null; +}; + +/** + * Anthropic 缓存 TTL 相关标头覆写审计 + * + * 说明:当系统根据配置/偏好对请求应用缓存 TTL 能力时,需要在“特殊设置”中可见, + * 便于审计与排查(与计费字段/Token 字段的展示互补)。 + */ +export type AnthropicCacheTtlHeaderOverrideSpecialSetting = { + type: "anthropic_cache_ttl_header_override"; + scope: "request_header"; + hit: boolean; + ttl: string; +}; + +/** + * Anthropic 1M 上下文相关标头覆写审计 + */ +export type AnthropicContext1mHeaderOverrideSpecialSetting = { + type: "anthropic_context_1m_header_override"; + scope: "request_header"; + hit: boolean; + header: "anthropic-beta"; + flag: string; +}; diff --git a/tests/unit/actions/active-sessions-special-settings.test.ts b/tests/unit/actions/active-sessions-special-settings.test.ts new file mode 100644 index 000000000..11fc63c2a --- /dev/null +++ b/tests/unit/actions/active-sessions-special-settings.test.ts @@ -0,0 +1,182 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const getSessionMock = vi.fn(); + +const getSessionDetailsCacheMock = vi.fn(); +const setSessionDetailsCacheMock = vi.fn(); + +const getSessionRequestCountMock = vi.fn(); +const getSessionRequestBodyMock = vi.fn(); +const getSessionMessagesMock = vi.fn(); +const getSessionResponseMock = vi.fn(); +const getSessionRequestHeadersMock = vi.fn(); +const getSessionResponseHeadersMock = vi.fn(); +const getSessionClientRequestMetaMock = vi.fn(); +const getSessionUpstreamRequestMetaMock = vi.fn(); +const getSessionUpstreamResponseMetaMock = vi.fn(); +const getSessionSpecialSettingsMock = vi.fn(); + +const aggregateSessionStatsMock = vi.fn(); +const findAdjacentRequestSequencesMock = vi.fn(); +const findMessageRequestAuditBySessionIdAndSequenceMock = vi.fn(); + +vi.mock("@/lib/auth", () => ({ + getSession: getSessionMock, +})); + +vi.mock("@/lib/cache/session-cache", () => ({ + getActiveSessionsCache: vi.fn(() => null), + setActiveSessionsCache: vi.fn(), + getSessionDetailsCache: getSessionDetailsCacheMock, + setSessionDetailsCache: setSessionDetailsCacheMock, + clearActiveSessionsCache: vi.fn(), + clearSessionDetailsCache: vi.fn(), + clearAllSessionsCache: vi.fn(), +})); + +vi.mock("@/lib/logger", () => ({ + logger: { + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock("@/lib/session-manager", () => ({ + SessionManager: { + getSessionRequestCount: getSessionRequestCountMock, + getSessionRequestBody: getSessionRequestBodyMock, + getSessionMessages: getSessionMessagesMock, + getSessionResponse: getSessionResponseMock, + getSessionRequestHeaders: getSessionRequestHeadersMock, + getSessionResponseHeaders: getSessionResponseHeadersMock, + getSessionClientRequestMeta: getSessionClientRequestMetaMock, + getSessionUpstreamRequestMeta: getSessionUpstreamRequestMetaMock, + getSessionUpstreamResponseMeta: getSessionUpstreamResponseMetaMock, + getSessionSpecialSettings: getSessionSpecialSettingsMock, + }, +})); + +vi.mock("@/repository/message", () => ({ + aggregateSessionStats: aggregateSessionStatsMock, + findAdjacentRequestSequences: findAdjacentRequestSequencesMock, + findMessageRequestAuditBySessionIdAndSequence: findMessageRequestAuditBySessionIdAndSequenceMock, +})); + +describe("getSessionDetails - unified specialSettings", () => { + beforeEach(() => { + vi.clearAllMocks(); + + getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); + getSessionDetailsCacheMock.mockReturnValue(null); + + aggregateSessionStatsMock.mockResolvedValue({ + sessionId: "sess_x", + requestCount: 1, + totalCostUsd: "0", + totalInputTokens: 0, + totalOutputTokens: 0, + totalCacheCreationTokens: 0, + totalCacheReadTokens: 0, + totalDurationMs: 0, + firstRequestAt: new Date(), + lastRequestAt: new Date(), + providers: [], + models: [], + userName: "u", + userId: 1, + keyName: "k", + keyId: 1, + userAgent: null, + apiType: "chat", + cacheTtlApplied: null, + }); + + findAdjacentRequestSequencesMock.mockResolvedValue({ prevSequence: null, nextSequence: null }); + + getSessionRequestCountMock.mockResolvedValue(1); + getSessionRequestBodyMock.mockResolvedValue(null); + getSessionMessagesMock.mockResolvedValue(null); + getSessionResponseMock.mockResolvedValue(null); + getSessionRequestHeadersMock.mockResolvedValue(null); + getSessionResponseHeadersMock.mockResolvedValue(null); + getSessionClientRequestMetaMock.mockResolvedValue(null); + getSessionUpstreamRequestMetaMock.mockResolvedValue(null); + getSessionUpstreamResponseMetaMock.mockResolvedValue(null); + }); + + test("当 Redis specialSettings 为空时,应由 DB 审计字段派生特殊设置", async () => { + getSessionSpecialSettingsMock.mockResolvedValue(null); + findMessageRequestAuditBySessionIdAndSequenceMock.mockResolvedValue({ + statusCode: 200, + blockedBy: "warmup", + blockedReason: JSON.stringify({ reason: "anthropic_warmup_intercepted" }), + cacheTtlApplied: "1h", + context1mApplied: true, + specialSettings: null, + }); + + const { getSessionDetails } = await import("@/actions/active-sessions"); + const result = await getSessionDetails("sess_x", 1); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + const types = (result.data.specialSettings ?? []).map((s) => s.type).sort(); + expect(types).toEqual( + [ + "anthropic_cache_ttl_header_override", + "anthropic_context_1m_header_override", + "guard_intercept", + ].sort() + ); + }); + + test("当 Redis 与 DB 同时存在 specialSettings 时,应合并并去重", async () => { + getSessionSpecialSettingsMock.mockResolvedValue([ + { + type: "provider_parameter_override", + scope: "provider", + providerId: 1, + providerName: "p", + providerType: "codex", + hit: true, + changed: true, + changes: [{ path: "temperature", before: 1, after: 0.2, changed: true }], + }, + ]); + + findMessageRequestAuditBySessionIdAndSequenceMock.mockResolvedValue({ + statusCode: 200, + blockedBy: "warmup", + blockedReason: JSON.stringify({ reason: "anthropic_warmup_intercepted" }), + cacheTtlApplied: null, + context1mApplied: false, + specialSettings: [ + { + type: "provider_parameter_override", + scope: "provider", + providerId: 1, + providerName: "p", + providerType: "codex", + hit: true, + changed: true, + changes: [{ path: "temperature", before: 1, after: 0.2, changed: true }], + }, + ], + }); + + const { getSessionDetails } = await import("@/actions/active-sessions"); + const result = await getSessionDetails("sess_x", 1); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + const settings = result.data.specialSettings ?? []; + expect(settings.some((s) => s.type === "provider_parameter_override")).toBe(true); + expect(settings.some((s) => s.type === "guard_intercept")).toBe(true); + expect(settings.filter((s) => s.type === "provider_parameter_override").length).toBe(1); + }); +}); diff --git a/tests/unit/dashboard-logs-virtualized-special-settings-ui.test.tsx b/tests/unit/dashboard-logs-virtualized-special-settings-ui.test.tsx new file mode 100644 index 000000000..acfda7403 --- /dev/null +++ b/tests/unit/dashboard-logs-virtualized-special-settings-ui.test.tsx @@ -0,0 +1,162 @@ +/** + * @vitest-environment happy-dom + */ + +import fs from "node:fs"; +import path from "node:path"; +import type { ReactNode } from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { NextIntlClientProvider } from "next-intl"; +import { describe, expect, test, vi } from "vitest"; +import { VirtualizedLogsTable } from "@/app/[locale]/dashboard/logs/_components/virtualized-logs-table"; +import type { UsageLogRow } from "@/repository/usage-logs"; + +// Note: The virtualized table relies on element measurements and ResizeObserver; happy-dom may not render rows. +// Stub useVirtualizer to "render only the first row" to keep UI assertions stable. +vi.mock("@/hooks/use-virtualizer", () => ({ + useVirtualizer: () => ({ + getVirtualItems: () => [{ index: 0, size: 52, start: 0 }], + getTotalSize: () => 52, + }), +})); + +vi.mock("@/actions/usage-logs", () => ({ + getUsageLogsBatch: vi.fn(async () => ({ + ok: true, + data: { + logs: [ + { + id: 1, + createdAt: new Date(), + sessionId: "session_test", + requestSequence: 1, + userName: "user", + keyName: "key", + providerName: "provider", + model: "claude-sonnet-4-5-20250929", + originalModel: "claude-sonnet-4-5-20250929", + endpoint: "/v1/messages", + statusCode: 200, + inputTokens: 1, + outputTokens: 1, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cacheCreation5mInputTokens: 0, + cacheCreation1hInputTokens: 0, + cacheTtlApplied: null, + totalTokens: 2, + costUsd: "0.000001", + costMultiplier: null, + durationMs: 10, + ttfbMs: 5, + errorMessage: null, + providerChain: null, + blockedBy: null, + blockedReason: null, + userAgent: "claude_cli/1.0", + messagesCount: 1, + context1mApplied: false, + specialSettings: [ + { + type: "provider_parameter_override", + scope: "provider", + providerId: 1, + providerName: "p", + providerType: "codex", + hit: true, + changed: true, + changes: [{ path: "temperature", before: 1, after: 0.2, changed: true }], + }, + ], + } satisfies UsageLogRow, + ], + nextCursor: null, + hasMore: false, + }, + })), +})); + +// Avoid importing the real next-intl navigation implementation in tests (it depends on Next.js runtime). +vi.mock("@/i18n/routing", () => ({ + Link: ({ children }: { children: ReactNode }) => children, +})); + +const dashboardMessages = JSON.parse( + fs.readFileSync(path.join(process.cwd(), "messages/en/dashboard.json"), "utf8") +); +const providerChainMessages = JSON.parse( + fs.readFileSync(path.join(process.cwd(), "messages/en/provider-chain.json"), "utf8") +); + +function renderWithIntl(node: ReactNode) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + refetchOnWindowFocus: false, + }, + }, + }); + + act(() => { + root.render( + + + {node} + + + ); + }); + + return { + container, + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +async function flushMicrotasks() { + await act(async () => { + await Promise.resolve(); + }); +} + +async function waitForText(container: HTMLElement, text: string, timeoutMs = 2000) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if ((container.textContent || "").includes(text)) return; + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + }); + } + throw new Error(`Timeout waiting for text: ${text}`); +} + +describe("VirtualizedLogsTable - specialSettings display", () => { + test("should not display specialSettings badge in the logs list row", async () => { + const { container, unmount } = renderWithIntl( + + ); + + await flushMicrotasks(); + + // Wait for initial data to render (avoid assertion stuck in Loading state). + await waitForText(container, "Loaded 1 records"); + + expect(container.textContent).not.toContain(dashboardMessages.logs.table.specialSettings); + + unmount(); + }); +}); diff --git a/tests/unit/lib/utils/special-settings.test.ts b/tests/unit/lib/utils/special-settings.test.ts new file mode 100644 index 000000000..5bd4e041e --- /dev/null +++ b/tests/unit/lib/utils/special-settings.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, test } from "vitest"; +import type { SpecialSetting } from "@/types/special-settings"; +import { buildUnifiedSpecialSettings } from "@/lib/utils/special-settings"; + +describe("buildUnifiedSpecialSettings", () => { + test("无任何输入时应返回 null", () => { + expect(buildUnifiedSpecialSettings({ existing: null })).toBe(null); + }); + + test("blockedBy=warmup 时应派生 guard_intercept 特殊设置", () => { + const settings = buildUnifiedSpecialSettings({ + existing: null, + blockedBy: "warmup", + blockedReason: JSON.stringify({ reason: "anthropic_warmup_intercepted" }), + statusCode: 200, + }); + + expect(settings).not.toBeNull(); + expect(settings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "guard_intercept", + scope: "guard", + hit: true, + guard: "warmup", + action: "intercept_response", + statusCode: 200, + }), + ]) + ); + }); + + test("blockedBy=sensitive_word 时应派生 guard_intercept 特殊设置", () => { + const settings = buildUnifiedSpecialSettings({ + existing: null, + blockedBy: "sensitive_word", + blockedReason: JSON.stringify({ word: "x" }), + statusCode: 400, + }); + + expect(settings).not.toBeNull(); + expect(settings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "guard_intercept", + scope: "guard", + hit: true, + guard: "sensitive_word", + action: "block_request", + statusCode: 400, + }), + ]) + ); + }); + + test("cacheTtlApplied 存在时应派生 anthropic_cache_ttl_header_override 特殊设置", () => { + const settings = buildUnifiedSpecialSettings({ + existing: null, + cacheTtlApplied: "1h", + }); + + expect(settings).not.toBeNull(); + expect(settings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "anthropic_cache_ttl_header_override", + scope: "request_header", + hit: true, + ttl: "1h", + }), + ]) + ); + }); + + test("context1mApplied=true 时应派生 anthropic_context_1m_header_override 特殊设置", () => { + const settings = buildUnifiedSpecialSettings({ + existing: null, + context1mApplied: true, + }); + + expect(settings).not.toBeNull(); + expect(settings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "anthropic_context_1m_header_override", + scope: "request_header", + hit: true, + header: "anthropic-beta", + }), + ]) + ); + }); + + test("应合并 existing specialSettings 与派生 specialSettings", () => { + const existing: SpecialSetting[] = [ + { + type: "provider_parameter_override", + scope: "provider", + providerId: 1, + providerName: "p", + providerType: "codex", + hit: true, + changed: true, + changes: [{ path: "temperature", before: 1, after: 0.2, changed: true }], + }, + ]; + + const settings = buildUnifiedSpecialSettings({ + existing, + blockedBy: "warmup", + blockedReason: JSON.stringify({ reason: "anthropic_warmup_intercepted" }), + statusCode: 200, + }); + + expect(settings).not.toBeNull(); + expect(settings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "provider_parameter_override" }), + expect.objectContaining({ type: "guard_intercept", guard: "warmup" }), + ]) + ); + }); + + test("应对重复的派生项去重(例如 existing 已包含同类 guard_intercept)", () => { + const existing: SpecialSetting[] = [ + { + type: "guard_intercept", + scope: "guard", + hit: true, + guard: "warmup", + action: "intercept_response", + statusCode: 200, + reason: JSON.stringify({ reason: "anthropic_warmup_intercepted" }), + }, + ]; + + const settings = buildUnifiedSpecialSettings({ + existing, + blockedBy: "warmup", + blockedReason: JSON.stringify({ reason: "anthropic_warmup_intercepted" }), + statusCode: 200, + }); + + expect(settings).not.toBeNull(); + expect(settings?.filter((s) => s.type === "guard_intercept").length).toBe(1); + }); + + test("guard_intercept 去重时不应受 reason 差异影响", () => { + const existing: SpecialSetting[] = [ + { + type: "guard_intercept", + scope: "guard", + hit: true, + guard: "warmup", + action: "intercept_response", + statusCode: 200, + reason: JSON.stringify({ reason: "a" }), + }, + ]; + + const settings = buildUnifiedSpecialSettings({ + existing, + blockedBy: "warmup", + blockedReason: JSON.stringify({ reason: "b" }), + statusCode: 200, + }); + + expect(settings).not.toBeNull(); + expect(settings?.filter((s) => s.type === "guard_intercept").length).toBe(1); + }); +}); From c5d5764fb56c5b3e61f5e98f77741d38cfc17999 Mon Sep 17 00:00:00 2001 From: YangQing-Lin <56943790+YangQing-Lin@users.noreply.github.com> Date: Sat, 10 Jan 2026 12:36:32 +0800 Subject: [PATCH 06/17] =?UTF-8?q?feat(providers):=20=E6=8C=89=E6=88=90?= =?UTF-8?q?=E6=9C=AC=E5=80=8D=E6=95=B0=E8=87=AA=E5=8A=A8=E6=8E=92=E5=BA=8F?= =?UTF-8?q?=E4=BE=9B=E5=BA=94=E5=95=86=E4=BC=98=E5=85=88=E7=BA=A7=20(#555)?= =?UTF-8?q?=20(#569)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(providers): add auto-sort priority by costMultiplier (#555) * 变更概要: - 5 个语言的 dashboard.json 添加顶层 actions 翻译键 - 2 个测试文件 import 真实 locale 文件替代硬编码 mock - 测试断言收敛到具体 tab 容器作用域 - CLAUDE.md 语言列表校正 (ko/de → ru/zh-TW) fix(i18n): add top-level dashboard.actions keys and improve test i18n strategy - Add `actions` (copy/download/copied) to dashboard.json for all 5 locales to fix CodeDisplay component missing translation keys - Refactor test files to import real locale messages instead of hardcoded mocks, ensuring tests stay in sync with actual translations - Scope test assertions to specific tab containers for better precision - Fix CLAUDE.md language list to match actual locales (ru/zh-TW, not ko/de) * 变更概要: - tsconfig.json 添加 @messages/* 路径别名 - tsconfig.json 移除 src/components/ui/** exclude - vitest.config.ts 添加 @messages 别名 - 测试文件使用新别名,修复类型断言 chore(config): add @messages path alias and fix TypeScript exclude - Add `@messages/*` path alias to tsconfig.json and vitest.config.ts for cleaner locale imports in tests - Remove `src/components/ui/**` from tsconfig exclude to enable TypeScript checking and IDE support for UI component tests - Update test imports to use @messages/en/dashboard.json - Fix non-null assertion in code-display.test.tsx to resolve TS error * fix(ui): 为纯图标导出按钮补充 aria-label,修复相关单测 - 为请求复制/下载两个纯图标按钮添加 aria-label,避免依赖 Tooltip 文本,同时提升可访问性 - 单测改为按 aria-label 定位按钮,并补齐 SessionMessagesDetailsTabs 的 mock(提供复制响应按钮) * fix(ui): 修复 session-messages-client 组件缩进混乱问题 - 将 Tab+空格混合缩进统一为纯空格,符合项目 Biome 规范 - 测试文件长行拆分以保持代码风格一致 * fix(providers): 统一 React Query QueryClient,改用 invalidate 刷新供应商列表 - 移除 ProviderManagerLoader 内部 QueryClientProvider,避免页面存在两个 QueryClient 导致 invalidate 不生效 - 将 staleTime/refetchOnWindowFocus 下沉到各个 useQuery,保持原有缓存/刷新行为一致 - AutoSortPriorityDialog 应用后改为 invalidateQueries(["providers"]),不再使用 window.location.reload() * ⏺ fix(test): 将需要数据库的 API 测试移入集成测试配置 - vitest.config.ts: 排除 my-usage-readonly.test.ts(需要 DSN) - vitest.integration.config.ts: 统一管理所有需要 DB 的 API 测试 - users-actions.test.ts - providers-actions.test.ts - keys-actions.test.ts - my-usage-readonly.test.ts 确保 `bun run test` 在无数据库环境下正常通过, 需要 DB 的测试通过 `bun run test:integration` 单独运行。 * fix(ui): 修复 AlertDialogDescription 内嵌套 div 导致的 hydration 错误 AlertDialogDescription 默认渲染为

标签,内部嵌套

违反 HTML 规范, 导致 React hydration mismatch 警告。使用 asChild 属性让其渲染为
解决。 * fix(ui): remove duplicate aria-label --- CLAUDE.md | 2 +- messages/en/dashboard.json | 5 + messages/en/settings.json | 16 ++ messages/ja/dashboard.json | 5 + messages/ja/settings.json | 16 ++ messages/ru/dashboard.json | 5 + messages/ru/settings.json | 16 ++ messages/zh-CN/dashboard.json | 5 + messages/zh-CN/settings.json | 16 ++ messages/zh-TW/dashboard.json | 5 + messages/zh-TW/settings.json | 16 ++ src/actions/providers.ts | 148 +++++++++++ .../session-messages-client-actions.test.tsx | 22 +- .../session-messages-client.test.tsx | 121 ++++----- .../_components/session-messages-client.tsx | 14 +- .../_components/auto-sort-priority-dialog.tsx | 250 ++++++++++++++++++ .../_components/provider-manager-loader.tsx | 24 +- src/app/[locale]/settings/providers/page.tsx | 2 + .../ui/__tests__/code-display.test.tsx | 47 +--- src/repository/provider.ts | 37 +++ tests/unit/actions/providers.test.ts | 206 +++++++++++++++ tests/unit/repository/provider.test.ts | 127 +++++++++ tsconfig.json | 5 +- vitest.config.ts | 2 + vitest.integration.config.ts | 9 +- 25 files changed, 987 insertions(+), 134 deletions(-) create mode 100644 src/app/[locale]/settings/providers/_components/auto-sort-priority-dialog.tsx create mode 100644 tests/unit/repository/provider.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 8b449668c..cf1b28607 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -126,7 +126,7 @@ Key components: - **Path alias**: `@/` maps to `./src/` - **Formatting**: Biome (double quotes, trailing commas, 2-space indent, 100 char width) - **Exports**: Prefer named exports over default exports -- **i18n**: Use `next-intl` for internationalization (5 languages: zh-CN, en, ja, ko, de) +- **i18n**: Use `next-intl` for internationalization (5 languages: zh-CN, zh-TW, en, ja, ru) - **Testing**: Unit tests in `tests/unit/`, integration in `tests/integration/`, source-adjacent tests in `src/**/*.test.ts` ## Environment Variables diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index 077a63746..8cc6d5add 100644 --- a/messages/en/dashboard.json +++ b/messages/en/dashboard.json @@ -1,4 +1,9 @@ { + "actions": { + "copy": "Copy", + "download": "Download", + "copied": "Copied" + }, "title": { "costRanking": "Cost Leaderboard", "costRankingDescription": "View user cost rankings, data updates every 5 minutes", diff --git a/messages/en/settings.json b/messages/en/settings.json index 388c99495..eac7bfaeb 100644 --- a/messages/en/settings.json +++ b/messages/en/settings.json @@ -615,6 +615,22 @@ "addFailed": "Failed to add provider", "addProvider": "Add Provider", "addSuccess": "Provider added successfully", + "autoSort": { + "button": "Auto Sort Priority", + "dialogTitle": "Auto Sort Provider Priority", + "dialogDescription": "Automatically assign priority based on cost multiplier (lower cost = higher priority)", + "changeCount": "{count} providers will be updated", + "noChanges": "No changes needed (already sorted)", + "costMultiplierHeader": "Cost Multiplier", + "priorityHeader": "Priority", + "providersHeader": "Providers", + "changesTitle": "Change Details", + "providerHeader": "Provider", + "priorityChangeHeader": "Priority Change", + "confirm": "Apply Changes", + "success": "Updated priority for {count} providers", + "error": "Failed to update priorities" + }, "types": { "claude": { "label": "Claude", diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json index 8c6340a00..a43abf9ca 100644 --- a/messages/ja/dashboard.json +++ b/messages/ja/dashboard.json @@ -1,4 +1,9 @@ { + "actions": { + "copy": "コピー", + "download": "ダウンロード", + "copied": "コピーしました" + }, "title": { "costRanking": "コスト ランキング", "costRankingDescription": "ユーザーコスト ランキングを表示します。データは 5 分ごとに更新されます", diff --git a/messages/ja/settings.json b/messages/ja/settings.json index 571f8f063..f7b2dbf6f 100644 --- a/messages/ja/settings.json +++ b/messages/ja/settings.json @@ -606,6 +606,22 @@ "addFailed": "プロバイダーの追加に失敗しました", "addProvider": "プロバイダーを追加", "addSuccess": "プロバイダーが正常に追加されました", + "autoSort": { + "button": "優先度を自動ソート", + "dialogTitle": "プロバイダー優先度の自動ソート", + "dialogDescription": "コスト倍率に基づいて優先度を自動割り当て(低コスト = 高優先度)", + "changeCount": "{count} 件のプロバイダーが更新されます", + "noChanges": "変更不要(ソート済み)", + "costMultiplierHeader": "コスト倍率", + "priorityHeader": "優先度", + "providersHeader": "プロバイダー", + "changesTitle": "変更詳細", + "providerHeader": "プロバイダー", + "priorityChangeHeader": "優先度変更", + "confirm": "変更を適用", + "success": "{count} 件のプロバイダーの優先度を更新しました", + "error": "優先度の更新に失敗しました" + }, "circuitBroken": "サーキットブレーカー作動中", "clone": "プロバイダーを複製", "cloneFailed": "コピーに失敗しました", diff --git a/messages/ru/dashboard.json b/messages/ru/dashboard.json index add9280f6..e0fd846da 100644 --- a/messages/ru/dashboard.json +++ b/messages/ru/dashboard.json @@ -1,4 +1,9 @@ { + "actions": { + "copy": "Копировать", + "download": "Скачать", + "copied": "Скопировано" + }, "title": { "costRanking": "Таблица расходов", "costRankingDescription": "Просмотр рейтинга расходов пользователей, данные обновляются каждые 5 минут", diff --git a/messages/ru/settings.json b/messages/ru/settings.json index efb1967f8..f73c3d9bf 100644 --- a/messages/ru/settings.json +++ b/messages/ru/settings.json @@ -606,6 +606,22 @@ "addFailed": "Ошибка добавления поставщика", "addProvider": "Добавить провайдера", "addSuccess": "Поставщик добавлен успешно", + "autoSort": { + "button": "Авто сортировка приоритета", + "dialogTitle": "Автоматическая сортировка приоритета поставщиков", + "dialogDescription": "Автоматически назначить приоритет на основе множителя стоимости (низкая стоимость = высокий приоритет)", + "changeCount": "{count} поставщиков будет обновлено", + "noChanges": "Изменения не требуются (уже отсортировано)", + "costMultiplierHeader": "Множитель стоимости", + "priorityHeader": "Приоритет", + "providersHeader": "Поставщики", + "changesTitle": "Детали изменений", + "providerHeader": "Поставщик", + "priorityChangeHeader": "Изменение приоритета", + "confirm": "Применить изменения", + "success": "Обновлён приоритет для {count} поставщиков", + "error": "Не удалось обновить приоритеты" + }, "circuitBroken": "Цепь разомкнута", "clone": "Дублировать поставщика", "cloneFailed": "Ошибка копирования", diff --git a/messages/zh-CN/dashboard.json b/messages/zh-CN/dashboard.json index 7e3f22f31..d027152a4 100644 --- a/messages/zh-CN/dashboard.json +++ b/messages/zh-CN/dashboard.json @@ -1,4 +1,9 @@ { + "actions": { + "copy": "复制", + "download": "下载", + "copied": "已复制" + }, "title": { "costRanking": "消耗排行榜", "costRankingDescription": "查看用户消耗排名,数据每 5 分钟更新一次", diff --git a/messages/zh-CN/settings.json b/messages/zh-CN/settings.json index 942619612..b14bdd05f 100644 --- a/messages/zh-CN/settings.json +++ b/messages/zh-CN/settings.json @@ -138,6 +138,22 @@ "subtitle": "服务商管理", "subtitleDesc": "配置上游服务商的金额限流和并发限制,留空表示无限制。", "add": "添加供应商", + "autoSort": { + "button": "自动排序优先级", + "dialogTitle": "自动排序供应商优先级", + "dialogDescription": "根据成本倍率自动分配优先级(低成本 = 高优先级)", + "changeCount": "{count} 个供应商将被更新", + "noChanges": "无需更改(已排序)", + "costMultiplierHeader": "成本倍率", + "priorityHeader": "优先级", + "providersHeader": "供应商", + "changesTitle": "变更详情", + "providerHeader": "供应商", + "priorityChangeHeader": "优先级变更", + "confirm": "应用变更", + "success": "已更新 {count} 个供应商的优先级", + "error": "更新优先级失败" + }, "types": { "claude": { "label": "Claude", diff --git a/messages/zh-TW/dashboard.json b/messages/zh-TW/dashboard.json index 77149f016..c7bc527a4 100644 --- a/messages/zh-TW/dashboard.json +++ b/messages/zh-TW/dashboard.json @@ -1,4 +1,9 @@ { + "actions": { + "copy": "複製", + "download": "下載", + "copied": "已複製" + }, "title": { "costRanking": "消耗排行榜", "costRankingDescription": "查看用戶消耗排名,資料每 5 分鐘更新一次", diff --git a/messages/zh-TW/settings.json b/messages/zh-TW/settings.json index 4ee371969..153ee73c9 100644 --- a/messages/zh-TW/settings.json +++ b/messages/zh-TW/settings.json @@ -606,6 +606,22 @@ "addFailed": "新增服務商失敗", "addProvider": "新增服务商", "addSuccess": "新增服務商成功", + "autoSort": { + "button": "自動排序優先級", + "dialogTitle": "自動排序供應商優先級", + "dialogDescription": "根據成本倍率自動分配優先級(低成本 = 高優先級)", + "changeCount": "{count} 個供應商將被更新", + "noChanges": "無需更改(已排序)", + "costMultiplierHeader": "成本倍率", + "priorityHeader": "優先級", + "providersHeader": "供應商", + "changesTitle": "變更詳情", + "providerHeader": "供應商", + "priorityChangeHeader": "優先級變更", + "confirm": "應用變更", + "success": "已更新 {count} 個供應商的優先級", + "error": "更新優先級失敗" + }, "circuitBroken": "熔断中", "clone": "複製服務商", "cloneFailed": "複製失敗", diff --git a/src/actions/providers.ts b/src/actions/providers.ts index 0a812aad7..3a9d73827 100644 --- a/src/actions/providers.ts +++ b/src/actions/providers.ts @@ -41,6 +41,7 @@ import { getProviderStatistics, resetProviderTotalCostResetAt, updateProvider, + updateProviderPrioritiesBatch, } from "@/repository/provider"; import type { CacheTtlPreference } from "@/types/cache"; import type { @@ -54,6 +55,27 @@ import type { } from "@/types/provider"; import type { ActionResult } from "./types"; +type AutoSortResult = { + groups: Array<{ + costMultiplier: number; + priority: number; + providers: Array<{ id: number; name: string }>; + }>; + changes: Array<{ + providerId: number; + name: string; + oldPriority: number; + newPriority: number; + costMultiplier: number; + }>; + summary: { + totalProviders: number; + changedCount: number; + groupCount: number; + }; + applied: boolean; +}; + const API_TEST_TIMEOUT_LIMITS = { DEFAULT: 15000, MIN: 5000, @@ -740,6 +762,132 @@ export async function removeProvider(providerId: number): Promise } } +export async function autoSortProviderPriority(args: { + confirm: boolean; +}): Promise> { + try { + const session = await getSession(); + if (!session || session.user.role !== "admin") { + return { ok: false, error: "无权限执行此操作" }; + } + + const providers = await findAllProvidersFresh(); + if (providers.length === 0) { + return { + ok: true, + data: { + groups: [], + changes: [], + summary: { + totalProviders: 0, + changedCount: 0, + groupCount: 0, + }, + applied: args.confirm, + }, + }; + } + + const groupsByCostMultiplier = new Map(); + for (const provider of providers) { + const rawCostMultiplier = Number(provider.costMultiplier); + const costMultiplier = Number.isFinite(rawCostMultiplier) ? rawCostMultiplier : 0; + + if (!Number.isFinite(rawCostMultiplier)) { + logger.warn("autoSortProviderPriority:invalid_cost_multiplier", { + providerId: provider.id, + providerName: provider.name, + costMultiplier: provider.costMultiplier, + fallback: costMultiplier, + }); + } + + const bucket = groupsByCostMultiplier.get(costMultiplier); + if (bucket) { + bucket.push(provider); + } else { + groupsByCostMultiplier.set(costMultiplier, [provider]); + } + } + + const sortedCostMultipliers = Array.from(groupsByCostMultiplier.keys()).sort((a, b) => a - b); + const groups: AutoSortResult["groups"] = []; + const changes: AutoSortResult["changes"] = []; + + for (const [priority, costMultiplier] of sortedCostMultipliers.entries()) { + const groupProviders = groupsByCostMultiplier.get(costMultiplier) ?? []; + groups.push({ + costMultiplier, + priority, + providers: groupProviders + .slice() + .sort((a, b) => a.id - b.id) + .map((provider) => ({ id: provider.id, name: provider.name })), + }); + + for (const provider of groupProviders) { + const oldPriority = provider.priority ?? 0; + const newPriority = priority; + if (oldPriority !== newPriority) { + changes.push({ + providerId: provider.id, + name: provider.name, + oldPriority, + newPriority, + costMultiplier, + }); + } + } + } + + const summary: AutoSortResult["summary"] = { + totalProviders: providers.length, + changedCount: changes.length, + groupCount: groups.length, + }; + + if (!args.confirm) { + return { + ok: true, + data: { + groups, + changes, + summary, + applied: false, + }, + }; + } + + if (changes.length > 0) { + await updateProviderPrioritiesBatch( + changes.map((change) => ({ id: change.providerId, priority: change.newPriority })) + ); + try { + await publishProviderCacheInvalidation(); + } catch (error) { + logger.warn("autoSortProviderPriority:cache_invalidation_failed", { + changedCount: changes.length, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + return { + ok: true, + data: { + groups, + changes, + summary, + applied: true, + }, + }; + } catch (error) { + logger.error("autoSortProviderPriority:error", error); + const message = error instanceof Error ? error.message : "自动排序供应商优先级失败"; + return { ok: false, error: message }; + } +} + /** * 获取所有供应商的熔断器健康状态 * 返回格式:{ providerId: { circuitState, failureCount, circuitOpenUntil, ... } } diff --git a/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client-actions.test.tsx b/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client-actions.test.tsx index 50b64654c..bb5993295 100644 --- a/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client-actions.test.tsx +++ b/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client-actions.test.tsx @@ -194,9 +194,8 @@ describe("SessionMessagesClient (request export actions)", () => { const { container, unmount } = renderClient(); await flushEffects(); - const buttons = Array.from(container.querySelectorAll("button")); - const downloadBtn = buttons.find((b) => b.textContent?.includes("actions.downloadMessages")); - expect(downloadBtn).not.toBeUndefined(); + const downloadBtn = container.querySelector('button[aria-label="actions.downloadMessages"]'); + expect(downloadBtn).not.toBeNull(); click(downloadBtn as HTMLButtonElement); expect(createObjectURLSpy).toHaveBeenCalledTimes(1); @@ -282,9 +281,8 @@ describe("SessionMessagesClient (request export actions)", () => { 2 ); - const buttons = Array.from(container.querySelectorAll("button")); - const copyBtn = buttons.find((b) => b.textContent?.includes("actions.copyMessages")); - expect(copyBtn).not.toBeUndefined(); + const copyBtn = container.querySelector('button[aria-label="actions.copyMessages"]'); + expect(copyBtn).not.toBeNull(); await clickAsync(copyBtn as HTMLButtonElement); expect(clipboardWriteText).toHaveBeenCalledWith(expectedJson); act(() => { @@ -292,8 +290,8 @@ describe("SessionMessagesClient (request export actions)", () => { }); vi.useRealTimers(); - const downloadBtn = buttons.find((b) => b.textContent?.includes("actions.downloadMessages")); - expect(downloadBtn).not.toBeUndefined(); + const downloadBtn = container.querySelector('button[aria-label="actions.downloadMessages"]'); + expect(downloadBtn).not.toBeNull(); click(downloadBtn as HTMLButtonElement); expect(createObjectURLSpy).toHaveBeenCalledTimes(1); @@ -335,8 +333,8 @@ describe("SessionMessagesClient (request export actions)", () => { const { container, unmount } = renderClient(); await flushEffects(); - expect(container.textContent).not.toContain("actions.copyMessages"); - expect(container.textContent).not.toContain("actions.downloadMessages"); + expect(container.querySelector('button[aria-label="actions.copyMessages"]')).toBeNull(); + expect(container.querySelector('button[aria-label="actions.downloadMessages"]')).toBeNull(); unmount(); }); @@ -366,8 +364,8 @@ describe("SessionMessagesClient (request export actions)", () => { const { container, unmount } = renderClient(); await flushEffects(); - expect(container.textContent).not.toContain("actions.copyMessages"); - expect(container.textContent).not.toContain("actions.downloadMessages"); + expect(container.querySelector('button[aria-label="actions.copyMessages"]')).toBeNull(); + expect(container.querySelector('button[aria-label="actions.downloadMessages"]')).toBeNull(); unmount(); }); diff --git a/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client.test.tsx b/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client.test.tsx index 1c3465276..f2c92da01 100644 --- a/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client.test.tsx +++ b/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client.test.tsx @@ -7,49 +7,12 @@ import { act } from "react"; import { createRoot } from "react-dom/client"; import { NextIntlClientProvider } from "next-intl"; import { describe, expect, test, vi } from "vitest"; +import dashboardMessages from "@messages/en/dashboard.json"; import { SessionMessagesDetailsTabs } from "./session-details-tabs"; +// Use real locale messages to ensure test stays in sync with actual translations const messages = { - dashboard: { - sessions: { - details: { - requestHeaders: "Request Headers", - requestBody: "Request Body", - requestMessages: "Request Messages", - specialSettings: "Special", - responseHeaders: "Response Headers", - responseBody: "Response Body", - noHeaders: "No data", - noData: "No Data", - storageTip: "Storage Tip", - }, - codeDisplay: { - raw: "Raw", - pretty: "Pretty", - searchPlaceholder: "Search", - expand: "Expand", - collapse: "Collapse", - themeAuto: "Auto", - themeLight: "Light", - themeDark: "Dark", - noMatches: "No matches", - onlyMatches: "Only matches", - showAll: "Show all", - prevPage: "Prev", - nextPage: "Next", - pageInfo: "Page {page} / {total}", - sseEvent: "Event", - sseData: "Data", - hardLimit: { - title: "Content too large", - size: "Size: {sizeMB} MB ({sizeBytes} bytes)", - maximum: "Maximum allowed: {maxSizeMB} MB or {maxLines} lines", - hint: "Please download the file to view the full content.", - download: "Download", - }, - }, - }, - }, + dashboard: dashboardMessages, } as const; function renderWithIntl(node: ReactNode) { @@ -108,39 +71,61 @@ describe("SessionMessagesDetailsTabs", () => { container.querySelector("[data-testid='session-tab-trigger-request-messages']") ).not.toBeNull(); - const requestBody = container.querySelector( - "[data-testid='session-tab-request-body'] [data-testid='code-display']" + // Check request body tab content within its scope + const requestBodyTab = container.querySelector( + "[data-testid='session-tab-request-body']" ) as HTMLElement; - expect(requestBody.getAttribute("data-language")).toBe("json"); - expect(container.textContent).toContain('"model": "gpt-5.2"'); + const requestBodyCodeDisplay = requestBodyTab.querySelector( + "[data-testid='code-display']" + ) as HTMLElement; + expect(requestBodyCodeDisplay.getAttribute("data-language")).toBe("json"); + expect(requestBodyTab.textContent).toContain('"model": "gpt-5.2"'); + // Switch to request headers tab and check within its scope const requestHeadersTrigger = container.querySelector( "[data-testid='session-tab-trigger-request-headers']" ) as HTMLElement; click(requestHeadersTrigger); - expect(container.textContent).toContain("CLIENT: POST https://example.com/v1/responses"); + const requestHeadersTab = container.querySelector( + "[data-testid='session-tab-request-headers']" + ) as HTMLElement; + expect(requestHeadersTab.textContent).toContain( + "CLIENT: POST https://example.com/v1/responses" + ); + // Switch to request messages tab and check within its scope const requestMessagesTrigger = container.querySelector( "[data-testid='session-tab-trigger-request-messages']" ) as HTMLElement; click(requestMessagesTrigger); - expect(container.textContent).toContain('"content": "hi"'); + const requestMessagesTab = container.querySelector( + "[data-testid='session-tab-request-messages']" + ) as HTMLElement; + expect(requestMessagesTab.textContent).toContain('"content": "hi"'); + // Switch to response body tab and check SSE detection const responseBodyTrigger = container.querySelector( "[data-testid='session-tab-trigger-response-body']" ) as HTMLElement; click(responseBodyTrigger); - const responseBody = container.querySelector( - "[data-testid='session-tab-response-body'] [data-testid='code-display']" + const responseBodyTab = container.querySelector( + "[data-testid='session-tab-response-body']" ) as HTMLElement; - expect(responseBody.getAttribute("data-language")).toBe("sse"); + const responseBodyCodeDisplay = responseBodyTab.querySelector( + "[data-testid='code-display']" + ) as HTMLElement; + expect(responseBodyCodeDisplay.getAttribute("data-language")).toBe("sse"); + // Switch to response headers tab and check within its scope const responseHeadersTrigger = container.querySelector( "[data-testid='session-tab-trigger-response-headers']" ) as HTMLElement; click(responseHeadersTrigger); - expect(container.textContent).toContain( + const responseHeadersTab = container.querySelector( + "[data-testid='session-tab-response-headers']" + ) as HTMLElement; + expect(responseHeadersTab.textContent).toContain( "UPSTREAM: HTTP 200 https://api.example.com/v1/responses" ); @@ -166,10 +151,13 @@ describe("SessionMessagesDetailsTabs", () => { ) as HTMLElement; click(responseBodyTrigger); - const responseBody = container.querySelector( - "[data-testid='session-tab-response-body'] [data-testid='code-display']" + const responseBodyTab = container.querySelector( + "[data-testid='session-tab-response-body']" ) as HTMLElement; - expect(responseBody.getAttribute("data-language")).toBe("json"); + const responseBodyCodeDisplay = responseBodyTab.querySelector( + "[data-testid='code-display']" + ) as HTMLElement; + expect(responseBodyCodeDisplay.getAttribute("data-language")).toBe("json"); unmount(); }); @@ -188,19 +176,31 @@ describe("SessionMessagesDetailsTabs", () => { /> ); - expect(container.textContent).toContain("Storage Tip"); + // Check default tab (request body) shows storageTip when null - scoped to tab + const requestBodyTab = container.querySelector( + "[data-testid='session-tab-request-body']" + ) as HTMLElement; + expect(requestBodyTab.textContent).toContain(dashboardMessages.sessions.details.storageTip); + // Switch to request headers tab and check storageTip - scoped to tab const requestHeadersTrigger = container.querySelector( "[data-testid='session-tab-trigger-request-headers']" ) as HTMLElement; click(requestHeadersTrigger); - expect(container.textContent).toContain("Storage Tip"); + const requestHeadersTab = container.querySelector( + "[data-testid='session-tab-request-headers']" + ) as HTMLElement; + expect(requestHeadersTab.textContent).toContain(dashboardMessages.sessions.details.storageTip); + // Switch to special settings tab and check noData - scoped to tab const specialSettingsTrigger = container.querySelector( "[data-testid='session-tab-trigger-special-settings']" ) as HTMLElement; click(specialSettingsTrigger); - expect(container.textContent).toContain("No Data"); + const specialSettingsTab = container.querySelector( + "[data-testid='session-tab-special-settings']" + ) as HTMLElement; + expect(specialSettingsTab.textContent).toContain(dashboardMessages.sessions.details.noData); unmount(); }); @@ -231,7 +231,9 @@ describe("SessionMessagesDetailsTabs", () => { const requestHeadersTab = container.querySelector( "[data-testid='session-tab-request-headers']" ) as HTMLElement; - expect(requestHeadersTab.textContent).not.toContain("Content too large"); + expect(requestHeadersTab.textContent).not.toContain( + dashboardMessages.sessions.codeDisplay.hardLimit.title + ); const search = requestHeadersTab.querySelector( "[data-testid='code-display-search']" @@ -278,8 +280,9 @@ describe("SessionMessagesDetailsTabs", () => { const requestBodyTab = container.querySelector( "[data-testid='session-tab-request-body']" ) as HTMLElement; - expect(requestBodyTab.textContent).toContain("Content too large"); - expect(requestBodyTab.textContent).toContain("30,000 lines"); + expect(requestBodyTab.textContent).toContain( + dashboardMessages.sessions.codeDisplay.hardLimit.title + ); const downloadBtn = requestBodyTab.querySelector( "[data-testid='code-display-hard-limit-download']" diff --git a/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client.tsx b/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client.tsx index aba97ea85..032ae1f3d 100644 --- a/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client.tsx +++ b/src/app/[locale]/dashboard/sessions/[sessionId]/messages/_components/session-messages-client.tsx @@ -372,8 +372,8 @@ export function SessionMessagesClient() { variant="outline" size="icon" className="h-8 w-8" - onClick={handleCopyRequest} aria-label={t("actions.copyMessages")} + onClick={handleCopyRequest} > {copiedRequest ? ( @@ -394,8 +394,8 @@ export function SessionMessagesClient() { variant="outline" size="icon" className="h-8 w-8" - onClick={handleDownloadRequest} aria-label={t("actions.downloadMessages")} + onClick={handleDownloadRequest} > {t("actions.downloadMessages")} @@ -562,10 +562,12 @@ export function SessionMessagesClient() { {t("actions.terminateSessionTitle")} - - {t("actions.terminateSessionDescription")} -
- {sessionId} + +
+ {t("actions.terminateSessionDescription")} +
+ {sessionId} +
diff --git a/src/app/[locale]/settings/providers/_components/auto-sort-priority-dialog.tsx b/src/app/[locale]/settings/providers/_components/auto-sort-priority-dialog.tsx new file mode 100644 index 000000000..eaebd4992 --- /dev/null +++ b/src/app/[locale]/settings/providers/_components/auto-sort-priority-dialog.tsx @@ -0,0 +1,250 @@ +"use client"; + +import { useQueryClient } from "@tanstack/react-query"; +import { ArrowRight, ListOrdered, Loader2 } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useState, useTransition } from "react"; +import { toast } from "sonner"; +import { autoSortProviderPriority } from "@/actions/providers"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +type AutoSortResult = { + groups: Array<{ + costMultiplier: number; + priority: number; + providers: Array<{ id: number; name: string }>; + }>; + changes: Array<{ + providerId: number; + name: string; + oldPriority: number; + newPriority: number; + costMultiplier: number; + }>; + summary: { + totalProviders: number; + changedCount: number; + groupCount: number; + }; + applied: boolean; +}; + +export function AutoSortPriorityDialog() { + const queryClient = useQueryClient(); + const t = useTranslations("settings.providers.autoSort"); + const tCommon = useTranslations("settings.common"); + const tErrors = useTranslations("errors"); + + const [open, setOpen] = useState(false); + const [previewData, setPreviewData] = useState(null); + const [isPending, startTransition] = useTransition(); + const [isApplying, setIsApplying] = useState(false); + + const getActionErrorMessage = (result: { + errorCode?: string; + errorParams?: Record; + error?: string | null; + }): string => { + if (result.errorCode) { + try { + return tErrors(result.errorCode, result.errorParams); + } catch { + return t("error"); + } + } + + if (result.error) { + try { + return tErrors(result.error); + } catch { + return t("error"); + } + } + + return t("error"); + }; + + const handleOpenChange = (isOpen: boolean) => { + setOpen(isOpen); + if (isOpen) { + // Load preview when dialog opens + startTransition(async () => { + try { + const result = await autoSortProviderPriority({ confirm: false }); + if (result.ok) { + setPreviewData(result.data); + } else { + toast.error(getActionErrorMessage(result)); + setOpen(false); + } + } catch (error) { + console.error("autoSortProviderPriority preview failed", error); + toast.error(t("error")); + setOpen(false); + } + }); + } else { + // Clear preview when dialog closes + setPreviewData(null); + } + }; + + const handleApply = async () => { + setIsApplying(true); + try { + const result = await autoSortProviderPriority({ confirm: true }); + if (result.ok) { + toast.success(t("success", { count: result.data.summary.changedCount })); + queryClient.invalidateQueries({ queryKey: ["providers"] }); + setOpen(false); + } else { + toast.error(getActionErrorMessage(result)); + } + } catch (error) { + console.error("autoSortProviderPriority apply failed", error); + toast.error(t("error")); + } finally { + setIsApplying(false); + } + }; + + const hasChanges = previewData && previewData.summary.changedCount > 0; + + return ( + + + + + + + {t("dialogTitle")} + {t("dialogDescription")} + + +
+ {isPending ? ( +
+ +
+ ) : previewData ? ( + <> + {/* Summary */} +
+ {hasChanges + ? t("changeCount", { count: previewData.summary.changedCount }) + : t("noChanges")} +
+ + {/* Groups Preview Table */} + {previewData.groups.length > 0 && ( +
+ + + + {t("costMultiplierHeader")} + {t("priorityHeader")} + {t("providersHeader")} + + + + {previewData.groups.map((group) => ( + + {group.costMultiplier}x + + {group.priority} + + +
+ {group.providers.map((provider) => ( + + {provider.name} + + ))} +
+
+
+ ))} +
+
+
+ )} + + {/* Changes Detail */} + {hasChanges && ( +
+

{t("changesTitle")}

+
+ + + + {t("providerHeader")} + + {t("priorityChangeHeader")} + + + + + {previewData.changes.map((change) => ( + + + {change.name} + + ({change.costMultiplier}x) + + + +
+ + {change.oldPriority} + + + + {change.newPriority} + +
+
+
+ ))} +
+
+
+
+ )} + + ) : null} +
+ + + + + +
+
+ ); +} diff --git a/src/app/[locale]/settings/providers/_components/provider-manager-loader.tsx b/src/app/[locale]/settings/providers/_components/provider-manager-loader.tsx index f857e0fa5..b07fb3e0b 100644 --- a/src/app/[locale]/settings/providers/_components/provider-manager-loader.tsx +++ b/src/app/[locale]/settings/providers/_components/provider-manager-loader.tsx @@ -1,6 +1,6 @@ "use client"; -import { QueryClient, QueryClientProvider, useQuery } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { getProviderStatisticsAsync, getProviders, @@ -23,15 +23,6 @@ type ProviderHealthStatus = Record< } >; -const queryClient = new QueryClient({ - defaultOptions: { - queries: { - refetchOnWindowFocus: false, - staleTime: 30000, - }, - }, -}); - async function fetchSystemSettings(): Promise<{ currencyDisplay: CurrencyCode }> { const response = await fetch("/api/system-settings"); if (!response.ok) { @@ -56,6 +47,8 @@ function ProviderManagerLoaderContent({ } = useQuery({ queryKey: ["providers"], queryFn: getProviders, + refetchOnWindowFocus: false, + staleTime: 30_000, }); const { @@ -65,6 +58,8 @@ function ProviderManagerLoaderContent({ } = useQuery({ queryKey: ["providers-health"], queryFn: getProvidersHealthStatus, + refetchOnWindowFocus: false, + staleTime: 30_000, }); // Statistics loaded independently with longer cache @@ -72,6 +67,7 @@ function ProviderManagerLoaderContent({ useQuery({ queryKey: ["providers-statistics"], queryFn: getProviderStatisticsAsync, + refetchOnWindowFocus: false, staleTime: 30_000, refetchInterval: 60_000, }); @@ -83,6 +79,8 @@ function ProviderManagerLoaderContent({ } = useQuery<{ currencyDisplay: CurrencyCode }>({ queryKey: ["system-settings"], queryFn: fetchSystemSettings, + refetchOnWindowFocus: false, + staleTime: 30_000, }); const loading = isProvidersLoading || isHealthLoading || isSettingsLoading; @@ -106,9 +104,5 @@ function ProviderManagerLoaderContent({ } export function ProviderManagerLoader(props: ProviderManagerLoaderProps) { - return ( - - - - ); + return ; } diff --git a/src/app/[locale]/settings/providers/page.tsx b/src/app/[locale]/settings/providers/page.tsx index f8a7dd1ae..9f06333ae 100644 --- a/src/app/[locale]/settings/providers/page.tsx +++ b/src/app/[locale]/settings/providers/page.tsx @@ -6,6 +6,7 @@ import { Link } from "@/i18n/routing"; import { getSession } from "@/lib/auth"; import { getEnvConfig } from "@/lib/config/env.schema"; import { SettingsPageHeader } from "../_components/settings-page-header"; +import { AutoSortPriorityDialog } from "./_components/auto-sort-priority-dialog"; import { ProviderManagerLoader } from "./_components/provider-manager-loader"; import { SchedulingRulesDialog } from "./_components/scheduling-rules-dialog"; @@ -33,6 +34,7 @@ export default async function SettingsProvidersPage() { {t("providers.section.leaderboard")} + } diff --git a/src/components/ui/__tests__/code-display.test.tsx b/src/components/ui/__tests__/code-display.test.tsx index 49f0bb77f..836bf1793 100644 --- a/src/components/ui/__tests__/code-display.test.tsx +++ b/src/components/ui/__tests__/code-display.test.tsx @@ -7,38 +7,12 @@ import { act } from "react"; import { createRoot } from "react-dom/client"; import { NextIntlClientProvider } from "next-intl"; import { describe, expect, test, vi } from "vitest"; +import dashboardMessages from "@messages/en/dashboard.json"; import { CodeDisplay } from "@/components/ui/code-display"; +// Use real locale messages to ensure test stays in sync with actual translations const messages = { - dashboard: { - sessions: { - codeDisplay: { - raw: "Raw", - pretty: "Pretty", - searchPlaceholder: "Search", - expand: "Expand", - collapse: "Collapse", - themeAuto: "Auto", - themeLight: "Light", - themeDark: "Dark", - noMatches: "No matches", - onlyMatches: "Only matches", - showAll: "Show all", - prevPage: "Prev", - nextPage: "Next", - pageInfo: "Page {page} / {total}", - sseEvent: "Event", - sseData: "Data", - hardLimit: { - title: "Content too large", - size: "Size: {sizeMB} MB ({sizeBytes} bytes)", - maximum: "Maximum allowed: {maxSizeMB} MB or {maxLines} lines", - hint: "Please download the file to view the full content.", - download: "Download", - }, - }, - }, - }, + dashboard: dashboardMessages, } as const; function renderWithIntl(node: ReactNode) { @@ -208,7 +182,7 @@ describe("CodeDisplay", () => { "[data-testid='code-display-only-matches-toggle']" ) as HTMLElement; click(toggle); - expect(container.textContent).toContain("No matches"); + expect(container.textContent).toContain(dashboardMessages.sessions.codeDisplay.noMatches); act(() => { const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; @@ -245,7 +219,7 @@ describe("CodeDisplay", () => { setter?.call(input, "does-not-exist"); input.dispatchEvent(new Event("input", { bubbles: true })); }); - expect(container.textContent).toContain("No matches"); + expect(container.textContent).toContain(dashboardMessages.sessions.codeDisplay.noMatches); unmount(); }); @@ -295,8 +269,7 @@ describe("CodeDisplay", () => { ); - expect(container.textContent).toContain("Content too large"); - expect(container.textContent).toContain("1.00 MB"); + expect(container.textContent).toContain(dashboardMessages.sessions.codeDisplay.hardLimit.title); unmount(); }); @@ -331,8 +304,9 @@ describe("CodeDisplay", () => { click(downloadBtn); expect(createObjectURLSpy).toHaveBeenCalledTimes(1); - expect(lastAnchor?.download).toBe("huge.txt"); - expect(lastAnchor?.href).toBe("blob:mock"); + expect(lastAnchor).not.toBeNull(); + expect(lastAnchor!.download).toBe("huge.txt"); + expect(lastAnchor!.href).toBe("blob:mock"); const blob = createObjectURLSpy.mock.calls[0]?.[0] as Blob; expect(await blob.text()).toBe(hugeContent); @@ -352,8 +326,7 @@ describe("CodeDisplay", () => { ); - expect(container.textContent).toContain("Content too large"); - expect(container.textContent).toContain("10,000 lines"); + expect(container.textContent).toContain(dashboardMessages.sessions.codeDisplay.hardLimit.title); unmount(); }); }); diff --git a/src/repository/provider.ts b/src/repository/provider.ts index 4e1eed68d..357998331 100644 --- a/src/repository/provider.ts +++ b/src/repository/provider.ts @@ -495,6 +495,43 @@ export async function updateProvider( return toProvider(provider); } +export async function updateProviderPrioritiesBatch( + updates: Array<{ id: number; priority: number }> +): Promise { + if (updates.length === 0) { + return 0; + } + + // Deduplicate ids: last one wins + const updateMap = new Map(); + for (const update of updates) { + updateMap.set(update.id, update.priority); + } + + const ids = Array.from(updateMap.keys()); + const priorityCol = sql.identifier("priority"); + const updatedAtCol = sql.identifier("updated_at"); + const cases = ids.map((id) => sql`WHEN ${id} THEN ${updateMap.get(id)!}`); + + const idList = sql.join( + ids.map((id) => sql`${id}`), + sql`, ` + ); + + const query = sql` + UPDATE providers + SET + ${priorityCol} = CASE id ${sql.join(cases, sql` `)} ELSE ${priorityCol} END, + ${updatedAtCol} = NOW() + WHERE id IN (${idList}) AND deleted_at IS NULL + `; + + const result = await db.execute(query); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (result as any).rowCount || 0; +} + export async function deleteProvider(id: number): Promise { const result = await db .update(providers) diff --git a/tests/unit/actions/providers.test.ts b/tests/unit/actions/providers.test.ts index c6cec156b..53d783920 100644 --- a/tests/unit/actions/providers.test.ts +++ b/tests/unit/actions/providers.test.ts @@ -7,6 +7,7 @@ const getProviderStatisticsMock = vi.fn(); const createProviderMock = vi.fn(); const updateProviderMock = vi.fn(); const deleteProviderMock = vi.fn(); +const updateProviderPrioritiesBatchMock = vi.fn(); const publishProviderCacheInvalidationMock = vi.fn(); const saveProviderCircuitConfigMock = vi.fn(); @@ -29,6 +30,7 @@ vi.mock("@/repository/provider", () => ({ getProviderStatistics: getProviderStatisticsMock, resetProviderTotalCostResetAt: vi.fn(async () => {}), updateProvider: updateProviderMock, + updateProviderPrioritiesBatch: updateProviderPrioritiesBatchMock, })); vi.mock("@/lib/cache/provider-cache", () => ({ @@ -161,6 +163,7 @@ describe("Provider Actions - Async Optimization", () => { saveProviderCircuitConfigMock.mockResolvedValue(undefined); deleteProviderCircuitConfigMock.mockResolvedValue(undefined); clearProviderStateMock.mockResolvedValue(undefined); + updateProviderPrioritiesBatchMock.mockResolvedValue(0); }); describe("getProviders", () => { @@ -188,6 +191,209 @@ describe("Provider Actions - Async Optimization", () => { }); }); + describe("autoSortProviderPriority", () => { + it("should return preview only when confirm is false", async () => { + findAllProvidersFreshMock.mockResolvedValue([ + { id: 1, name: "a", costMultiplier: "2.0", priority: 0 } as any, + { id: 2, name: "b", costMultiplier: "1.0", priority: 1 } as any, + { id: 3, name: "c", costMultiplier: "1.0", priority: 9 } as any, + ]); + + const { autoSortProviderPriority } = await import("@/actions/providers"); + const result = await autoSortProviderPriority({ confirm: false }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.data.applied).toBe(false); + expect(result.data.summary.groupCount).toBe(2); + expect(result.data.summary.totalProviders).toBe(3); + expect(result.data.summary.changedCount).toBe(3); + expect(result.data.groups).toEqual([ + { + costMultiplier: 1, + priority: 0, + providers: [ + { id: 2, name: "b" }, + { id: 3, name: "c" }, + ], + }, + { + costMultiplier: 2, + priority: 1, + providers: [{ id: 1, name: "a" }], + }, + ]); + + expect(updateProviderPrioritiesBatchMock).not.toHaveBeenCalled(); + expect(publishProviderCacheInvalidationMock).not.toHaveBeenCalled(); + }); + + it("should handle invalid costMultiplier values gracefully", async () => { + findAllProvidersFreshMock.mockResolvedValue([ + { id: 1, name: "bad", costMultiplier: undefined, priority: 5 } as any, + { id: 2, name: "good", costMultiplier: "1.0", priority: 0 } as any, + ]); + + const { autoSortProviderPriority } = await import("@/actions/providers"); + const result = await autoSortProviderPriority({ confirm: false }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.data.summary.groupCount).toBe(2); + expect(result.data.groups).toEqual([ + { + costMultiplier: 0, + priority: 0, + providers: [{ id: 1, name: "bad" }], + }, + { + costMultiplier: 1, + priority: 1, + providers: [{ id: 2, name: "good" }], + }, + ]); + }); + + it("should apply changes when confirm is true", async () => { + findAllProvidersFreshMock.mockResolvedValue([ + { id: 10, name: "x", costMultiplier: "2.0", priority: 0 } as any, + { id: 20, name: "y", costMultiplier: "1.0", priority: 0 } as any, + ]); + + const { autoSortProviderPriority } = await import("@/actions/providers"); + const result = await autoSortProviderPriority({ confirm: true }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.data.applied).toBe(true); + expect(result.data.summary.changedCount).toBe(1); + + expect(updateProviderPrioritiesBatchMock).toHaveBeenCalledTimes(1); + expect(updateProviderPrioritiesBatchMock).toHaveBeenCalledWith([{ id: 10, priority: 1 }]); + expect(publishProviderCacheInvalidationMock).toHaveBeenCalledTimes(1); + }); + + it("should work with a single provider", async () => { + findAllProvidersFreshMock.mockResolvedValue([ + { id: 1, name: "only", costMultiplier: "1.0", priority: 9 } as any, + ]); + + const { autoSortProviderPriority } = await import("@/actions/providers"); + const result = await autoSortProviderPriority({ confirm: true }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.data.applied).toBe(true); + expect(result.data.summary.groupCount).toBe(1); + expect(result.data.summary.changedCount).toBe(1); + expect(updateProviderPrioritiesBatchMock).toHaveBeenCalledWith([{ id: 1, priority: 0 }]); + expect(publishProviderCacheInvalidationMock).toHaveBeenCalledTimes(1); + }); + + it("should set priority 0 for all providers when costMultiplier is the same", async () => { + findAllProvidersFreshMock.mockResolvedValue([ + { id: 1, name: "a", costMultiplier: "1.0", priority: 5 } as any, + { id: 2, name: "b", costMultiplier: "1.0", priority: 6 } as any, + { id: 3, name: "c", costMultiplier: "1.0", priority: 7 } as any, + ]); + + const { autoSortProviderPriority } = await import("@/actions/providers"); + const result = await autoSortProviderPriority({ confirm: true }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.data.groups).toEqual([ + { + costMultiplier: 1, + priority: 0, + providers: [ + { id: 1, name: "a" }, + { id: 2, name: "b" }, + { id: 3, name: "c" }, + ], + }, + ]); + expect(updateProviderPrioritiesBatchMock).toHaveBeenCalledWith([ + { id: 1, priority: 0 }, + { id: 2, priority: 0 }, + { id: 3, priority: 0 }, + ]); + expect(publishProviderCacheInvalidationMock).toHaveBeenCalledTimes(1); + }); + + it("should reject non-admin users", async () => { + getSessionMock.mockResolvedValueOnce({ user: { id: 2, role: "user" } }); + + const { autoSortProviderPriority } = await import("@/actions/providers"); + const result = await autoSortProviderPriority({ confirm: true }); + + expect(result.ok).toBe(false); + if (result.ok) return; + + expect(result.error).toBe("无权限执行此操作"); + expect(updateProviderPrioritiesBatchMock).not.toHaveBeenCalled(); + expect(publishProviderCacheInvalidationMock).not.toHaveBeenCalled(); + }); + + it("should not fail when cache invalidation publish throws", async () => { + publishProviderCacheInvalidationMock.mockRejectedValueOnce(new Error("boom")); + findAllProvidersFreshMock.mockResolvedValue([ + { id: 10, name: "x", costMultiplier: "2.0", priority: 0 } as any, + { id: 20, name: "y", costMultiplier: "1.0", priority: 0 } as any, + ]); + + const { autoSortProviderPriority } = await import("@/actions/providers"); + const result = await autoSortProviderPriority({ confirm: true }); + + expect(result.ok).toBe(true); + expect(updateProviderPrioritiesBatchMock).toHaveBeenCalledTimes(1); + expect(publishProviderCacheInvalidationMock).toHaveBeenCalledTimes(1); + }); + + it("should not write or invalidate cache when already sorted", async () => { + findAllProvidersFreshMock.mockResolvedValue([ + { id: 10, name: "x", costMultiplier: "1.0", priority: 0 } as any, + { id: 20, name: "y", costMultiplier: "2.0", priority: 1 } as any, + ]); + + const { autoSortProviderPriority } = await import("@/actions/providers"); + const result = await autoSortProviderPriority({ confirm: true }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(result.data.applied).toBe(true); + expect(result.data.changes).toEqual([]); + expect(updateProviderPrioritiesBatchMock).not.toHaveBeenCalled(); + expect(publishProviderCacheInvalidationMock).not.toHaveBeenCalled(); + }); + + it("should handle empty providers list", async () => { + findAllProvidersFreshMock.mockResolvedValue([]); + + const { autoSortProviderPriority } = await import("@/actions/providers"); + const preview = await autoSortProviderPriority({ confirm: false }); + const applied = await autoSortProviderPriority({ confirm: true }); + + expect(preview.ok).toBe(true); + if (preview.ok) { + expect(preview.data.summary.totalProviders).toBe(0); + expect(preview.data.applied).toBe(false); + } + + expect(applied.ok).toBe(true); + if (applied.ok) { + expect(applied.data.summary.totalProviders).toBe(0); + expect(applied.data.applied).toBe(true); + } + }); + }); + describe("getProviderStatisticsAsync", () => { it("should return statistics map by provider id", async () => { getProviderStatisticsMock.mockResolvedValue([ diff --git a/tests/unit/repository/provider.test.ts b/tests/unit/repository/provider.test.ts new file mode 100644 index 000000000..694c29e98 --- /dev/null +++ b/tests/unit/repository/provider.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, test, vi } from "vitest"; + +function sqlToString(sqlObj: unknown): string { + const stack = new Set(); + + const walk = (node: unknown): string => { + if (node === null || node === undefined) return ""; + + if (typeof node === "string") { + return node; + } + + if (typeof node === "number" || typeof node === "bigint" || typeof node === "boolean") { + return String(node); + } + + if (typeof node === "object") { + if (stack.has(node)) return ""; + stack.add(node); + + try { + const anyNode = node as any; + if (Array.isArray(anyNode)) { + return anyNode.map(walk).join(""); + } + + if (Object.hasOwn(anyNode, "value")) { + const { value } = anyNode; + if (Array.isArray(value)) { + return value.map(String).join(""); + } + if (value === null || value === undefined) return ""; + return String(value); + } + + if (anyNode.queryChunks) { + return walk(anyNode.queryChunks); + } + } finally { + stack.delete(node); + } + } + + return ""; + }; + + return walk(sqlObj); +} + +describe("provider repository - updateProviderPrioritiesBatch", () => { + test("returns 0 and does not execute SQL when updates is empty", async () => { + vi.resetModules(); + + const executeMock = vi.fn(async () => ({ rowCount: 0 })); + + vi.doMock("@/drizzle/db", () => ({ + db: { + execute: executeMock, + }, + })); + + const { updateProviderPrioritiesBatch } = await import("@/repository/provider"); + const result = await updateProviderPrioritiesBatch([]); + + expect(result).toBe(0); + expect(executeMock).not.toHaveBeenCalled(); + }); + + test("generates CASE batch update SQL and returns affected rows", async () => { + vi.resetModules(); + + const executeMock = vi.fn(async () => ({ rowCount: 2 })); + + vi.doMock("@/drizzle/db", () => ({ + db: { + execute: executeMock, + }, + })); + + const { updateProviderPrioritiesBatch } = await import("@/repository/provider"); + const result = await updateProviderPrioritiesBatch([ + { id: 1, priority: 0 }, + { id: 2, priority: 3 }, + ]); + + expect(result).toBe(2); + expect(executeMock).toHaveBeenCalledTimes(1); + + const queryArg = executeMock.mock.calls[0]?.[0]; + const sqlText = sqlToString(queryArg).replaceAll(/\s+/g, " ").trim(); + + expect(sqlText).toContain("UPDATE providers"); + expect(sqlText).toContain("SET"); + expect(sqlText).toContain("priority = CASE id"); + expect(sqlText).toContain("WHEN 1 THEN 0"); + expect(sqlText).toContain("WHEN 2 THEN 3"); + expect(sqlText).toContain("updated_at = NOW()"); + expect(sqlText).toContain("WHERE id IN (1, 2) AND deleted_at IS NULL"); + }); + + test("deduplicates provider ids (last update wins)", async () => { + vi.resetModules(); + + const executeMock = vi.fn(async () => ({ rowCount: 1 })); + + vi.doMock("@/drizzle/db", () => ({ + db: { + execute: executeMock, + }, + })); + + const { updateProviderPrioritiesBatch } = await import("@/repository/provider"); + const result = await updateProviderPrioritiesBatch([ + { id: 1, priority: 0 }, + { id: 1, priority: 2 }, + ]); + + expect(result).toBe(1); + expect(executeMock).toHaveBeenCalledTimes(1); + + const queryArg = executeMock.mock.calls[0]?.[0]; + const sqlText = sqlToString(queryArg).replaceAll(/\s+/g, " ").trim(); + + expect(sqlText).toContain("WHEN 1 THEN 2"); + expect(sqlText).toContain("WHERE id IN (1) AND deleted_at IS NULL"); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 6bdbb899e..9bda760ad 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -20,7 +20,8 @@ } ], "paths": { - "@/*": ["./src/*"] + "@/*": ["./src/*"], + "@messages/*": ["./messages/*"] } }, "include": [ @@ -31,5 +32,5 @@ ".next/types/**/*.ts", ".next/dev/types/**/*.ts" ], - "exclude": ["node_modules", "src/components/ui/**", "tests/**", "docs-site", ".next", "dist"] + "exclude": ["node_modules", "tests/**", "docs-site", ".next", "dist"] } diff --git a/vitest.config.ts b/vitest.config.ts index ef7897cba..ec86290f7 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -104,6 +104,7 @@ export default defineConfig({ "tests/api/users-actions.test.ts", "tests/api/providers-actions.test.ts", "tests/api/keys-actions.test.ts", + "tests/api/my-usage-readonly.test.ts", ], // ==================== 监听模式配置 ==================== @@ -131,6 +132,7 @@ export default defineConfig({ resolve: { alias: { "@": path.resolve(__dirname, "./src"), + "@messages": path.resolve(__dirname, "./messages"), // Mock server-only 包,避免测试环境报错 "server-only": path.resolve(__dirname, "./tests/server-only.mock.ts"), }, diff --git a/vitest.integration.config.ts b/vitest.integration.config.ts index c887aaaca..a9818dec7 100644 --- a/vitest.integration.config.ts +++ b/vitest.integration.config.ts @@ -16,12 +16,17 @@ export default defineConfig({ hookTimeout: 20000, maxConcurrency: 5, pool: "threads", - // 仅运行少量“需要数据库”的集成测试(避免把所有重依赖测试默认跑进 CI) - // 说明:仓库中存在其它“需要完整运行时/外部依赖”的集成测试,默认仍由主配置排除。 + // 仅运行"需要数据库"的集成测试(避免把所有重依赖测试默认跑进 CI) + // 说明:包括 tests/integration/ 目录和从主配置排除的需要 DB 的 API 测试 include: [ "tests/integration/webhook-targets-crud.test.ts", "tests/integration/notification-bindings.test.ts", "tests/integration/auth.test.ts", + // 需要 DB 的 API 测试(从主配置排除,在此运行) + "tests/api/users-actions.test.ts", + "tests/api/providers-actions.test.ts", + "tests/api/keys-actions.test.ts", + "tests/api/my-usage-readonly.test.ts", ], exclude: ["node_modules", ".next", "dist", "build", "coverage", "**/*.d.ts"], reporters: ["verbose"], From be460ab92dd49f4fd52938a5330a38918d042f4f Mon Sep 17 00:00:00 2001 From: Ding <44717411+ding113@users.noreply.github.com> Date: Sat, 10 Jan 2026 13:22:15 +0800 Subject: [PATCH 07/17] feat: thinking signature rectifier (#576) * feat: add thinking signature rectifier * fix: skip rectifier retry when no-op --- .gitignore | 1 + drizzle/0051_silent_maelstrom.sql | 1 + drizzle/meta/0051_snapshot.json | 2359 +++++++++++++++++ drizzle/meta/_journal.json | 7 + messages/en/settings.json | 2 + messages/ja/settings.json | 2 + messages/ru/settings.json | 2 + messages/zh-CN/settings.json | 2 + messages/zh-TW/settings.json | 2 + package.json | 1 + src/actions/my-usage.ts | 8 +- src/actions/system-config.ts | 2 + .../_components/system-settings-form.tsx | 23 + src/app/[locale]/settings/config/page.tsx | 1 + src/app/v1/_lib/proxy/forwarder.ts | 153 +- .../thinking-signature-rectifier.test.ts | 88 + .../proxy/thinking-signature-rectifier.ts | 123 + src/drizzle/schema.ts | 6 + src/lib/config/system-settings-cache.ts | 8 +- src/lib/utils/special-settings.ts | 12 + src/lib/validation/schemas.ts | 2 + src/repository/_shared/transformers.ts | 1 + src/repository/system-config.ts | 8 + src/types/special-settings.ts | 22 + src/types/system-config.ts | 7 + ...arder-thinking-signature-rectifier.test.ts | 269 ++ vitest.thinking-signature-rectifier.config.ts | 52 + 27 files changed, 3156 insertions(+), 8 deletions(-) create mode 100644 drizzle/0051_silent_maelstrom.sql create mode 100644 drizzle/meta/0051_snapshot.json create mode 100644 src/app/v1/_lib/proxy/thinking-signature-rectifier.test.ts create mode 100644 src/app/v1/_lib/proxy/thinking-signature-rectifier.ts create mode 100644 tests/unit/proxy/proxy-forwarder-thinking-signature-rectifier.test.ts create mode 100644 vitest.thinking-signature-rectifier.config.ts diff --git a/.gitignore b/.gitignore index 0ad9d53f5..0908e34db 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ /coverage-quota /coverage-my-usage /coverage-proxy-guard-pipeline +/coverage-thinking-signature-rectifier # next.js /.next/ diff --git a/drizzle/0051_silent_maelstrom.sql b/drizzle/0051_silent_maelstrom.sql new file mode 100644 index 000000000..987702efd --- /dev/null +++ b/drizzle/0051_silent_maelstrom.sql @@ -0,0 +1 @@ +ALTER TABLE "system_settings" ADD COLUMN "enable_thinking_signature_rectifier" boolean DEFAULT true NOT NULL; \ No newline at end of file diff --git a/drizzle/meta/0051_snapshot.json b/drizzle/meta/0051_snapshot.json new file mode 100644 index 000000000..058e96d6c --- /dev/null +++ b/drizzle/meta/0051_snapshot.json @@ -0,0 +1,2359 @@ +{ + "id": "c7b01fc8-2ed8-4359-a233-9fa3a2f7e8ec", + "prevId": "708bddf0-5d35-4367-ab3f-a50a84d57c5d", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.error_rules": { + "name": "error_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'regex'" + }, + "category": { + "name": "category", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "override_response": { + "name": "override_response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "override_status_code": { + "name": "override_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_error_rules_enabled": { + "name": "idx_error_rules_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unique_pattern": { + "name": "unique_pattern", + "columns": [ + { + "expression": "pattern", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_category": { + "name": "idx_category", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_match_type": { + "name": "idx_match_type", + "columns": [ + { + "expression": "match_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.keys": { + "name": "keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "can_login_web_ui": { + "name": "can_login_web_ui", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_daily_usd": { + "name": "limit_daily_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "provider_group": { + "name": "provider_group", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'default'" + }, + "cache_ttl_preference": { + "name": "cache_ttl_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_keys_user_id": { + "name": "idx_keys_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_created_at": { + "name": "idx_keys_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_deleted_at": { + "name": "idx_keys_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message_request": { + "name": "message_request", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "numeric(21, 15)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "request_sequence": { + "name": "request_sequence", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "provider_chain": { + "name": "provider_chain", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "api_type": { + "name": "api_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "endpoint": { + "name": "endpoint", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "original_model": { + "name": "original_model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ttfb_ms": { + "name": "ttfb_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_creation_input_tokens": { + "name": "cache_creation_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_read_input_tokens": { + "name": "cache_read_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_creation_5m_input_tokens": { + "name": "cache_creation_5m_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_creation_1h_input_tokens": { + "name": "cache_creation_1h_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_applied": { + "name": "cache_ttl_applied", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "context_1m_applied": { + "name": "context_1m_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "special_settings": { + "name": "special_settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_stack": { + "name": "error_stack", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_cause": { + "name": "error_cause", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "blocked_by": { + "name": "blocked_by", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "blocked_reason": { + "name": "blocked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "messages_count": { + "name": "messages_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_message_request_user_date_cost": { + "name": "idx_message_request_user_date_cost", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_query": { + "name": "idx_message_request_user_query", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_id": { + "name": "idx_message_request_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_seq": { + "name": "idx_message_request_session_seq", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_sequence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_endpoint": { + "name": "idx_message_request_endpoint", + "columns": [ + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_blocked_by": { + "name": "idx_message_request_blocked_by", + "columns": [ + { + "expression": "blocked_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_provider_id": { + "name": "idx_message_request_provider_id", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_id": { + "name": "idx_message_request_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key": { + "name": "idx_message_request_key", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_created_at": { + "name": "idx_message_request_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_deleted_at": { + "name": "idx_message_request_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model_prices": { + "name": "model_prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "model_name": { + "name": "model_name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "price_data": { + "name": "price_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_model_prices_latest": { + "name": "idx_model_prices_latest", + "columns": [ + { + "expression": "model_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_model_name": { + "name": "idx_model_prices_model_name", + "columns": [ + { + "expression": "model_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_created_at": { + "name": "idx_model_prices_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_settings": { + "name": "notification_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "use_legacy_mode": { + "name": "use_legacy_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "circuit_breaker_enabled": { + "name": "circuit_breaker_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "circuit_breaker_webhook": { + "name": "circuit_breaker_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "daily_leaderboard_enabled": { + "name": "daily_leaderboard_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "daily_leaderboard_webhook": { + "name": "daily_leaderboard_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "daily_leaderboard_time": { + "name": "daily_leaderboard_time", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false, + "default": "'09:00'" + }, + "daily_leaderboard_top_n": { + "name": "daily_leaderboard_top_n", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "cost_alert_enabled": { + "name": "cost_alert_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cost_alert_webhook": { + "name": "cost_alert_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "cost_alert_threshold": { + "name": "cost_alert_threshold", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": false, + "default": "'0.80'" + }, + "cost_alert_check_interval": { + "name": "cost_alert_check_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 60 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_target_bindings": { + "name": "notification_target_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "notification_type": { + "name": "notification_type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "schedule_cron": { + "name": "schedule_cron", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "schedule_timezone": { + "name": "schedule_timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'Asia/Shanghai'" + }, + "template_override": { + "name": "template_override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "unique_notification_target_binding": { + "name": "unique_notification_target_binding", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notification_bindings_type": { + "name": "idx_notification_bindings_type", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notification_bindings_target": { + "name": "idx_notification_bindings_target", + "columns": [ + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_target_bindings_target_id_webhook_targets_id_fk": { + "name": "notification_target_bindings_target_id_webhook_targets_id_fk", + "tableFrom": "notification_target_bindings", + "tableTo": "webhook_targets", + "columnsFrom": [ + "target_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.providers": { + "name": "providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "weight": { + "name": "weight", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false, + "default": "'1.0'" + }, + "group_tag": { + "name": "group_tag", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "provider_type": { + "name": "provider_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'claude'" + }, + "preserve_client_ip": { + "name": "preserve_client_ip", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "model_redirects": { + "name": "model_redirects", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "allowed_models": { + "name": "allowed_models", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "join_claude_pool": { + "name": "join_claude_pool", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "codex_instructions_strategy": { + "name": "codex_instructions_strategy", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'auto'" + }, + "mcp_passthrough_type": { + "name": "mcp_passthrough_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "mcp_passthrough_url": { + "name": "mcp_passthrough_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_daily_usd": { + "name": "limit_daily_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "total_cost_reset_at": { + "name": "total_cost_reset_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "max_retry_attempts": { + "name": "max_retry_attempts", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "circuit_breaker_failure_threshold": { + "name": "circuit_breaker_failure_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "circuit_breaker_open_duration": { + "name": "circuit_breaker_open_duration", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1800000 + }, + "circuit_breaker_half_open_success_threshold": { + "name": "circuit_breaker_half_open_success_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 2 + }, + "proxy_url": { + "name": "proxy_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "proxy_fallback_to_direct": { + "name": "proxy_fallback_to_direct", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "first_byte_timeout_streaming_ms": { + "name": "first_byte_timeout_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "streaming_idle_timeout_ms": { + "name": "streaming_idle_timeout_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "request_timeout_non_streaming_ms": { + "name": "request_timeout_non_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_preference": { + "name": "cache_ttl_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "context_1m_preference": { + "name": "context_1m_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_reasoning_effort_preference": { + "name": "codex_reasoning_effort_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_reasoning_summary_preference": { + "name": "codex_reasoning_summary_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_text_verbosity_preference": { + "name": "codex_text_verbosity_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "codex_parallel_tool_calls_preference": { + "name": "codex_parallel_tool_calls_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "tpm": { + "name": "tpm", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "rpm": { + "name": "rpm", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "rpd": { + "name": "rpd", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "cc": { + "name": "cc", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_providers_enabled_priority": { + "name": "idx_providers_enabled_priority", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "weight", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_group": { + "name": "idx_providers_group", + "columns": [ + { + "expression": "group_tag", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_created_at": { + "name": "idx_providers_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_deleted_at": { + "name": "idx_providers_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.request_filters": { + "name": "request_filters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "replacement": { + "name": "replacement", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "binding_type": { + "name": "binding_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'global'" + }, + "provider_ids": { + "name": "provider_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "group_tags": { + "name": "group_tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_request_filters_enabled": { + "name": "idx_request_filters_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_scope": { + "name": "idx_request_filters_scope", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_action": { + "name": "idx_request_filters_action", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_binding": { + "name": "idx_request_filters_binding", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "binding_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sensitive_words": { + "name": "sensitive_words", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "word": { + "name": "word", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'contains'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_sensitive_words_enabled": { + "name": "idx_sensitive_words_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "match_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_sensitive_words_created_at": { + "name": "idx_sensitive_words_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_settings": { + "name": "system_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "site_title": { + "name": "site_title", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "default": "'Claude Code Hub'" + }, + "allow_global_usage_view": { + "name": "allow_global_usage_view", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "currency_display": { + "name": "currency_display", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "billing_model_source": { + "name": "billing_model_source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'original'" + }, + "enable_auto_cleanup": { + "name": "enable_auto_cleanup", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "cleanup_retention_days": { + "name": "cleanup_retention_days", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30 + }, + "cleanup_schedule": { + "name": "cleanup_schedule", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'0 2 * * *'" + }, + "cleanup_batch_size": { + "name": "cleanup_batch_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10000 + }, + "enable_client_version_check": { + "name": "enable_client_version_check", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verbose_provider_error": { + "name": "verbose_provider_error", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_http2": { + "name": "enable_http2", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "intercept_anthropic_warmup_requests": { + "name": "intercept_anthropic_warmup_requests", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_thinking_signature_rectifier": { + "name": "enable_thinking_signature_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "enable_response_fixer": { + "name": "enable_response_fixer", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "response_fixer_config": { + "name": "response_fixer_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{\"fixTruncatedJson\":true,\"fixSseFormat\":true,\"fixEncoding\":true,\"maxJsonDepth\":200,\"maxFixSize\":1048576}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "rpm_limit": { + "name": "rpm_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "daily_limit_usd": { + "name": "daily_limit_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "provider_group": { + "name": "provider_group", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'default'" + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "allowed_clients": { + "name": "allowed_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "allowed_models": { + "name": "allowed_models", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_users_active_role_sort": { + "name": "idx_users_active_role_sort", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_enabled_expires_at": { + "name": "idx_users_enabled_expires_at", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_created_at": { + "name": "idx_users_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_deleted_at": { + "name": "idx_users_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_targets": { + "name": "webhook_targets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "webhook_provider_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "webhook_url": { + "name": "webhook_url", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false + }, + "telegram_bot_token": { + "name": "telegram_bot_token", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "dingtalk_secret": { + "name": "dingtalk_secret", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "custom_template": { + "name": "custom_template", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "custom_headers": { + "name": "custom_headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "proxy_url": { + "name": "proxy_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "proxy_fallback_to_direct": { + "name": "proxy_fallback_to_direct", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_test_at": { + "name": "last_test_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_test_result": { + "name": "last_test_result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.daily_reset_mode": { + "name": "daily_reset_mode", + "schema": "public", + "values": [ + "fixed", + "rolling" + ] + }, + "public.notification_type": { + "name": "notification_type", + "schema": "public", + "values": [ + "circuit_breaker", + "daily_leaderboard", + "cost_alert" + ] + }, + "public.webhook_provider_type": { + "name": "webhook_provider_type", + "schema": "public", + "values": [ + "wechat", + "feishu", + "dingtalk", + "telegram", + "custom" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index e2a568734..2da6ac2a7 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -358,6 +358,13 @@ "when": 1767894561890, "tag": "0050_flippant_jack_flag", "breakpoints": true + }, + { + "idx": 51, + "version": "7", + "when": 1767976327237, + "tag": "0051_silent_maelstrom", + "breakpoints": true } ] } \ No newline at end of file diff --git a/messages/en/settings.json b/messages/en/settings.json index eac7bfaeb..cddd6971c 100644 --- a/messages/en/settings.json +++ b/messages/en/settings.json @@ -104,6 +104,8 @@ "enableHttp2Desc": "When enabled, proxy requests will prefer HTTP/2 protocol. Automatically falls back to HTTP/1.1 on failure.", "interceptAnthropicWarmupRequests": "Intercept Warmup Requests (Anthropic)", "interceptAnthropicWarmupRequestsDesc": "When enabled, Claude Code warmup probe requests will be answered by CCH directly to avoid upstream provider calls; the request is logged for audit but is not billed, not rate-limited, and excluded from statistics.", + "enableThinkingSignatureRectifier": "Enable Thinking Signature Rectifier", + "enableThinkingSignatureRectifierDesc": "When Anthropic providers return thinking signature incompatibility or invalid request errors, automatically removes incompatible thinking blocks and retries once against the same provider (enabled by default).", "enableResponseFixer": "Enable Response Fixer", "enableResponseFixerDesc": "Automatically repairs common upstream response issues (encoding, SSE, truncated JSON). Enabled by default.", "responseFixerFixEncoding": "Fix encoding issues", diff --git a/messages/ja/settings.json b/messages/ja/settings.json index f7b2dbf6f..d4c391415 100644 --- a/messages/ja/settings.json +++ b/messages/ja/settings.json @@ -102,6 +102,8 @@ "verboseProviderErrorDesc": "有効にすると、すべてのプロバイダーが利用不可の場合に詳細なエラーメッセージ(プロバイダー数、レート制限の理由など)を返します。無効の場合は簡潔なエラーコードのみを返します。", "interceptAnthropicWarmupRequests": "Warmup リクエストを遮断(Anthropic)", "interceptAnthropicWarmupRequestsDesc": "有効にすると、Claude Code の Warmup プローブ要求は CCH が直接短い応答を返し、上流プロバイダーへのリクエストを回避します。ログには残りますが、課金/レート制限/統計には含まれません。", + "enableThinkingSignatureRectifier": "thinking 署名整流を有効化", + "enableThinkingSignatureRectifierDesc": "Anthropic プロバイダーで thinking 署名の不整合や不正なリクエストエラーが発生した場合、thinking 関連ブロックを削除して同一プロバイダーへ1回だけ再試行します(既定で有効)。", "enableResponseFixer": "レスポンス整流を有効化", "enableResponseFixerDesc": "上流応答の一般的な形式問題(エンコーディング、SSE、途切れた JSON)を自動修復します(既定で有効)。", "responseFixerFixEncoding": "エンコーディングを修復", diff --git a/messages/ru/settings.json b/messages/ru/settings.json index f73c3d9bf..8f7730d3b 100644 --- a/messages/ru/settings.json +++ b/messages/ru/settings.json @@ -102,6 +102,8 @@ "verboseProviderErrorDesc": "При включении возвращает подробные сообщения об ошибках при недоступности всех провайдеров (количество провайдеров, причины ограничений и т.д.); при отключении возвращает только простой код ошибки.", "interceptAnthropicWarmupRequests": "Перехватывать Warmup-запросы (Anthropic)", "interceptAnthropicWarmupRequestsDesc": "Если включено, Warmup-пробные запросы Claude Code будут отвечены самим CCH без обращения к провайдерам; запрос сохраняется в логах, но не тарифицируется, не учитывается в лимитах и исключается из статистики.", + "enableThinkingSignatureRectifier": "Включить исправление thinking-signature", + "enableThinkingSignatureRectifierDesc": "Если Anthropic-провайдер возвращает ошибку несовместимой подписи thinking или некорректного запроса, автоматически удаляет несовместимые thinking-блоки и повторяет запрос один раз к тому же провайдеру (включено по умолчанию).", "enableResponseFixer": "Включить исправление ответов", "enableResponseFixerDesc": "Автоматически исправляет распространённые проблемы ответа у провайдеров (кодировка, SSE, обрезанный JSON). Включено по умолчанию.", "responseFixerFixEncoding": "Исправлять кодировку", diff --git a/messages/zh-CN/settings.json b/messages/zh-CN/settings.json index b14bdd05f..147b1b227 100644 --- a/messages/zh-CN/settings.json +++ b/messages/zh-CN/settings.json @@ -85,6 +85,8 @@ "enableHttp2Desc": "启用后,代理请求将优先使用 HTTP/2 协议。如果 HTTP/2 失败,将自动降级到 HTTP/1.1。", "interceptAnthropicWarmupRequests": "拦截 Warmup 请求(Anthropic)", "interceptAnthropicWarmupRequestsDesc": "开启后,识别到 Claude Code 的 Warmup 探测请求将由 CCH 直接抢答短响应,避免访问上游供应商;该请求会记录在日志中,但不计费、不限流、不计入统计。", + "enableThinkingSignatureRectifier": "启用 thinking 签名整流器", + "enableThinkingSignatureRectifierDesc": "当 Anthropic 类型供应商返回 thinking 签名不兼容或非法请求等错误时,自动移除不兼容的 thinking 相关块并对同一供应商重试一次(默认开启)。", "enableResponseFixer": "启用响应整流", "enableResponseFixerDesc": "自动修复上游响应中常见的编码、SSE 与 JSON 格式问题(默认开启)。", "responseFixerFixEncoding": "修复编码问题", diff --git a/messages/zh-TW/settings.json b/messages/zh-TW/settings.json index 153ee73c9..fb33cf1b5 100644 --- a/messages/zh-TW/settings.json +++ b/messages/zh-TW/settings.json @@ -102,6 +102,8 @@ "verboseProviderErrorDesc": "開啟後,當所有供應商不可用時返回詳細錯誤資訊(包含供應商數量、限流原因等);關閉後僅返回簡潔錯誤碼。", "interceptAnthropicWarmupRequests": "攔截 Warmup 請求(Anthropic)", "interceptAnthropicWarmupRequestsDesc": "開啟後,識別到 Claude Code 的 Warmup 探測請求將由 CCH 直接搶答短回應,避免存取上游供應商;該請求會記錄在日誌中,但不計費、不限流、不計入統計。", + "enableThinkingSignatureRectifier": "啟用 thinking 簽名整流器", + "enableThinkingSignatureRectifierDesc": "當 Anthropic 類型供應商返回 thinking 簽名不相容或非法請求等錯誤時,自動移除不相容的 thinking 相關區塊並對同一供應商重試一次(預設開啟)。", "enableResponseFixer": "啟用回應整流", "enableResponseFixerDesc": "自動修復上游回應中常見的編碼、SSE 與 JSON 格式問題(預設開啟)。", "responseFixerFixEncoding": "修復編碼問題", diff --git a/package.json b/package.json index 8a8c639ae..b1b43852e 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "test:e2e": "vitest run --config vitest.e2e.config.ts --reporter=verbose", "test:integration": "vitest run --config vitest.integration.config.ts --reporter=verbose", "test:coverage": "vitest run --coverage", + "test:coverage:thinking-signature-rectifier": "vitest run --config vitest.thinking-signature-rectifier.config.ts --coverage", "test:coverage:quota": "vitest run --config vitest.quota.config.ts --coverage", "test:coverage:my-usage": "vitest run --config vitest.my-usage.config.ts --coverage", "test:coverage:proxy-guard-pipeline": "vitest run --config vitest.proxy-guard-pipeline.config.ts --coverage", diff --git a/src/actions/my-usage.ts b/src/actions/my-usage.ts index 17252e516..88b9741c7 100644 --- a/src/actions/my-usage.ts +++ b/src/actions/my-usage.ts @@ -541,12 +541,12 @@ export async function getMyStatsSummary( const settings = await getSystemSettings(); const currencyCode = settings.currencyDisplay; + // 日期字符串来自前端的 YYYY-MM-DD(目前使用 toISOString().split("T")[0] 生成),因此按 UTC 解析更一致。 + // 注意:new Date("YYYY-MM-DDT00:00:00") 会按本地时区解析,可能导致跨时区边界偏移。 const parsedStart = filters.startDate - ? new Date(`${filters.startDate}T00:00:00`).getTime() - : Number.NaN; - const parsedEnd = filters.endDate - ? new Date(`${filters.endDate}T00:00:00`).getTime() + ? Date.parse(`${filters.startDate}T00:00:00.000Z`) : Number.NaN; + const parsedEnd = filters.endDate ? Date.parse(`${filters.endDate}T00:00:00.000Z`) : Number.NaN; const startTime = Number.isFinite(parsedStart) ? parsedStart : undefined; // endTime 使用“次日零点”作为排他上界(created_at < endTime),避免 23:59:59.999 的边界问题 diff --git a/src/actions/system-config.ts b/src/actions/system-config.ts index 7ef36cdd2..eedf78985 100644 --- a/src/actions/system-config.ts +++ b/src/actions/system-config.ts @@ -38,6 +38,7 @@ export async function saveSystemSettings(formData: { verboseProviderError?: boolean; enableHttp2?: boolean; interceptAnthropicWarmupRequests?: boolean; + enableThinkingSignatureRectifier?: boolean; enableResponseFixer?: boolean; responseFixerConfig?: Partial; }): Promise> { @@ -61,6 +62,7 @@ export async function saveSystemSettings(formData: { verboseProviderError: validated.verboseProviderError, enableHttp2: validated.enableHttp2, interceptAnthropicWarmupRequests: validated.interceptAnthropicWarmupRequests, + enableThinkingSignatureRectifier: validated.enableThinkingSignatureRectifier, enableResponseFixer: validated.enableResponseFixer, responseFixerConfig: validated.responseFixerConfig, }); diff --git a/src/app/[locale]/settings/config/_components/system-settings-form.tsx b/src/app/[locale]/settings/config/_components/system-settings-form.tsx index 719565c3a..ad21c2327 100644 --- a/src/app/[locale]/settings/config/_components/system-settings-form.tsx +++ b/src/app/[locale]/settings/config/_components/system-settings-form.tsx @@ -30,6 +30,7 @@ interface SystemSettingsFormProps { | "verboseProviderError" | "enableHttp2" | "interceptAnthropicWarmupRequests" + | "enableThinkingSignatureRectifier" | "enableResponseFixer" | "responseFixerConfig" >; @@ -56,6 +57,9 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps) const [interceptAnthropicWarmupRequests, setInterceptAnthropicWarmupRequests] = useState( initialSettings.interceptAnthropicWarmupRequests ); + const [enableThinkingSignatureRectifier, setEnableThinkingSignatureRectifier] = useState( + initialSettings.enableThinkingSignatureRectifier + ); const [enableResponseFixer, setEnableResponseFixer] = useState( initialSettings.enableResponseFixer ); @@ -81,6 +85,7 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps) verboseProviderError, enableHttp2, interceptAnthropicWarmupRequests, + enableThinkingSignatureRectifier, enableResponseFixer, responseFixerConfig, }); @@ -98,6 +103,7 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps) setVerboseProviderError(result.data.verboseProviderError); setEnableHttp2(result.data.enableHttp2); setInterceptAnthropicWarmupRequests(result.data.interceptAnthropicWarmupRequests); + setEnableThinkingSignatureRectifier(result.data.enableThinkingSignatureRectifier); setEnableResponseFixer(result.data.enableResponseFixer); setResponseFixerConfig(result.data.responseFixerConfig); } @@ -227,6 +233,23 @@ export function SystemSettingsForm({ initialSettings }: SystemSettingsFormProps) /> +
+
+ +

+ {t("enableThinkingSignatureRectifierDesc")} +

+
+ setEnableThinkingSignatureRectifier(checked)} + disabled={isPending} + /> +
+
diff --git a/src/app/[locale]/settings/config/page.tsx b/src/app/[locale]/settings/config/page.tsx index 9fee99375..6b00d4669 100644 --- a/src/app/[locale]/settings/config/page.tsx +++ b/src/app/[locale]/settings/config/page.tsx @@ -41,6 +41,7 @@ async function SettingsConfigContent() { verboseProviderError: settings.verboseProviderError, enableHttp2: settings.enableHttp2, interceptAnthropicWarmupRequests: settings.interceptAnthropicWarmupRequests, + enableThinkingSignatureRectifier: settings.enableThinkingSignatureRectifier, enableResponseFixer: settings.enableResponseFixer, responseFixerConfig: settings.responseFixerConfig, }} diff --git a/src/app/v1/_lib/proxy/forwarder.ts b/src/app/v1/_lib/proxy/forwarder.ts index ec604301a..fc18c9be8 100644 --- a/src/app/v1/_lib/proxy/forwarder.ts +++ b/src/app/v1/_lib/proxy/forwarder.ts @@ -10,7 +10,7 @@ import { recordSuccess, } from "@/lib/circuit-breaker"; import { applyCodexProviderOverridesWithAudit } from "@/lib/codex/provider-overrides"; -import { isHttp2Enabled } from "@/lib/config"; +import { getCachedSystemSettings, isHttp2Enabled } from "@/lib/config"; import { getEnvConfig } from "@/lib/config/env.schema"; import { PROVIDER_DEFAULTS, PROVIDER_LIMITS } from "@/lib/constants/provider.constants"; import { logger } from "@/lib/logger"; @@ -41,6 +41,10 @@ import { mapClientFormatToTransformer, mapProviderTypeToTransformer } from "./fo import { ModelRedirector } from "./model-redirector"; import { ProxyProviderResolver } from "./provider-selector"; import type { ProxySession } from "./session"; +import { + detectThinkingSignatureRectifierTrigger, + rectifyAnthropicRequestMessage, +} from "./thinking-signature-rectifier"; const STANDARD_ENDPOINTS = [ "/v1/messages", @@ -201,10 +205,11 @@ export class ProxyForwarder { totalProvidersAttempted++; let attemptCount = 0; // 当前供应商的尝试次数 - const maxAttemptsPerProvider = resolveMaxAttemptsForProvider( + let maxAttemptsPerProvider = resolveMaxAttemptsForProvider( currentProvider, envDefaultMaxAttempts ); + let thinkingSignatureRectifierRetried = false; logger.info("ProxyForwarder: Trying provider", { providerId: currentProvider.id, @@ -374,7 +379,7 @@ export class ProxyForwarder { // ⭐ 1. 分类错误(供应商错误 vs 系统错误 vs 客户端中断) // 使用异步版本确保错误规则已加载 - const errorCategory = await categorizeErrorAsync(lastError); + let errorCategory = await categorizeErrorAsync(lastError); const errorMessage = lastError instanceof ProxyError ? lastError.getDetailedErrorMessage() @@ -411,6 +416,148 @@ export class ProxyForwarder { throw lastError; } + // 2.5 Thinking signature 整流器:命中后对同供应商“整流 + 重试一次” + // 目标:解决 Anthropic 与非 Anthropic 渠道切换导致的 thinking 签名不兼容问题 + // 约束: + // - 仅对 Anthropic 类型供应商生效 + // - 不依赖 error rules 开关(用户可能关闭规则,但仍希望整流生效) + // - 不计入熔断器、不触发供应商切换 + const isAnthropicProvider = + currentProvider.providerType === "claude" || + currentProvider.providerType === "claude-auth"; + const rectifierTrigger = isAnthropicProvider + ? detectThinkingSignatureRectifierTrigger(errorMessage) + : null; + + if (rectifierTrigger) { + const settings = await getCachedSystemSettings(); + const enabled = settings.enableThinkingSignatureRectifier ?? true; + + if (enabled) { + // 已重试过仍失败:强制按“不可重试的客户端错误”处理,避免污染熔断器/触发供应商切换 + if (thinkingSignatureRectifierRetried) { + errorCategory = ErrorCategory.NON_RETRYABLE_CLIENT_ERROR; + } else { + const requestDetailsBeforeRectify = buildRequestDetails(session); + + // 整流请求体(原地修改 session.request.message) + const rectified = rectifyAnthropicRequestMessage( + session.request.message as Record + ); + + // 写入审计字段(specialSettings) + session.addSpecialSetting({ + type: "thinking_signature_rectifier", + scope: "request", + hit: rectified.applied, + providerId: currentProvider.id, + providerName: currentProvider.name, + trigger: rectifierTrigger, + attemptNumber: attemptCount, + retryAttemptNumber: attemptCount + 1, + removedThinkingBlocks: rectified.removedThinkingBlocks, + removedRedactedThinkingBlocks: rectified.removedRedactedThinkingBlocks, + removedSignatureFields: rectified.removedSignatureFields, + }); + + const specialSettings = session.getSpecialSettings(); + if (specialSettings && session.sessionId) { + try { + await SessionManager.storeSessionSpecialSettings( + session.sessionId, + specialSettings, + session.requestSequence + ); + } catch (persistError) { + logger.error("[ProxyForwarder] Failed to store special settings", { + error: persistError, + sessionId: session.sessionId, + }); + } + } + + if (specialSettings && session.messageContext?.id) { + try { + await updateMessageRequestDetails(session.messageContext.id, { + specialSettings, + }); + } catch (persistError) { + logger.error("[ProxyForwarder] Failed to persist special settings", { + error: persistError, + messageRequestId: session.messageContext.id, + }); + } + } + + // 无任何可整流内容:不做无意义重试,直接走既有“不可重试客户端错误”分支 + if (!rectified.applied) { + logger.info( + "ProxyForwarder: Thinking signature rectifier not applicable, skipping retry", + { + providerId: currentProvider.id, + providerName: currentProvider.name, + trigger: rectifierTrigger, + attemptNumber: attemptCount, + } + ); + errorCategory = ErrorCategory.NON_RETRYABLE_CLIENT_ERROR; + } else { + logger.info("ProxyForwarder: Thinking signature rectifier applied, retrying", { + providerId: currentProvider.id, + providerName: currentProvider.name, + trigger: rectifierTrigger, + attemptNumber: attemptCount, + willRetryAttemptNumber: attemptCount + 1, + }); + + thinkingSignatureRectifierRetried = true; + + // 记录失败的第一次请求(以 retry_failed 体现“发生过一次重试”) + if (lastError instanceof ProxyError) { + session.addProviderToChain(currentProvider, { + reason: "retry_failed", + circuitState: getCircuitState(currentProvider.id), + attemptNumber: attemptCount, + errorMessage, + statusCode: lastError.statusCode, + errorDetails: { + provider: { + id: currentProvider.id, + name: currentProvider.name, + statusCode: lastError.statusCode, + statusText: lastError.message, + upstreamBody: lastError.upstreamError?.body, + upstreamParsed: lastError.upstreamError?.parsed, + }, + request: requestDetailsBeforeRectify, + }, + }); + } else { + session.addProviderToChain(currentProvider, { + reason: "retry_failed", + circuitState: getCircuitState(currentProvider.id), + attemptNumber: attemptCount, + errorMessage, + errorDetails: { + system: { + errorType: lastError.constructor.name, + errorName: lastError.name, + errorMessage: lastError.message || lastError.name || "Unknown error", + errorStack: lastError.stack?.split("\n").slice(0, 3).join("\n"), + }, + request: requestDetailsBeforeRectify, + }, + }); + } + + // 确保即使 maxAttemptsPerProvider=1 也能完成一次额外重试 + maxAttemptsPerProvider = Math.max(maxAttemptsPerProvider, attemptCount + 1); + continue; + } + } + } + } + // ⭐ 3. 不可重试的客户端输入错误处理(不计入熔断器,不重试,立即返回) if (errorCategory === ErrorCategory.NON_RETRYABLE_CLIENT_ERROR) { const proxyError = lastError as ProxyError; diff --git a/src/app/v1/_lib/proxy/thinking-signature-rectifier.test.ts b/src/app/v1/_lib/proxy/thinking-signature-rectifier.test.ts new file mode 100644 index 000000000..ae25421e2 --- /dev/null +++ b/src/app/v1/_lib/proxy/thinking-signature-rectifier.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from "vitest"; + +import { + detectThinkingSignatureRectifierTrigger, + rectifyAnthropicRequestMessage, +} from "./thinking-signature-rectifier"; + +describe("thinking-signature-rectifier", () => { + describe("detectThinkingSignatureRectifierTrigger", () => { + test("应命中:Invalid `signature` in `thinking` block(含反引号)", () => { + const trigger = detectThinkingSignatureRectifierTrigger( + "messages.1.content.0: Invalid `signature` in `thinking` block" + ); + expect(trigger).toBe("invalid_signature_in_thinking_block"); + }); + + test("应命中:Invalid signature in thinking block(无反引号/大小写混用)", () => { + const trigger = detectThinkingSignatureRectifierTrigger( + "Messages.1.Content.0: invalid signature in thinking block" + ); + expect(trigger).toBe("invalid_signature_in_thinking_block"); + }); + + test("应命中:非法请求/illegal request/invalid request", () => { + expect(detectThinkingSignatureRectifierTrigger("非法请求")).toBe("invalid_request"); + expect(detectThinkingSignatureRectifierTrigger("illegal request format")).toBe( + "invalid_request" + ); + expect(detectThinkingSignatureRectifierTrigger("invalid request: malformed JSON")).toBe( + "invalid_request" + ); + }); + + test("不应命中:无关错误", () => { + expect(detectThinkingSignatureRectifierTrigger("Request timeout")).toBeNull(); + }); + }); + + describe("rectifyAnthropicRequestMessage", () => { + test("应移除 thinking/redacted_thinking block,并移除非 thinking block 的 signature 字段", () => { + const message: Record = { + model: "claude-test", + messages: [ + { + role: "assistant", + content: [ + { type: "thinking", thinking: "t", signature: "sig_thinking" }, + { type: "text", text: "hello", signature: "sig_text_should_remove" }, + { + type: "tool_use", + id: "toolu_1", + name: "WebSearch", + input: { query: "q" }, + signature: "sig_tool_should_remove", + }, + { type: "redacted_thinking", data: "r", signature: "sig_redacted" }, + ], + }, + { + role: "user", + content: [{ type: "text", text: "hi" }], + }, + ], + }; + + const result = rectifyAnthropicRequestMessage(message); + expect(result.applied).toBe(true); + expect(result.removedThinkingBlocks).toBe(1); + expect(result.removedRedactedThinkingBlocks).toBe(1); + expect(result.removedSignatureFields).toBe(2); + + const messages = message.messages as any[]; + const content = messages[0].content as any[]; + expect(content.map((b) => b.type)).toEqual(["text", "tool_use"]); + expect(content[0].signature).toBeUndefined(); + expect(content[1].signature).toBeUndefined(); + }); + + test("无 messages 或 messages 不为数组时,应不修改", () => { + const message: Record = { model: "claude-test" }; + const result = rectifyAnthropicRequestMessage(message); + expect(result.applied).toBe(false); + expect(result.removedThinkingBlocks).toBe(0); + expect(result.removedRedactedThinkingBlocks).toBe(0); + expect(result.removedSignatureFields).toBe(0); + }); + }); +}); diff --git a/src/app/v1/_lib/proxy/thinking-signature-rectifier.ts b/src/app/v1/_lib/proxy/thinking-signature-rectifier.ts new file mode 100644 index 000000000..22b114978 --- /dev/null +++ b/src/app/v1/_lib/proxy/thinking-signature-rectifier.ts @@ -0,0 +1,123 @@ +export type ThinkingSignatureRectifierTrigger = + | "invalid_signature_in_thinking_block" + | "invalid_request"; + +export type ThinkingSignatureRectifierResult = { + applied: boolean; + removedThinkingBlocks: number; + removedRedactedThinkingBlocks: number; + removedSignatureFields: number; +}; + +/** + * 检测是否需要触发「thinking signature 整流器」 + * + * 注意:这里不依赖错误规则开关(error rules 可能被用户关闭),仅做字符串/正则判断。 + */ +export function detectThinkingSignatureRectifierTrigger( + errorMessage: string | null | undefined +): ThinkingSignatureRectifierTrigger | null { + if (!errorMessage) return null; + + const lower = errorMessage.toLowerCase(); + + // 兼容带/不带反引号、不同大小写的变体 + const looksLikeInvalidSignatureInThinkingBlock = + lower.includes("invalid") && + lower.includes("signature") && + lower.includes("thinking") && + lower.includes("block"); + + if (looksLikeInvalidSignatureInThinkingBlock) { + return "invalid_signature_in_thinking_block"; + } + + // 与默认错误规则保持一致(Issue #432 / Rule 6) + if (/非法请求|illegal request|invalid request/i.test(errorMessage)) { + return "invalid_request"; + } + + return null; +} + +/** + * 对 Anthropic 请求体做最小侵入整流: + * - 移除 messages[*].content 中的 thinking/redacted_thinking block(避免签名不兼容触发 400) + * - 移除非 thinking block 上遗留的 signature 字段(兼容跨渠道历史) + * + * 说明: + * - 仅在上游报错后、同供应商重试前调用,避免影响正常请求。 + * - 该函数会原地修改 message 对象(更适合代理链路的性能要求)。 + */ +export function rectifyAnthropicRequestMessage( + message: Record +): ThinkingSignatureRectifierResult { + const messages = message.messages; + if (!Array.isArray(messages)) { + return { + applied: false, + removedThinkingBlocks: 0, + removedRedactedThinkingBlocks: 0, + removedSignatureFields: 0, + }; + } + + let removedThinkingBlocks = 0; + let removedRedactedThinkingBlocks = 0; + let removedSignatureFields = 0; + let applied = false; + + for (const msg of messages) { + if (!msg || typeof msg !== "object") continue; + const msgObj = msg as Record; + const content = msgObj.content; + if (!Array.isArray(content)) continue; + + const newContent: unknown[] = []; + let contentWasModified = false; + + for (const block of content) { + if (!block || typeof block !== "object") { + newContent.push(block); + continue; + } + + const blockObj = block as Record; + const type = blockObj.type; + + if (type === "thinking") { + removedThinkingBlocks += 1; + contentWasModified = true; + continue; + } + + if (type === "redacted_thinking") { + removedRedactedThinkingBlocks += 1; + contentWasModified = true; + continue; + } + + if ("signature" in blockObj) { + const { signature: _signature, ...rest } = blockObj as any; + removedSignatureFields += 1; + contentWasModified = true; + newContent.push(rest); + continue; + } + + newContent.push(blockObj); + } + + if (contentWasModified) { + applied = true; + msgObj.content = newContent; + } + } + + return { + applied, + removedThinkingBlocks, + removedRedactedThinkingBlocks, + removedSignatureFields, + }; +} diff --git a/src/drizzle/schema.ts b/src/drizzle/schema.ts index e437f4bfc..f162fef90 100644 --- a/src/drizzle/schema.ts +++ b/src/drizzle/schema.ts @@ -485,6 +485,12 @@ export const systemSettings = pgTable('system_settings', { .notNull() .default(false), + // thinking signature 整流器(默认开启) + // 开启后:当 Anthropic 类型供应商出现 thinking 签名不兼容/非法请求等 400 错误时,自动整流并重试一次 + enableThinkingSignatureRectifier: boolean('enable_thinking_signature_rectifier') + .notNull() + .default(true), + // 响应整流(默认开启) enableResponseFixer: boolean('enable_response_fixer').notNull().default(true), responseFixerConfig: jsonb('response_fixer_config') diff --git a/src/lib/config/system-settings-cache.ts b/src/lib/config/system-settings-cache.ts index 89fba2576..8c0071627 100644 --- a/src/lib/config/system-settings-cache.ts +++ b/src/lib/config/system-settings-cache.ts @@ -26,10 +26,15 @@ let cachedAt: number = 0; /** Default settings used when cache fetch fails */ const DEFAULT_SETTINGS: Pick< SystemSettings, - "enableHttp2" | "interceptAnthropicWarmupRequests" | "enableResponseFixer" | "responseFixerConfig" + | "enableHttp2" + | "interceptAnthropicWarmupRequests" + | "enableThinkingSignatureRectifier" + | "enableResponseFixer" + | "responseFixerConfig" > = { enableHttp2: false, interceptAnthropicWarmupRequests: false, + enableThinkingSignatureRectifier: true, enableResponseFixer: true, responseFixerConfig: { fixTruncatedJson: true, @@ -97,6 +102,7 @@ export async function getCachedSystemSettings(): Promise { enableClientVersionCheck: false, enableHttp2: DEFAULT_SETTINGS.enableHttp2, interceptAnthropicWarmupRequests: DEFAULT_SETTINGS.interceptAnthropicWarmupRequests, + enableThinkingSignatureRectifier: DEFAULT_SETTINGS.enableThinkingSignatureRectifier, enableResponseFixer: DEFAULT_SETTINGS.enableResponseFixer, responseFixerConfig: DEFAULT_SETTINGS.responseFixerConfig, createdAt: new Date(), diff --git a/src/lib/utils/special-settings.ts b/src/lib/utils/special-settings.ts index b66cb07a7..91047eb83 100644 --- a/src/lib/utils/special-settings.ts +++ b/src/lib/utils/special-settings.ts @@ -55,6 +55,18 @@ function buildSettingKey(setting: SpecialSetting): string { return JSON.stringify([setting.type, setting.ttl]); case "anthropic_context_1m_header_override": return JSON.stringify([setting.type, setting.header, setting.flag]); + case "thinking_signature_rectifier": + return JSON.stringify([ + setting.type, + setting.hit, + setting.providerId ?? null, + setting.trigger, + setting.attemptNumber, + setting.retryAttemptNumber, + setting.removedThinkingBlocks, + setting.removedRedactedThinkingBlocks, + setting.removedSignatureFields, + ]); default: { // 兜底:保证即使未来扩展类型也不会导致运行时崩溃 const _exhaustive: never = setting; diff --git a/src/lib/validation/schemas.ts b/src/lib/validation/schemas.ts index ef7b010b3..38f26ce54 100644 --- a/src/lib/validation/schemas.ts +++ b/src/lib/validation/schemas.ts @@ -741,6 +741,8 @@ export const UpdateSystemSettingsSchema = z.object({ enableHttp2: z.boolean().optional(), // 可选拦截 Anthropic Warmup 请求(可选) interceptAnthropicWarmupRequests: z.boolean().optional(), + // thinking signature 整流器(可选) + enableThinkingSignatureRectifier: z.boolean().optional(), // 响应整流(可选) enableResponseFixer: z.boolean().optional(), responseFixerConfig: z diff --git a/src/repository/_shared/transformers.ts b/src/repository/_shared/transformers.ts index e0f0f4b1d..1b144a705 100644 --- a/src/repository/_shared/transformers.ts +++ b/src/repository/_shared/transformers.ts @@ -170,6 +170,7 @@ export function toSystemSettings(dbSettings: any): SystemSettings { verboseProviderError: dbSettings?.verboseProviderError ?? false, enableHttp2: dbSettings?.enableHttp2 ?? false, interceptAnthropicWarmupRequests: dbSettings?.interceptAnthropicWarmupRequests ?? false, + enableThinkingSignatureRectifier: dbSettings?.enableThinkingSignatureRectifier ?? true, enableResponseFixer: dbSettings?.enableResponseFixer ?? true, responseFixerConfig: { ...defaultResponseFixerConfig, diff --git a/src/repository/system-config.ts b/src/repository/system-config.ts index f64a79a5a..b5f2ee944 100644 --- a/src/repository/system-config.ts +++ b/src/repository/system-config.ts @@ -148,6 +148,7 @@ function createFallbackSettings(): SystemSettings { verboseProviderError: false, enableHttp2: false, interceptAnthropicWarmupRequests: false, + enableThinkingSignatureRectifier: true, enableResponseFixer: true, responseFixerConfig: { fixTruncatedJson: true, @@ -180,6 +181,7 @@ export async function getSystemSettings(): Promise { verboseProviderError: systemSettings.verboseProviderError, enableHttp2: systemSettings.enableHttp2, interceptAnthropicWarmupRequests: systemSettings.interceptAnthropicWarmupRequests, + enableThinkingSignatureRectifier: systemSettings.enableThinkingSignatureRectifier, enableResponseFixer: systemSettings.enableResponseFixer, responseFixerConfig: systemSettings.responseFixerConfig, createdAt: systemSettings.createdAt, @@ -312,6 +314,11 @@ export async function updateSystemSettings( updates.interceptAnthropicWarmupRequests = payload.interceptAnthropicWarmupRequests; } + // thinking signature 整流器开关(如果提供) + if (payload.enableThinkingSignatureRectifier !== undefined) { + updates.enableThinkingSignatureRectifier = payload.enableThinkingSignatureRectifier; + } + // 响应整流开关(如果提供) if (payload.enableResponseFixer !== undefined) { updates.enableResponseFixer = payload.enableResponseFixer; @@ -342,6 +349,7 @@ export async function updateSystemSettings( verboseProviderError: systemSettings.verboseProviderError, enableHttp2: systemSettings.enableHttp2, interceptAnthropicWarmupRequests: systemSettings.interceptAnthropicWarmupRequests, + enableThinkingSignatureRectifier: systemSettings.enableThinkingSignatureRectifier, enableResponseFixer: systemSettings.enableResponseFixer, responseFixerConfig: systemSettings.responseFixerConfig, createdAt: systemSettings.createdAt, diff --git a/src/types/special-settings.ts b/src/types/special-settings.ts index 7ef0d2cca..a989e9bde 100644 --- a/src/types/special-settings.ts +++ b/src/types/special-settings.ts @@ -9,6 +9,7 @@ export type SpecialSetting = | ProviderParameterOverrideSpecialSetting | ResponseFixerSpecialSetting | GuardInterceptSpecialSetting + | ThinkingSignatureRectifierSpecialSetting | AnthropicCacheTtlHeaderOverrideSpecialSetting | AnthropicContext1mHeaderOverrideSpecialSetting; @@ -85,3 +86,24 @@ export type AnthropicContext1mHeaderOverrideSpecialSetting = { header: "anthropic-beta"; flag: string; }; + +/** + * Thinking signature 整流器审计 + * + * 用于记录:当 Anthropic 类型供应商遇到 thinking 签名不兼容/非法请求等 400 错误时, + * 代理对请求体进行最小整流(移除 thinking/redacted_thinking 与遗留 signature 字段) + * 并对同供应商自动重试一次的行为,便于在请求日志中审计与回溯。 + */ +export type ThinkingSignatureRectifierSpecialSetting = { + type: "thinking_signature_rectifier"; + scope: "request"; + hit: boolean; + providerId: number | null; + providerName: string | null; + trigger: "invalid_signature_in_thinking_block" | "invalid_request"; + attemptNumber: number; + retryAttemptNumber: number; + removedThinkingBlocks: number; + removedRedactedThinkingBlocks: number; + removedSignatureFields: number; +}; diff --git a/src/types/system-config.ts b/src/types/system-config.ts index d74466de4..02bf316b6 100644 --- a/src/types/system-config.ts +++ b/src/types/system-config.ts @@ -40,6 +40,10 @@ export interface SystemSettings { // 可选拦截 Anthropic Warmup 请求(默认关闭) interceptAnthropicWarmupRequests: boolean; + // thinking signature 整流器(默认开启) + // 目标:当 Anthropic 类型供应商出现 thinking 签名不兼容导致的 400 错误时,自动整流并重试一次 + enableThinkingSignatureRectifier: boolean; + // 响应整流(默认开启) enableResponseFixer: boolean; responseFixerConfig: ResponseFixerConfig; @@ -77,6 +81,9 @@ export interface UpdateSystemSettingsInput { // 可选拦截 Anthropic Warmup 请求(可选) interceptAnthropicWarmupRequests?: boolean; + // thinking signature 整流器(可选) + enableThinkingSignatureRectifier?: boolean; + // 响应整流(可选) enableResponseFixer?: boolean; responseFixerConfig?: Partial; diff --git a/tests/unit/proxy/proxy-forwarder-thinking-signature-rectifier.test.ts b/tests/unit/proxy/proxy-forwarder-thinking-signature-rectifier.test.ts new file mode 100644 index 000000000..2c8505ea6 --- /dev/null +++ b/tests/unit/proxy/proxy-forwarder-thinking-signature-rectifier.test.ts @@ -0,0 +1,269 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const mocks = vi.hoisted(() => { + return { + getCachedSystemSettings: vi.fn(async () => ({ + enableThinkingSignatureRectifier: true, + })), + recordSuccess: vi.fn(), + recordFailure: vi.fn(async () => {}), + getCircuitState: vi.fn(() => "closed"), + getProviderHealthInfo: vi.fn(async () => ({ + health: { failureCount: 0 }, + config: { failureThreshold: 3 }, + })), + updateMessageRequestDetails: vi.fn(async () => {}), + }; +}); + +vi.mock("@/lib/config", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + isHttp2Enabled: vi.fn(async () => false), + getCachedSystemSettings: mocks.getCachedSystemSettings, + }; +}); + +vi.mock("@/lib/circuit-breaker", () => ({ + getCircuitState: mocks.getCircuitState, + getProviderHealthInfo: mocks.getProviderHealthInfo, + recordFailure: mocks.recordFailure, + recordSuccess: mocks.recordSuccess, +})); + +vi.mock("@/repository/message", () => ({ + updateMessageRequestDetails: mocks.updateMessageRequestDetails, +})); + +import { ProxyForwarder } from "@/app/v1/_lib/proxy/forwarder"; +import { ProxyError } from "@/app/v1/_lib/proxy/errors"; +import { ProxySession } from "@/app/v1/_lib/proxy/session"; +import type { Provider } from "@/types/provider"; + +function createSession(): ProxySession { + const headers = new Headers(); + const session = Object.create(ProxySession.prototype); + + Object.assign(session, { + startTime: Date.now(), + method: "POST", + requestUrl: new URL("https://example.com/v1/messages"), + headers, + originalHeaders: new Headers(headers), + headerLog: JSON.stringify(Object.fromEntries(headers.entries())), + request: { + model: "claude-test", + log: "", + message: { + model: "claude-test", + messages: [ + { + role: "assistant", + content: [ + { type: "thinking", thinking: "t", signature: "sig_thinking" }, + { type: "text", text: "hello", signature: "sig_text_should_remove" }, + { type: "redacted_thinking", data: "r", signature: "sig_redacted" }, + ], + }, + ], + }, + }, + userAgent: null, + context: null, + clientAbortSignal: null, + userName: "test-user", + authState: { success: true, user: null, key: null, apiKey: null }, + provider: null, + messageContext: { id: 123, createdAt: new Date(), user: { id: 1 }, key: {}, apiKey: "k" }, + sessionId: null, + requestSequence: 1, + originalFormat: "claude", + providerType: null, + originalModelName: null, + originalUrlPathname: null, + providerChain: [], + cacheTtlResolved: null, + context1mApplied: false, + specialSettings: [], + cachedPriceData: undefined, + cachedBillingModelSource: undefined, + isHeaderModified: () => false, + }); + + return session as any; +} + +function createAnthropicProvider(): Provider { + return { + id: 1, + name: "anthropic-1", + providerType: "claude", + url: "https://example.com/v1/messages", + key: "k", + preserveClientIp: false, + priority: 0, + } as unknown as Provider; +} + +describe("ProxyForwarder - thinking signature rectifier", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("首次命中特定 400 错误时应整流并对同供应商重试一次(成功后不抛错)", async () => { + const session = createSession(); + session.setProvider(createAnthropicProvider()); + + const doForward = vi.spyOn(ProxyForwarder as any, "doForward"); + + doForward.mockImplementationOnce(async () => { + throw new ProxyError("Invalid `signature` in `thinking` block", 400, { + body: "", + providerId: 1, + providerName: "anthropic-1", + }); + }); + + doForward.mockImplementationOnce(async (s: ProxySession) => { + const msg = s.request.message as any; + const blocks = msg.messages[0].content as any[]; + expect(blocks.some((b) => b.type === "thinking")).toBe(false); + expect(blocks.some((b) => b.type === "redacted_thinking")).toBe(false); + expect(blocks.some((b) => "signature" in b)).toBe(false); + + const body = JSON.stringify({ + type: "message", + content: [{ type: "text", text: "ok" }], + }); + + return new Response(body, { + status: 200, + headers: { + "content-type": "application/json", + "content-length": String(body.length), + }, + }); + }); + + const response = await ProxyForwarder.send(session); + + expect(response.status).toBe(200); + expect(doForward).toHaveBeenCalledTimes(2); + expect(session.getProviderChain()?.length).toBeGreaterThanOrEqual(2); + + const special = session.getSpecialSettings(); + expect(special).not.toBeNull(); + expect(JSON.stringify(special)).toContain("thinking_signature_rectifier"); + expect(mocks.updateMessageRequestDetails).toHaveBeenCalledTimes(1); + }); + + test("命中 invalid request 相关 400 错误时也应整流并对同供应商重试一次", async () => { + const session = createSession(); + session.setProvider(createAnthropicProvider()); + + const doForward = vi.spyOn(ProxyForwarder as any, "doForward"); + + doForward.mockImplementationOnce(async () => { + throw new ProxyError("invalid request: malformed content", 400, { + body: "", + providerId: 1, + providerName: "anthropic-1", + }); + }); + + doForward.mockImplementationOnce(async (s: ProxySession) => { + const msg = s.request.message as any; + const blocks = msg.messages[0].content as any[]; + expect(blocks.some((b) => b.type === "thinking")).toBe(false); + expect(blocks.some((b) => b.type === "redacted_thinking")).toBe(false); + expect(blocks.some((b) => "signature" in b)).toBe(false); + + const body = JSON.stringify({ + type: "message", + content: [{ type: "text", text: "ok" }], + }); + + return new Response(body, { + status: 200, + headers: { + "content-type": "application/json", + "content-length": String(body.length), + }, + }); + }); + + const response = await ProxyForwarder.send(session); + + expect(response.status).toBe(200); + expect(doForward).toHaveBeenCalledTimes(2); + expect(session.getProviderChain()?.length).toBeGreaterThanOrEqual(2); + + const special = session.getSpecialSettings(); + expect(special).not.toBeNull(); + expect(JSON.stringify(special)).toContain("thinking_signature_rectifier"); + expect(mocks.updateMessageRequestDetails).toHaveBeenCalledTimes(1); + }); + + test("匹配触发但无可整流内容时不应做无意义重试", async () => { + const session = createSession(); + session.setProvider(createAnthropicProvider()); + + const msg = session.request.message as any; + msg.messages[0].content = [{ type: "text", text: "hello" }]; + + const doForward = vi.spyOn(ProxyForwarder as any, "doForward"); + + doForward.mockImplementationOnce(async () => { + throw new ProxyError("Invalid `signature` in `thinking` block", 400, { + body: "", + providerId: 1, + providerName: "anthropic-1", + }); + }); + + await expect(ProxyForwarder.send(session)).rejects.toBeInstanceOf(ProxyError); + expect(doForward).toHaveBeenCalledTimes(1); + + // 仍应写入一次审计字段,但不应触发第二次 doForward 调用 + expect(mocks.updateMessageRequestDetails).toHaveBeenCalledTimes(1); + + const special = (session.getSpecialSettings() ?? []) as any[]; + const rectifier = special.find((s) => s.type === "thinking_signature_rectifier"); + expect(rectifier).toBeTruthy(); + expect(rectifier.hit).toBe(false); + }); + + test("重试后仍失败时应停止继续重试/切换,并按最终错误抛出", async () => { + const session = createSession(); + session.setProvider(createAnthropicProvider()); + + const doForward = vi.spyOn(ProxyForwarder as any, "doForward"); + + doForward.mockImplementationOnce(async () => { + throw new ProxyError("Invalid `signature` in `thinking` block", 400, { + body: "", + providerId: 1, + providerName: "anthropic-1", + }); + }); + + doForward.mockImplementationOnce(async () => { + throw new ProxyError("Invalid `signature` in `thinking` block", 400, { + body: "", + providerId: 1, + providerName: "anthropic-1", + }); + }); + + await expect(ProxyForwarder.send(session)).rejects.toBeInstanceOf(ProxyError); + expect(doForward).toHaveBeenCalledTimes(2); + + // 第一次失败会写入审计字段,且只需要写一次(同一条 message_request 记录) + expect(mocks.updateMessageRequestDetails).toHaveBeenCalledTimes(1); + + const special = session.getSpecialSettings(); + expect(special).not.toBeNull(); + expect(JSON.stringify(special)).toContain("thinking_signature_rectifier"); + }); +}); diff --git a/vitest.thinking-signature-rectifier.config.ts b/vitest.thinking-signature-rectifier.config.ts new file mode 100644 index 000000000..63f7d0079 --- /dev/null +++ b/vitest.thinking-signature-rectifier.config.ts @@ -0,0 +1,52 @@ +import path from "node:path"; +import { defineConfig } from "vitest/config"; + +/** + * thinking signature 整流器专项覆盖率配置 + * + * 目的: + * - 仅统计本次新增的整流器模块,避免把 Next/DB/Redis 等重模块纳入阈值 + * - 对“错误整流 + 重试一次”这类稳定性修复设置覆盖率门槛(>= 80%) + */ +export default defineConfig({ + test: { + globals: true, + environment: "node", + setupFiles: ["./tests/setup.ts"], + + include: [ + "src/app/v1/_lib/proxy/thinking-signature-rectifier.test.ts", + "tests/unit/proxy/proxy-forwarder-thinking-signature-rectifier.test.ts", + ], + exclude: ["node_modules", ".next", "dist", "build", "coverage", "tests/integration/**"], + + coverage: { + provider: "v8", + reporter: ["text", "html", "json"], + reportsDirectory: "./coverage-thinking-signature-rectifier", + + include: ["src/app/v1/_lib/proxy/thinking-signature-rectifier.ts"], + exclude: ["node_modules/", "tests/", "**/*.d.ts", ".next/"], + + thresholds: { + lines: 80, + functions: 80, + branches: 70, + statements: 80, + }, + }, + + reporters: ["verbose"], + isolate: true, + mockReset: true, + restoreMocks: true, + clearMocks: true, + }, + + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + "server-only": path.resolve(__dirname, "./tests/server-only.mock.ts"), + }, + }, +}); From ecce1f528e5dbee6d4f334c31e19b0dff63322e5 Mon Sep 17 00:00:00 2001 From: NieiR <31194814+NieiR@users.noreply.github.com> Date: Sat, 10 Jan 2026 17:26:37 +0800 Subject: [PATCH 08/17] =?UTF-8?q?feat(prices):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E6=89=8B=E5=8A=A8=E6=A8=A1=E5=9E=8B=E4=BB=B7=E6=A0=BC=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=8A=9F=E8=83=BD=20(#573)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: enable provider group editing in edit user dialog - Always show providerGroup field in edit mode (was hidden when user had no providerGroup) - Replace read-only Badge display with editable ProviderGroupSelect component - Move modelSuggestions hook after form declaration to support dynamic updates Regression from #539 * fix: add complete translations for ProviderGroupSelect in edit user dialog Pass full translations object to ProviderGroupSelect including: - tagInputErrors for validation messages (empty, duplicate, too_long, etc.) - errors.loadFailed for API error handling - providersSuffix for provider count display This fixes untranslated error messages when users input invalid provider group tags. * feat(prices): 添加手动模型价格管理功能 - 新增 source 字段区分 litellm/manual 来源 - 支持手动添加、编辑、删除模型价格 - LiteLLM 同步时自动跳过手动价格,避免覆盖 - 添加冲突检测和解决 UI,支持批量处理 - 完整的单元测试覆盖 closes #405 * fix: 修复 CI 检查问题 - 移除未使用的 ModelPriceSource 导入 - 修复 useEffect 依赖数组 (fetchPrices) - 修复 fetchPrices 声明前使用问题 - 添加价格非负数验证 - 格式化代码 * fix: wrap upsertModelPrice in transaction for data integrity --- drizzle/0052_model_price_source.sql | 2 + drizzle/meta/0052_snapshot.json | 2374 +++++++++++++++++ drizzle/meta/_journal.json | 9 +- messages/en/dashboard.json | 4 + messages/en/settings.json | 64 +- messages/ja/dashboard.json | 4 + messages/ja/settings.json | 64 +- messages/ru/dashboard.json | 4 + messages/ru/settings.json | 64 +- messages/zh-CN/dashboard.json | 4 + messages/zh-CN/settings.json | 64 +- messages/zh-TW/dashboard.json | 4 + messages/zh-TW/settings.json | 64 +- src/actions/model-prices.ts | 217 +- .../_components/user/edit-user-dialog.tsx | 11 +- .../user/forms/user-edit-section.tsx | 33 +- .../user/hooks/use-user-translations.ts | 25 +- .../_components/delete-model-dialog.tsx | 92 + .../prices/_components/model-price-dialog.tsx | 272 ++ .../prices/_components/price-list.tsx | 69 +- .../_components/sync-conflict-dialog.tsx | 391 +++ .../_components/sync-litellm-button.tsx | 100 +- .../_components/upload-price-dialog.tsx | 1 + src/app/[locale]/settings/prices/page.tsx | 2 + src/drizzle/schema.ts | 4 + src/lib/price-sync.ts | 2 +- src/repository/_shared/transformers.ts | 1 + src/repository/model-price.ts | 118 +- src/types/model-price.ts | 24 + tests/unit/actions/model-prices.test.ts | 448 ++++ 30 files changed, 4475 insertions(+), 60 deletions(-) create mode 100644 drizzle/0052_model_price_source.sql create mode 100644 drizzle/meta/0052_snapshot.json create mode 100644 src/app/[locale]/settings/prices/_components/delete-model-dialog.tsx create mode 100644 src/app/[locale]/settings/prices/_components/model-price-dialog.tsx create mode 100644 src/app/[locale]/settings/prices/_components/sync-conflict-dialog.tsx create mode 100644 tests/unit/actions/model-prices.test.ts diff --git a/drizzle/0052_model_price_source.sql b/drizzle/0052_model_price_source.sql new file mode 100644 index 000000000..5075e48f4 --- /dev/null +++ b/drizzle/0052_model_price_source.sql @@ -0,0 +1,2 @@ +ALTER TABLE "model_prices" ADD COLUMN "source" varchar(20) DEFAULT 'litellm' NOT NULL;--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_model_prices_source" ON "model_prices" USING btree ("source"); \ No newline at end of file diff --git a/drizzle/meta/0052_snapshot.json b/drizzle/meta/0052_snapshot.json new file mode 100644 index 000000000..62239cbb4 --- /dev/null +++ b/drizzle/meta/0052_snapshot.json @@ -0,0 +1,2374 @@ +{ + "id": "e7a58fbf-6e7a-4c5f-a0ac-255fcf6439d7", + "prevId": "c7b01fc8-2ed8-4359-a233-9fa3a2f7e8ec", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.error_rules": { + "name": "error_rules", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'regex'" + }, + "category": { + "name": "category", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "override_response": { + "name": "override_response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "override_status_code": { + "name": "override_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_error_rules_enabled": { + "name": "idx_error_rules_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unique_pattern": { + "name": "unique_pattern", + "columns": [ + { + "expression": "pattern", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_category": { + "name": "idx_category", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_match_type": { + "name": "idx_match_type", + "columns": [ + { + "expression": "match_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.keys": { + "name": "keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "can_login_web_ui": { + "name": "can_login_web_ui", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_daily_usd": { + "name": "limit_daily_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "provider_group": { + "name": "provider_group", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'default'" + }, + "cache_ttl_preference": { + "name": "cache_ttl_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_keys_user_id": { + "name": "idx_keys_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_created_at": { + "name": "idx_keys_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_keys_deleted_at": { + "name": "idx_keys_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message_request": { + "name": "message_request", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "numeric(21, 15)", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "request_sequence": { + "name": "request_sequence", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "provider_chain": { + "name": "provider_chain", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "api_type": { + "name": "api_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "endpoint": { + "name": "endpoint", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "original_model": { + "name": "original_model", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ttfb_ms": { + "name": "ttfb_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_creation_input_tokens": { + "name": "cache_creation_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_read_input_tokens": { + "name": "cache_read_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_creation_5m_input_tokens": { + "name": "cache_creation_5m_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_creation_1h_input_tokens": { + "name": "cache_creation_1h_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_applied": { + "name": "cache_ttl_applied", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "context_1m_applied": { + "name": "context_1m_applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "special_settings": { + "name": "special_settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_stack": { + "name": "error_stack", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_cause": { + "name": "error_cause", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "blocked_by": { + "name": "blocked_by", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "blocked_reason": { + "name": "blocked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "messages_count": { + "name": "messages_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_message_request_user_date_cost": { + "name": "idx_message_request_user_date_cost", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_usd", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_query": { + "name": "idx_message_request_user_query", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_id": { + "name": "idx_message_request_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_session_seq": { + "name": "idx_message_request_session_seq", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_sequence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_endpoint": { + "name": "idx_message_request_endpoint", + "columns": [ + { + "expression": "endpoint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_blocked_by": { + "name": "idx_message_request_blocked_by", + "columns": [ + { + "expression": "blocked_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message_request\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_provider_id": { + "name": "idx_message_request_provider_id", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_user_id": { + "name": "idx_message_request_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_key": { + "name": "idx_message_request_key", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_created_at": { + "name": "idx_message_request_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_request_deleted_at": { + "name": "idx_message_request_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model_prices": { + "name": "model_prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "model_name": { + "name": "model_name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "price_data": { + "name": "price_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "source": { + "name": "source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'litellm'" + } + }, + "indexes": { + "idx_model_prices_latest": { + "name": "idx_model_prices_latest", + "columns": [ + { + "expression": "model_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_model_name": { + "name": "idx_model_prices_model_name", + "columns": [ + { + "expression": "model_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_created_at": { + "name": "idx_model_prices_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_model_prices_source": { + "name": "idx_model_prices_source", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_settings": { + "name": "notification_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "use_legacy_mode": { + "name": "use_legacy_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "circuit_breaker_enabled": { + "name": "circuit_breaker_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "circuit_breaker_webhook": { + "name": "circuit_breaker_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "daily_leaderboard_enabled": { + "name": "daily_leaderboard_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "daily_leaderboard_webhook": { + "name": "daily_leaderboard_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "daily_leaderboard_time": { + "name": "daily_leaderboard_time", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false, + "default": "'09:00'" + }, + "daily_leaderboard_top_n": { + "name": "daily_leaderboard_top_n", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "cost_alert_enabled": { + "name": "cost_alert_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cost_alert_webhook": { + "name": "cost_alert_webhook", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "cost_alert_threshold": { + "name": "cost_alert_threshold", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": false, + "default": "'0.80'" + }, + "cost_alert_check_interval": { + "name": "cost_alert_check_interval", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 60 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification_target_bindings": { + "name": "notification_target_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "notification_type": { + "name": "notification_type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "schedule_cron": { + "name": "schedule_cron", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "schedule_timezone": { + "name": "schedule_timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'Asia/Shanghai'" + }, + "template_override": { + "name": "template_override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "unique_notification_target_binding": { + "name": "unique_notification_target_binding", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notification_bindings_type": { + "name": "idx_notification_bindings_type", + "columns": [ + { + "expression": "notification_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notification_bindings_target": { + "name": "idx_notification_bindings_target", + "columns": [ + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_target_bindings_target_id_webhook_targets_id_fk": { + "name": "notification_target_bindings_target_id_webhook_targets_id_fk", + "tableFrom": "notification_target_bindings", + "tableTo": "webhook_targets", + "columnsFrom": [ + "target_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.providers": { + "name": "providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "weight": { + "name": "weight", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false, + "default": "'1.0'" + }, + "group_tag": { + "name": "group_tag", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "provider_type": { + "name": "provider_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'claude'" + }, + "preserve_client_ip": { + "name": "preserve_client_ip", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "model_redirects": { + "name": "model_redirects", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "allowed_models": { + "name": "allowed_models", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'null'::jsonb" + }, + "join_claude_pool": { + "name": "join_claude_pool", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "codex_instructions_strategy": { + "name": "codex_instructions_strategy", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'auto'" + }, + "mcp_passthrough_type": { + "name": "mcp_passthrough_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "mcp_passthrough_url": { + "name": "mcp_passthrough_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_daily_usd": { + "name": "limit_daily_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "total_cost_reset_at": { + "name": "total_cost_reset_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "max_retry_attempts": { + "name": "max_retry_attempts", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "circuit_breaker_failure_threshold": { + "name": "circuit_breaker_failure_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 5 + }, + "circuit_breaker_open_duration": { + "name": "circuit_breaker_open_duration", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1800000 + }, + "circuit_breaker_half_open_success_threshold": { + "name": "circuit_breaker_half_open_success_threshold", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 2 + }, + "proxy_url": { + "name": "proxy_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "proxy_fallback_to_direct": { + "name": "proxy_fallback_to_direct", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "first_byte_timeout_streaming_ms": { + "name": "first_byte_timeout_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "streaming_idle_timeout_ms": { + "name": "streaming_idle_timeout_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "request_timeout_non_streaming_ms": { + "name": "request_timeout_non_streaming_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cache_ttl_preference": { + "name": "cache_ttl_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "context_1m_preference": { + "name": "context_1m_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_reasoning_effort_preference": { + "name": "codex_reasoning_effort_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_reasoning_summary_preference": { + "name": "codex_reasoning_summary_preference", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "codex_text_verbosity_preference": { + "name": "codex_text_verbosity_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "codex_parallel_tool_calls_preference": { + "name": "codex_parallel_tool_calls_preference", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "tpm": { + "name": "tpm", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "rpm": { + "name": "rpm", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "rpd": { + "name": "rpd", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "cc": { + "name": "cc", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_providers_enabled_priority": { + "name": "idx_providers_enabled_priority", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "weight", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_group": { + "name": "idx_providers_group", + "columns": [ + { + "expression": "group_tag", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"providers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_created_at": { + "name": "idx_providers_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_providers_deleted_at": { + "name": "idx_providers_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.request_filters": { + "name": "request_filters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(30)", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "replacement": { + "name": "replacement", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "binding_type": { + "name": "binding_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'global'" + }, + "provider_ids": { + "name": "provider_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "group_tags": { + "name": "group_tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_request_filters_enabled": { + "name": "idx_request_filters_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_scope": { + "name": "idx_request_filters_scope", + "columns": [ + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_action": { + "name": "idx_request_filters_action", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_request_filters_binding": { + "name": "idx_request_filters_binding", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "binding_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sensitive_words": { + "name": "sensitive_words", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "word": { + "name": "word", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "match_type": { + "name": "match_type", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'contains'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_sensitive_words_enabled": { + "name": "idx_sensitive_words_enabled", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "match_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_sensitive_words_created_at": { + "name": "idx_sensitive_words_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_settings": { + "name": "system_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "site_title": { + "name": "site_title", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "default": "'Claude Code Hub'" + }, + "allow_global_usage_view": { + "name": "allow_global_usage_view", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "currency_display": { + "name": "currency_display", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "billing_model_source": { + "name": "billing_model_source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'original'" + }, + "enable_auto_cleanup": { + "name": "enable_auto_cleanup", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "cleanup_retention_days": { + "name": "cleanup_retention_days", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30 + }, + "cleanup_schedule": { + "name": "cleanup_schedule", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'0 2 * * *'" + }, + "cleanup_batch_size": { + "name": "cleanup_batch_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10000 + }, + "enable_client_version_check": { + "name": "enable_client_version_check", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "verbose_provider_error": { + "name": "verbose_provider_error", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_http2": { + "name": "enable_http2", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "intercept_anthropic_warmup_requests": { + "name": "intercept_anthropic_warmup_requests", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enable_response_fixer": { + "name": "enable_response_fixer", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "response_fixer_config": { + "name": "response_fixer_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{\"fixTruncatedJson\":true,\"fixSseFormat\":true,\"fixEncoding\":true,\"maxJsonDepth\":200,\"maxFixSize\":1048576}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "rpm_limit": { + "name": "rpm_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "daily_limit_usd": { + "name": "daily_limit_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "provider_group": { + "name": "provider_group", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false, + "default": "'default'" + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "limit_5h_usd": { + "name": "limit_5h_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_weekly_usd": { + "name": "limit_weekly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_monthly_usd": { + "name": "limit_monthly_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_total_usd": { + "name": "limit_total_usd", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "limit_concurrent_sessions": { + "name": "limit_concurrent_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "daily_reset_mode": { + "name": "daily_reset_mode", + "type": "daily_reset_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fixed'" + }, + "daily_reset_time": { + "name": "daily_reset_time", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true, + "default": "'00:00'" + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "allowed_clients": { + "name": "allowed_clients", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "allowed_models": { + "name": "allowed_models", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_users_active_role_sort": { + "name": "idx_users_active_role_sort", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_enabled_expires_at": { + "name": "idx_users_enabled_expires_at", + "columns": [ + { + "expression": "is_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"users\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_created_at": { + "name": "idx_users_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_deleted_at": { + "name": "idx_users_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_targets": { + "name": "webhook_targets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "webhook_provider_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "webhook_url": { + "name": "webhook_url", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": false + }, + "telegram_bot_token": { + "name": "telegram_bot_token", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "telegram_chat_id": { + "name": "telegram_chat_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "dingtalk_secret": { + "name": "dingtalk_secret", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "custom_template": { + "name": "custom_template", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "custom_headers": { + "name": "custom_headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "proxy_url": { + "name": "proxy_url", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "proxy_fallback_to_direct": { + "name": "proxy_fallback_to_direct", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_test_at": { + "name": "last_test_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_test_result": { + "name": "last_test_result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.daily_reset_mode": { + "name": "daily_reset_mode", + "schema": "public", + "values": [ + "fixed", + "rolling" + ] + }, + "public.notification_type": { + "name": "notification_type", + "schema": "public", + "values": [ + "circuit_breaker", + "daily_leaderboard", + "cost_alert" + ] + }, + "public.webhook_provider_type": { + "name": "webhook_provider_type", + "schema": "public", + "values": [ + "wechat", + "feishu", + "dingtalk", + "telegram", + "custom" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 2da6ac2a7..9473c9cce 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -365,6 +365,13 @@ "when": 1767976327237, "tag": "0051_silent_maelstrom", "breakpoints": true + }, + { + "idx": 52, + "version": "7", + "when": 1767924921400, + "tag": "0052_model_price_source", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index 8cc6d5add..91ef7163a 100644 --- a/messages/en/dashboard.json +++ b/messages/en/dashboard.json @@ -1377,6 +1377,10 @@ "threeMonths": "In 3 months", "oneYear": "In 1 year" }, + "providerGroupSelect": { + "providersSuffix": "providers", + "loadFailed": "Failed to load provider groups" + }, "providerGroup": { "label": "Provider group", "placeholder": "Select provider group", diff --git a/messages/en/settings.json b/messages/en/settings.json index cddd6971c..0572ae511 100644 --- a/messages/en/settings.json +++ b/messages/en/settings.json @@ -546,13 +546,46 @@ "sync": { "button": "Sync LiteLLM Prices", "syncing": "Syncing...", + "checking": "Checking conflicts...", "successWithChanges": "Price table updated: {added} added, {updated} updated, {unchanged} unchanged", "successNoChanges": "Price table is up to date, no updates needed", "failed": "Sync failed", "failedError": "Sync failed: {error}", "failedNoResult": "Price table updated but no result returned", "noModels": "No model prices found", - "partialFailure": "Partial update succeeded, but {failed} models failed" + "partialFailure": "Partial update succeeded, but {failed} models failed", + "skippedConflicts": "Skipped {count} manual models" + }, + "conflict": { + "title": "Select Items to Overwrite", + "description": "The following models have manual prices. Check the ones to overwrite with LiteLLM prices, unchecked ones will be kept unchanged", + "searchPlaceholder": "Search models...", + "table": { + "modelName": "Model", + "manualPrice": "Manual Price", + "litellmPrice": "LiteLLM Price", + "action": "Action" + }, + "viewDiff": "View Diff", + "diffTitle": "Price Difference", + "diff": { + "field": "Field", + "manual": "Manual", + "litellm": "LiteLLM", + "inputPrice": "Input Price", + "outputPrice": "Output Price", + "imagePrice": "Image Price", + "provider": "Provider", + "mode": "Type" + }, + "pagination": { + "showing": "Showing {from}-{to} of {total}" + }, + "selectedCount": "Selected {count}/{total} models", + "noMatch": "No matching models found", + "noConflicts": "No conflicts", + "applyOverwrite": "Apply Overwrite", + "applying": "Applying..." }, "table": { "modelName": "Model Name", @@ -561,6 +594,7 @@ "inputPrice": "Input Price ($/M)", "outputPrice": "Output Price ($/M)", "updatedAt": "Updated At", + "actions": "Actions", "typeChat": "Chat", "typeImage": "Image", "typeCompletion": "Completion", @@ -610,6 +644,34 @@ "details": "Details", "viewDetails": "View detailed logs" } + }, + "addModel": "Add Model", + "editModel": "Edit Model", + "deleteModel": "Delete Model", + "addModelDescription": "Manually add a new model price configuration", + "editModelDescription": "Edit the model price configuration", + "deleteConfirm": "Are you sure you want to delete model {name}? This action cannot be undone.", + "form": { + "modelName": "Model Name", + "modelNamePlaceholder": "e.g., gpt-5.2-codex", + "modelNameRequired": "Model name is required", + "type": "Type", + "provider": "Provider", + "providerPlaceholder": "e.g., openai", + "inputPrice": "Input Price ($/M tokens)", + "outputPrice": "Output Price ($/M tokens)", + "outputPriceImage": "Output Price ($/image)" + }, + "actions": { + "edit": "Edit", + "delete": "Delete" + }, + "toast": { + "createSuccess": "Model added", + "updateSuccess": "Model updated", + "deleteSuccess": "Model deleted", + "saveFailed": "Failed to save", + "deleteFailed": "Failed to delete" } }, "providers": { diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json index a43abf9ca..b8ece37ac 100644 --- a/messages/ja/dashboard.json +++ b/messages/ja/dashboard.json @@ -1339,6 +1339,10 @@ "threeMonths": "3か月後", "oneYear": "1年後" }, + "providerGroupSelect": { + "providersSuffix": "件のプロバイダー", + "loadFailed": "プロバイダーグループの読み込みに失敗しました" + }, "providerGroup": { "label": "プロバイダーグループ", "placeholder": "プロバイダーグループを選択", diff --git a/messages/ja/settings.json b/messages/ja/settings.json index d4c391415..c02d1d290 100644 --- a/messages/ja/settings.json +++ b/messages/ja/settings.json @@ -537,13 +537,46 @@ "sync": { "button": "LiteLLM価格を同期", "syncing": "同期中...", + "checking": "競合を確認中...", "successWithChanges": "価格表を更新: {added}件追加、{updated}件更新、{unchanged}件変化なし", "successNoChanges": "価格表は最新です。更新の必要はありません", "failed": "同期に失敗しました", "failedError": "同期に失敗しました: {error}", "failedNoResult": "価格表は更新されましたが結果が返されていません", "noModels": "モデル価格が見つかりません", - "partialFailure": "一部更新が成功しましたが、{failed}件のモデルが失敗しました" + "partialFailure": "一部更新が成功しましたが、{failed}件のモデルが失敗しました", + "skippedConflicts": "{count}件の手動モデルをスキップしました" + }, + "conflict": { + "title": "上書きする項目を選択", + "description": "以下のモデルには手動で設定された価格があります。チェックした項目はLiteLLM価格で上書きされ、チェックしない項目は現在のままです", + "searchPlaceholder": "モデルを検索...", + "table": { + "modelName": "モデル", + "manualPrice": "手動価格", + "litellmPrice": "LiteLLM価格", + "action": "操作" + }, + "viewDiff": "差異を表示", + "diffTitle": "価格差異", + "diff": { + "field": "フィールド", + "manual": "手動", + "litellm": "LiteLLM", + "inputPrice": "入力価格", + "outputPrice": "出力価格", + "imagePrice": "画像価格", + "provider": "プロバイダー", + "mode": "タイプ" + }, + "pagination": { + "showing": "{from}〜{to}件を表示(全{total}件)" + }, + "selectedCount": "{count}/{total}件のモデルを選択", + "noMatch": "一致するモデルが見つかりません", + "noConflicts": "競合なし", + "applyOverwrite": "上書きを適用", + "applying": "適用中..." }, "table": { "modelName": "モデル名", @@ -552,6 +585,7 @@ "inputPrice": "入力価格 ($/M)", "outputPrice": "出力価格 ($/M)", "updatedAt": "更新日時", + "actions": "操作", "typeChat": "チャット", "typeImage": "画像生成", "typeCompletion": "補完", @@ -601,6 +635,34 @@ "details": "詳細", "viewDetails": "詳細ログを表示" } + }, + "addModel": "モデルを追加", + "editModel": "モデルを編集", + "deleteModel": "モデルを削除", + "addModelDescription": "新しいモデル価格設定を手動で追加します", + "editModelDescription": "モデルの価格設定を編集します", + "deleteConfirm": "モデル {name} を削除してもよろしいですか?この操作は元に戻せません。", + "form": { + "modelName": "モデル名", + "modelNamePlaceholder": "例: gpt-5.2-codex", + "modelNameRequired": "モデル名は必須です", + "type": "タイプ", + "provider": "プロバイダー", + "providerPlaceholder": "例: openai", + "inputPrice": "入力価格 ($/M tokens)", + "outputPrice": "出力価格 ($/M tokens)", + "outputPriceImage": "出力価格 ($/image)" + }, + "actions": { + "edit": "編集", + "delete": "削除" + }, + "toast": { + "createSuccess": "モデルを追加しました", + "updateSuccess": "モデルを更新しました", + "deleteSuccess": "モデルを削除しました", + "saveFailed": "保存に失敗しました", + "deleteFailed": "削除に失敗しました" } }, "providers": { diff --git a/messages/ru/dashboard.json b/messages/ru/dashboard.json index e0fd846da..1b332bc7e 100644 --- a/messages/ru/dashboard.json +++ b/messages/ru/dashboard.json @@ -1350,6 +1350,10 @@ "threeMonths": "Через 3 месяца", "oneYear": "Через год" }, + "providerGroupSelect": { + "providersSuffix": "провайдеров", + "loadFailed": "Не удалось загрузить группы провайдеров" + }, "providerGroup": { "label": "Группа провайдеров", "placeholder": "Выберите группу провайдеров", diff --git a/messages/ru/settings.json b/messages/ru/settings.json index 8f7730d3b..4fe8730ae 100644 --- a/messages/ru/settings.json +++ b/messages/ru/settings.json @@ -537,13 +537,46 @@ "sync": { "button": "Синхронизировать цены LiteLLM", "syncing": "Синхронизация...", + "checking": "Проверка конфликтов...", "successWithChanges": "Обновление прайс-листа: добавлено {added}, обновлено {updated}, без изменений {unchanged}", "successNoChanges": "Прайс-лист актуален, обновление не требуется", "failed": "Ошибка синхронизации", "failedError": "Ошибка синхронизации: {error}", "failedNoResult": "Прайс-лист обновлен но результат не возвращен", "noModels": "Цены моделей не найдены", - "partialFailure": "Частичное обновление выполнено, но {failed} моделей не удалось обновить" + "partialFailure": "Частичное обновление выполнено, но {failed} моделей не удалось обновить", + "skippedConflicts": "Пропущено {count} ручных моделей" + }, + "conflict": { + "title": "Выберите элементы для перезаписи", + "description": "Следующие модели имеют ручные цены. Отмеченные будут перезаписаны ценами LiteLLM, неотмеченные останутся без изменений", + "searchPlaceholder": "Поиск моделей...", + "table": { + "modelName": "Модель", + "manualPrice": "Ручная цена", + "litellmPrice": "Цена LiteLLM", + "action": "Действие" + }, + "viewDiff": "Показать различия", + "diffTitle": "Различия цен", + "diff": { + "field": "Поле", + "manual": "Ручное", + "litellm": "LiteLLM", + "inputPrice": "Цена ввода", + "outputPrice": "Цена вывода", + "imagePrice": "Цена изображения", + "provider": "Поставщик", + "mode": "Тип" + }, + "pagination": { + "showing": "Показано {from}-{to} из {total}" + }, + "selectedCount": "Выбрано {count}/{total} моделей", + "noMatch": "Модели не найдены", + "noConflicts": "Конфликтов нет", + "applyOverwrite": "Применить перезапись", + "applying": "Применение..." }, "table": { "modelName": "Название модели", @@ -552,6 +585,7 @@ "inputPrice": "Цена ввода ($/M)", "outputPrice": "Цена вывода ($/M)", "updatedAt": "Обновлено", + "actions": "Действия", "typeChat": "Чат", "typeImage": "Генерация изображений", "typeCompletion": "Дополнение", @@ -601,6 +635,34 @@ "details": "Подробности", "viewDetails": "Просмотреть подробный журнал" } + }, + "addModel": "Добавить модель", + "editModel": "Редактировать модель", + "deleteModel": "Удалить модель", + "addModelDescription": "Вручную добавить новую цену модели", + "editModelDescription": "Редактировать цену модели", + "deleteConfirm": "Удалить модель {name}? Это действие необратимо.", + "form": { + "modelName": "Название модели", + "modelNamePlaceholder": "например: gpt-5.2-codex", + "modelNameRequired": "Название модели обязательно", + "type": "Тип", + "provider": "Поставщик", + "providerPlaceholder": "например: openai", + "inputPrice": "Цена ввода ($/M токенов)", + "outputPrice": "Цена вывода ($/M токенов)", + "outputPriceImage": "Цена вывода ($/изображение)" + }, + "actions": { + "edit": "Редактировать", + "delete": "Удалить" + }, + "toast": { + "createSuccess": "Модель добавлена", + "updateSuccess": "Модель обновлена", + "deleteSuccess": "Модель удалена", + "saveFailed": "Ошибка сохранения", + "deleteFailed": "Ошибка удаления" } }, "providers": { diff --git a/messages/zh-CN/dashboard.json b/messages/zh-CN/dashboard.json index d027152a4..40b1be77a 100644 --- a/messages/zh-CN/dashboard.json +++ b/messages/zh-CN/dashboard.json @@ -1378,6 +1378,10 @@ "threeMonths": "三月后", "oneYear": "一年后" }, + "providerGroupSelect": { + "providersSuffix": "个供应商", + "loadFailed": "加载供应商分组失败" + }, "providerGroup": { "label": "供应商分组", "placeholder": "选择供应商分组", diff --git a/messages/zh-CN/settings.json b/messages/zh-CN/settings.json index 147b1b227..a63c08b93 100644 --- a/messages/zh-CN/settings.json +++ b/messages/zh-CN/settings.json @@ -1265,13 +1265,46 @@ "sync": { "button": "同步 LiteLLM 价格", "syncing": "同步中...", + "checking": "检查冲突...", "successWithChanges": "价格表更新: 新增 {added} 个,更新 {updated} 个,未变化 {unchanged} 个", "successNoChanges": "价格表已是最新,无需更新", "failed": "同步失败", "failedError": "同步失败: {error}", "failedNoResult": "价格表更新成功但未返回处理结果", "noModels": "未找到支持的模型价格", - "partialFailure": "部分更新成功,但有 {failed} 个模型失败" + "partialFailure": "部分更新成功,但有 {failed} 个模型失败", + "skippedConflicts": "跳过 {count} 个手动模型" + }, + "conflict": { + "title": "选择要覆盖的冲突项", + "description": "以下模型存在手动维护的价格,勾选后将用 LiteLLM 价格覆盖,未勾选的保持本地不变", + "searchPlaceholder": "搜索模型...", + "table": { + "modelName": "模型", + "manualPrice": "手动价格", + "litellmPrice": "LiteLLM 价格", + "action": "操作" + }, + "viewDiff": "查看差异", + "diffTitle": "价格差异对比", + "diff": { + "field": "字段", + "manual": "手动", + "litellm": "LiteLLM", + "inputPrice": "输入价格", + "outputPrice": "输出价格", + "imagePrice": "图片价格", + "provider": "供应商", + "mode": "类型" + }, + "pagination": { + "showing": "显示 {from}-{to} 条,共 {total} 条" + }, + "selectedCount": "已选择 {count}/{total} 个模型", + "noMatch": "未找到匹配的模型", + "noConflicts": "无冲突项", + "applyOverwrite": "应用覆盖", + "applying": "应用中..." }, "table": { "modelName": "模型名称", @@ -1280,6 +1313,7 @@ "inputPrice": "输入价格 ($/M)", "outputPrice": "输出价格 ($/M)", "updatedAt": "更新时间", + "actions": "操作", "typeChat": "对话", "typeImage": "图像生成", "typeCompletion": "补全", @@ -1329,6 +1363,34 @@ "details": "详细信息", "viewDetails": "查看详细日志" } + }, + "addModel": "添加模型", + "editModel": "编辑模型", + "deleteModel": "删除模型", + "addModelDescription": "手动添加新的模型价格配置", + "editModelDescription": "编辑模型的价格配置", + "deleteConfirm": "确定要删除模型 {name} 吗?此操作不可撤销。", + "form": { + "modelName": "模型名称", + "modelNamePlaceholder": "例如: gpt-5.2-codex", + "modelNameRequired": "模型名称不能为空", + "type": "类型", + "provider": "供应商", + "providerPlaceholder": "例如: openai", + "inputPrice": "输入价格 ($/M tokens)", + "outputPrice": "输出价格 ($/M tokens)", + "outputPriceImage": "输出价格 ($/image)" + }, + "actions": { + "edit": "编辑", + "delete": "删除" + }, + "toast": { + "createSuccess": "模型已添加", + "updateSuccess": "模型已更新", + "deleteSuccess": "模型已删除", + "saveFailed": "保存失败", + "deleteFailed": "删除失败" } }, "sensitiveWords": { diff --git a/messages/zh-TW/dashboard.json b/messages/zh-TW/dashboard.json index c7bc527a4..5751f6d32 100644 --- a/messages/zh-TW/dashboard.json +++ b/messages/zh-TW/dashboard.json @@ -1348,6 +1348,10 @@ "threeMonths": "三個月後", "oneYear": "一年後" }, + "providerGroupSelect": { + "providersSuffix": "個供應商", + "loadFailed": "載入供應商分組失敗" + }, "providerGroup": { "label": "供應商分組", "placeholder": "選擇供應商分組", diff --git a/messages/zh-TW/settings.json b/messages/zh-TW/settings.json index fb33cf1b5..2cf3fe91b 100644 --- a/messages/zh-TW/settings.json +++ b/messages/zh-TW/settings.json @@ -537,13 +537,46 @@ "sync": { "button": "同步 LiteLLM 價格", "syncing": "同步中...", + "checking": "檢查衝突...", "successWithChanges": "價格表更新: 新增 {added} 個,更新 {updated} 個,未變化 {unchanged} 個", "successNoChanges": "價格表已是最新,無需更新", "failed": "同步失敗", "failedError": "同步失敗: {error}", "failedNoResult": "價格表更新成功但未返回處理結果", "noModels": "未找到支援的模型價格", - "partialFailure": "部分更新成功,但有 {failed} 個模型失敗" + "partialFailure": "部分更新成功,但有 {failed} 個模型失敗", + "skippedConflicts": "跳過 {count} 個手動模型" + }, + "conflict": { + "title": "選擇要覆蓋的衝突項", + "description": "以下模型存在手動維護的價格,勾選後將用 LiteLLM 價格覆蓋,未勾選的保持本地不變", + "searchPlaceholder": "搜尋模型...", + "table": { + "modelName": "模型", + "manualPrice": "手動價格", + "litellmPrice": "LiteLLM 價格", + "action": "操作" + }, + "viewDiff": "查看差異", + "diffTitle": "價格差異對比", + "diff": { + "field": "欄位", + "manual": "手動", + "litellm": "LiteLLM", + "inputPrice": "輸入價格", + "outputPrice": "輸出價格", + "imagePrice": "圖片價格", + "provider": "供應商", + "mode": "類型" + }, + "pagination": { + "showing": "顯示 {from}-{to} 條,共 {total} 條" + }, + "selectedCount": "已選擇 {count}/{total} 個模型", + "noMatch": "未找到符合的模型", + "noConflicts": "無衝突項", + "applyOverwrite": "套用覆蓋", + "applying": "套用中..." }, "table": { "modelName": "模型名稱", @@ -552,6 +585,7 @@ "inputPrice": "輸入價格 ($/M)", "outputPrice": "輸出價格 ($/M)", "updatedAt": "更新時間", + "actions": "操作", "typeChat": "對話", "typeImage": "圖像生成", "typeCompletion": "補全", @@ -601,6 +635,34 @@ "details": "詳細資訊", "viewDetails": "檢視詳細記錄" } + }, + "addModel": "新增模型", + "editModel": "編輯模型", + "deleteModel": "刪除模型", + "addModelDescription": "手動新增模型價格設定", + "editModelDescription": "編輯模型的價格設定", + "deleteConfirm": "確定要刪除模型 {name} 嗎?此操作無法復原。", + "form": { + "modelName": "模型名稱", + "modelNamePlaceholder": "例如: gpt-5.2-codex", + "modelNameRequired": "模型名稱為必填", + "type": "類型", + "provider": "提供商", + "providerPlaceholder": "例如: openai", + "inputPrice": "輸入價格 ($/M tokens)", + "outputPrice": "輸出價格 ($/M tokens)", + "outputPriceImage": "輸出價格 ($/張圖)" + }, + "actions": { + "edit": "編輯", + "delete": "刪除" + }, + "toast": { + "createSuccess": "模型已新增", + "updateSuccess": "模型已更新", + "deleteSuccess": "模型已刪除", + "saveFailed": "儲存失敗", + "deleteFailed": "刪除失敗" } }, "providers": { diff --git a/src/actions/model-prices.ts b/src/actions/model-prices.ts index c082ebfb7..9d25caef7 100644 --- a/src/actions/model-prices.ts +++ b/src/actions/model-prices.ts @@ -6,18 +6,23 @@ import { logger } from "@/lib/logger"; import { getPriceTableJson } from "@/lib/price-sync"; import { createModelPrice, + deleteModelPriceByName, findAllLatestPrices, findAllLatestPricesPaginated, + findAllManualPrices, findLatestPriceByModel, hasAnyPriceRecords, type PaginatedResult, type PaginationParams, + upsertModelPrice, } from "@/repository/model-price"; import type { ModelPrice, ModelPriceData, PriceTableJson, PriceUpdateResult, + SyncConflict, + SyncConflictCheckResult, } from "@/types/model-price"; import type { ActionResult } from "./types"; @@ -32,9 +37,12 @@ function isPriceDataEqual(data1: ModelPriceData, data2: ModelPriceData): boolean /** * 价格表处理核心逻辑(内部函数,无权限检查) * 用于系统初始化和 Web UI 上传 + * @param jsonContent - 价格表 JSON 内容 + * @param overwriteManual - 可选,要覆盖的手动添加模型名称列表 */ export async function processPriceTableInternal( - jsonContent: string + jsonContent: string, + overwriteManual?: string[] ): Promise> { try { // 解析JSON内容 @@ -63,12 +71,19 @@ export async function processPriceTableInternal( return typeof modelName === "string" && modelName.trim().length > 0; }); + // 创建覆盖列表的 Set 用于快速查找 + const overwriteSet = new Set(overwriteManual ?? []); + + // 获取所有手动添加的模型(用于冲突检测) + const manualPrices = await findAllManualPrices(); + const result: PriceUpdateResult = { added: [], updated: [], unchanged: [], failed: [], total: entries.length, + skippedConflicts: [], }; // 处理每个模型的价格 @@ -88,23 +103,37 @@ export async function processPriceTableInternal( continue; } + // 检查是否存在手动添加的价格且不在覆盖列表中 + const isManualPrice = manualPrices.has(modelName); + if (isManualPrice && !overwriteSet.has(modelName)) { + // 跳过手动添加的模型,记录到 skippedConflicts + result.skippedConflicts?.push(modelName); + result.unchanged.push(modelName); + logger.debug(`跳过手动添加的模型: ${modelName}`); + continue; + } + // 查找该模型的最新价格 const existingPrice = await findLatestPriceByModel(modelName); if (!existingPrice) { // 模型不存在,新增记录 - await createModelPrice(modelName, priceData); + await createModelPrice(modelName, priceData, "litellm"); result.added.push(modelName); } else if (!isPriceDataEqual(existingPrice.priceData, priceData)) { - // 模型存在但价格发生变化,新增记录 - await createModelPrice(modelName, priceData); + // 模型存在但价格发生变化 + // 如果是手动模型且在覆盖列表中,先删除旧记录 + if (isManualPrice && overwriteSet.has(modelName)) { + await deleteModelPriceByName(modelName); + } + await createModelPrice(modelName, priceData, "litellm"); result.updated.push(modelName); } else { // 价格未发生变化,不需要更新 result.unchanged.push(modelName); } } catch (error) { - logger.error("处理模型 ${modelName} 失败:", error); + logger.error(`处理模型 ${modelName} 失败:`, error); result.failed.push(modelName); } } @@ -122,9 +151,11 @@ export async function processPriceTableInternal( /** * 上传并更新模型价格表(Web UI 入口,包含权限检查) + * @param overwriteManual - 可选,要覆盖的手动添加模型名称列表 */ export async function uploadPriceTable( - jsonContent: string + jsonContent: string, + overwriteManual?: string[] ): Promise> { // 权限检查:只有管理员可以上传价格表 const session = await getSession(); @@ -133,7 +164,7 @@ export async function uploadPriceTable( } // 调用核心逻辑 - return processPriceTableInternal(jsonContent); + return processPriceTableInternal(jsonContent, overwriteManual); } /** @@ -241,11 +272,76 @@ export async function getAvailableModelsByProviderType(): Promise { * 获取指定模型的最新价格 */ +/** + * 检查 LiteLLM 同步是否会产生冲突 + * @returns 冲突检查结果,包含是否有冲突以及冲突列表 + */ +export async function checkLiteLLMSyncConflicts(): Promise> { + try { + // 权限检查:只有管理员可以检查冲突 + const session = await getSession(); + if (!session || session.user.role !== "admin") { + return { ok: false, error: "无权限执行此操作" }; + } + + // 获取价格表 JSON + const jsonContent = await getPriceTableJson(); + if (!jsonContent) { + return { + ok: false, + error: "无法从 CDN 或缓存获取价格表,请检查网络连接或稍后重试", + }; + } + + // 解析 JSON + let priceTable: PriceTableJson; + try { + priceTable = JSON.parse(jsonContent); + } catch { + return { ok: false, error: "JSON格式不正确" }; + } + + // 获取数据库中所有 manual 价格 + const manualPrices = await findAllManualPrices(); + logger.info(`[Conflict Check] Found ${manualPrices.size} manual prices in database`); + + // 构建冲突列表:检查哪些 manual 模型会被 LiteLLM 同步覆盖 + const conflicts: SyncConflict[] = []; + for (const [modelName, manualPrice] of manualPrices) { + const litellmPrice = priceTable[modelName]; + if (litellmPrice && typeof litellmPrice === "object" && "mode" in litellmPrice) { + conflicts.push({ + modelName, + manualPrice: manualPrice.priceData, + litellmPrice: litellmPrice as ModelPriceData, + }); + } + } + + logger.info(`[Conflict Check] Found ${conflicts.length} conflicts`); + + return { + ok: true, + data: { + hasConflicts: conflicts.length > 0, + conflicts, + }, + }; + } catch (error) { + logger.error("检查同步冲突失败:", error); + const message = error instanceof Error ? error.message : "检查失败,请稍后重试"; + return { ok: false, error: message }; + } +} + /** * 从 LiteLLM CDN 同步价格表到数据库 + * @param overwriteManual - 可选,要覆盖的手动添加模型名称列表 * @returns 同步结果 */ -export async function syncLiteLLMPrices(): Promise> { +export async function syncLiteLLMPrices( + overwriteManual?: string[] +): Promise> { try { // 权限检查:只有管理员可以同步价格表 const session = await getSession(); @@ -267,7 +363,7 @@ export async function syncLiteLLMPrices(): Promise> { + try { + // 权限检查:只有管理员可以操作 + const session = await getSession(); + if (!session || session.user.role !== "admin") { + return { ok: false, error: "无权限执行此操作" }; + } + + // 验证输入 + if (!input.modelName?.trim()) { + return { ok: false, error: "模型名称不能为空" }; + } + + // 验证价格非负 + if ( + input.inputCostPerToken !== undefined && + (input.inputCostPerToken < 0 || !Number.isFinite(input.inputCostPerToken)) + ) { + return { ok: false, error: "输入价格必须为非负数" }; + } + if ( + input.outputCostPerToken !== undefined && + (input.outputCostPerToken < 0 || !Number.isFinite(input.outputCostPerToken)) + ) { + return { ok: false, error: "输出价格必须为非负数" }; + } + if ( + input.outputCostPerImage !== undefined && + (input.outputCostPerImage < 0 || !Number.isFinite(input.outputCostPerImage)) + ) { + return { ok: false, error: "图片价格必须为非负数" }; + } + + // 构建价格数据 + const priceData: ModelPriceData = { + mode: input.mode, + litellm_provider: input.litellmProvider || undefined, + input_cost_per_token: input.inputCostPerToken, + output_cost_per_token: input.outputCostPerToken, + output_cost_per_image: input.outputCostPerImage, + }; + + // 执行更新 + const result = await upsertModelPrice(input.modelName.trim(), priceData); + + // 刷新页面数据 + revalidatePath("/settings/prices"); + + return { ok: true, data: result }; + } catch (error) { + logger.error("更新模型价格失败:", error); + const message = error instanceof Error ? error.message : "操作失败,请稍后重试"; + return { ok: false, error: message }; + } +} + +/** + * 删除单个模型价格(硬删除) + */ +export async function deleteSingleModelPrice(modelName: string): Promise> { + try { + // 权限检查:只有管理员可以操作 + const session = await getSession(); + if (!session || session.user.role !== "admin") { + return { ok: false, error: "无权限执行此操作" }; + } + + // 验证输入 + if (!modelName?.trim()) { + return { ok: false, error: "模型名称不能为空" }; + } + + // 执行删除 + await deleteModelPriceByName(modelName.trim()); + + // 刷新页面数据 + revalidatePath("/settings/prices"); + + return { ok: true, data: undefined }; + } catch (error) { + logger.error("删除模型价格失败:", error); + const message = error instanceof Error ? error.message : "删除失败,请稍后重试"; + return { ok: false, error: message }; + } +} diff --git a/src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx b/src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx index 5d6b3a97f..e808276f9 100644 --- a/src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx +++ b/src/app/[locale]/dashboard/_components/user/edit-user-dialog.tsx @@ -72,10 +72,8 @@ function EditUserDialogInner({ onOpenChange, user, onSuccess }: EditUserDialogPr const tCommon = useTranslations("common"); const [isPending, startTransition] = useTransition(); - // Use shared hooks - const modelSuggestions = useModelSuggestions(user.providerGroup); - const showUserProviderGroup = Boolean(user.providerGroup?.trim()); - const userEditTranslations = useUserTranslations({ showProviderGroup: showUserProviderGroup }); + // Always show providerGroup field in edit mode + const userEditTranslations = useUserTranslations({ showProviderGroup: true }); const defaultValues = useMemo(() => buildDefaultValues(user), [user]); @@ -125,6 +123,9 @@ function EditUserDialogInner({ onOpenChange, user, onSuccess }: EditUserDialogPr const currentUserDraft = form.values || defaultValues; + // Model suggestions based on current providerGroup value + const modelSuggestions = useModelSuggestions(currentUserDraft.providerGroup); + const handleUserChange = (field: string | Record, value?: any) => { const prev = form.values || defaultValues; const next = { ...prev } as EditUserValues; @@ -236,7 +237,7 @@ function EditUserDialogInner({ onOpenChange, user, onSuccess }: EditUserDialogPr await handleEnableUser(); } }} - showProviderGroup={showUserProviderGroup} + showProviderGroup onChange={handleUserChange} translations={userEditTranslations} modelSuggestions={modelSuggestions} diff --git a/src/app/[locale]/dashboard/_components/user/forms/user-edit-section.tsx b/src/app/[locale]/dashboard/_components/user/forms/user-edit-section.tsx index e8a691d60..1f8b30cdf 100644 --- a/src/app/[locale]/dashboard/_components/user/forms/user-edit-section.tsx +++ b/src/app/[locale]/dashboard/_components/user/forms/user-edit-section.tsx @@ -14,7 +14,6 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; -import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; @@ -23,6 +22,7 @@ import { cn } from "@/lib/utils"; import { AccessRestrictionsSection } from "./access-restrictions-section"; import { type DailyResetMode, LimitRulePicker, type LimitType } from "./limit-rule-picker"; import { type LimitRuleDisplayItem, LimitRulesDisplay } from "./limit-rules-display"; +import { ProviderGroupSelect } from "./provider-group-select"; import { QuickExpirePicker } from "./quick-expire-picker"; export interface UserEditSectionProps { @@ -69,6 +69,17 @@ export interface UserEditSectionProps { providerGroup?: { label: string; placeholder: string; + providersSuffix?: string; + tagInputErrors?: { + empty?: string; + duplicate?: string; + too_long?: string; + invalid_format?: string; + max_tags?: string; + }; + errors?: { + loadFailed?: string; + }; }; enableStatus?: { label: string; @@ -411,20 +422,12 @@ export function UserEditSection({ /> {showProviderGroup && translations.fields.providerGroup && ( -
- -
- {(user.providerGroup || PROVIDER_GROUP.DEFAULT) - .split(",") - .map((g) => g.trim()) - .filter(Boolean) - .map((group) => ( - - {group} - - ))} -
-
+ emitChange("providerGroup", val)} + disabled={false} + translations={translations.fields.providerGroup} + /> )}
diff --git a/src/app/[locale]/dashboard/_components/user/hooks/use-user-translations.ts b/src/app/[locale]/dashboard/_components/user/hooks/use-user-translations.ts index 1360c99f1..96cb4e8de 100644 --- a/src/app/[locale]/dashboard/_components/user/hooks/use-user-translations.ts +++ b/src/app/[locale]/dashboard/_components/user/hooks/use-user-translations.ts @@ -26,6 +26,17 @@ export interface UserEditTranslations { providerGroup?: { label: string; placeholder: string; + providersSuffix?: string; + tagInputErrors?: { + empty?: string; + duplicate?: string; + too_long?: string; + invalid_format?: string; + max_tags?: string; + }; + errors?: { + loadFailed?: string; + }; }; enableStatus: { label: string; @@ -98,6 +109,7 @@ export function useUserTranslations( ): UserEditTranslations { const { showProviderGroup = false } = options; const t = useTranslations("dashboard.userManagement"); + const tUi = useTranslations("ui.tagInput"); return useMemo(() => { return { @@ -124,6 +136,17 @@ export function useUserTranslations( ? { label: t("userEditSection.fields.providerGroup.label"), placeholder: t("userEditSection.fields.providerGroup.placeholder"), + providersSuffix: t("providerGroupSelect.providersSuffix"), + tagInputErrors: { + empty: tUi("emptyTag"), + duplicate: tUi("duplicateTag"), + too_long: tUi("tooLong", { max: 50 }), + invalid_format: tUi("invalidFormat"), + max_tags: tUi("maxTags"), + }, + errors: { + loadFailed: t("providerGroupSelect.loadFailed"), + }, } : undefined, enableStatus: { @@ -187,5 +210,5 @@ export function useUserTranslations( year: t("quickExpire.oneYear"), }, }; - }, [t, showProviderGroup]); + }, [t, tUi, showProviderGroup]); } diff --git a/src/app/[locale]/settings/prices/_components/delete-model-dialog.tsx b/src/app/[locale]/settings/prices/_components/delete-model-dialog.tsx new file mode 100644 index 000000000..e11781ccc --- /dev/null +++ b/src/app/[locale]/settings/prices/_components/delete-model-dialog.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { Loader2, Trash2 } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useState } from "react"; +import { toast } from "sonner"; +import { deleteSingleModelPrice } from "@/actions/model-prices"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; + +interface DeleteModelDialogProps { + modelName: string; + trigger?: React.ReactNode; + onSuccess?: () => void; +} + +/** + * 删除模型价格确认对话框 + */ +export function DeleteModelDialog({ modelName, trigger, onSuccess }: DeleteModelDialogProps) { + const t = useTranslations("settings.prices"); + const tCommon = useTranslations("settings.common"); + + const [open, setOpen] = useState(false); + const [loading, setLoading] = useState(false); + + const handleDelete = async () => { + setLoading(true); + + try { + const result = await deleteSingleModelPrice(modelName); + + if (!result.ok) { + toast.error(result.error); + return; + } + + toast.success(t("toast.deleteSuccess")); + setOpen(false); + onSuccess?.(); + window.dispatchEvent(new Event("price-data-updated")); + } catch (error) { + console.error("删除失败:", error); + toast.error(t("toast.deleteFailed")); + } finally { + setLoading(false); + } + }; + + const defaultTrigger = ( + + ); + + return ( + + {trigger || defaultTrigger} + + + {t("deleteModel")} + {t("deleteConfirm", { name: modelName })} + + + {tCommon("cancel")} + { + e.preventDefault(); + handleDelete(); + }} + disabled={loading} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {loading && } + {tCommon("delete")} + + + + + ); +} diff --git a/src/app/[locale]/settings/prices/_components/model-price-dialog.tsx b/src/app/[locale]/settings/prices/_components/model-price-dialog.tsx new file mode 100644 index 000000000..dba1c1c34 --- /dev/null +++ b/src/app/[locale]/settings/prices/_components/model-price-dialog.tsx @@ -0,0 +1,272 @@ +"use client"; + +import { Loader2, Pencil, Plus } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { upsertSingleModelPrice } from "@/actions/model-prices"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type { ModelPrice } from "@/types/model-price"; + +interface ModelPriceDialogProps { + mode: "create" | "edit"; + initialData?: ModelPrice; + trigger?: React.ReactNode; + onSuccess?: () => void; +} + +type ModelMode = "chat" | "image_generation" | "completion"; + +/** + * 模型价格添加/编辑对话框 + */ +export function ModelPriceDialog({ mode, initialData, trigger, onSuccess }: ModelPriceDialogProps) { + const t = useTranslations("settings.prices"); + const tCommon = useTranslations("settings.common"); + + const [open, setOpen] = useState(false); + const [loading, setLoading] = useState(false); + + // 表单状态 + const [modelName, setModelName] = useState(""); + const [modelMode, setModelMode] = useState("chat"); + const [provider, setProvider] = useState(""); + const [inputPrice, setInputPrice] = useState(""); + const [outputPrice, setOutputPrice] = useState(""); + + // 当对话框打开或初始数据变化时,重置表单 + useEffect(() => { + if (open) { + if (mode === "edit" && initialData) { + setModelName(initialData.modelName); + setModelMode((initialData.priceData.mode as ModelMode) || "chat"); + setProvider(initialData.priceData.litellm_provider || ""); + // 将每 token 价格转换为每百万 token 价格显示 + setInputPrice( + initialData.priceData.input_cost_per_token + ? (initialData.priceData.input_cost_per_token * 1000000).toString() + : "" + ); + if (initialData.priceData.mode === "image_generation") { + setOutputPrice( + initialData.priceData.output_cost_per_image + ? initialData.priceData.output_cost_per_image.toString() + : "" + ); + } else { + setOutputPrice( + initialData.priceData.output_cost_per_token + ? (initialData.priceData.output_cost_per_token * 1000000).toString() + : "" + ); + } + } else { + // 创建模式,清空表单 + setModelName(""); + setModelMode("chat"); + setProvider(""); + setInputPrice(""); + setOutputPrice(""); + } + } + }, [open, mode, initialData]); + + const handleSubmit = async () => { + // 验证 + if (!modelName.trim()) { + toast.error(t("form.modelNameRequired")); + return; + } + + setLoading(true); + + try { + // 将每百万 token 价格转换回每 token 价格 + const inputCostPerToken = inputPrice ? parseFloat(inputPrice) / 1000000 : undefined; + const outputCostPerToken = + modelMode !== "image_generation" && outputPrice + ? parseFloat(outputPrice) / 1000000 + : undefined; + const outputCostPerImage = + modelMode === "image_generation" && outputPrice ? parseFloat(outputPrice) : undefined; + + const result = await upsertSingleModelPrice({ + modelName: modelName.trim(), + mode: modelMode, + litellmProvider: provider.trim() || undefined, + inputCostPerToken, + outputCostPerToken, + outputCostPerImage, + }); + + if (!result.ok) { + toast.error(result.error); + return; + } + + toast.success(mode === "create" ? t("toast.createSuccess") : t("toast.updateSuccess")); + setOpen(false); + onSuccess?.(); + window.dispatchEvent(new Event("price-data-updated")); + } catch (error) { + console.error("保存失败:", error); + toast.error(t("toast.saveFailed")); + } finally { + setLoading(false); + } + }; + + const defaultTrigger = + mode === "create" ? ( + + ) : ( + + ); + + return ( + + {trigger || defaultTrigger} + + + {mode === "create" ? t("addModel") : t("editModel")} + + {mode === "create" ? t("addModelDescription") : t("editModelDescription")} + + + +
+ {/* 模型名称 */} +
+ + setModelName(e.target.value)} + placeholder={t("form.modelNamePlaceholder")} + disabled={mode === "edit" || loading} + /> +
+ + {/* 类型 */} +
+ + +
+ + {/* 供应商 */} +
+ + setProvider(e.target.value)} + placeholder={t("form.providerPlaceholder")} + disabled={loading} + /> +
+ + {/* 输入价格 */} + {modelMode !== "image_generation" && ( +
+ +
+ + $ + + setInputPrice(e.target.value)} + placeholder="0.00" + className="pl-7 pr-12" + disabled={loading} + /> + + /M + +
+
+ )} + + {/* 输出价格 */} +
+ +
+ + $ + + setOutputPrice(e.target.value)} + placeholder="0.00" + className="pl-7 pr-16" + disabled={loading} + /> + + {modelMode === "image_generation" ? "/img" : "/M"} + +
+
+
+ + + + + +
+
+ ); +} diff --git a/src/app/[locale]/settings/prices/_components/price-list.tsx b/src/app/[locale]/settings/prices/_components/price-list.tsx index 17bb114d6..f471c957f 100644 --- a/src/app/[locale]/settings/prices/_components/price-list.tsx +++ b/src/app/[locale]/settings/prices/_components/price-list.tsx @@ -1,10 +1,25 @@ "use client"; -import { ChevronLeft, ChevronRight, DollarSign, Package, Search } from "lucide-react"; +import { + ChevronLeft, + ChevronRight, + DollarSign, + MoreHorizontal, + Package, + Pencil, + Search, + Trash2, +} from "lucide-react"; import { useTranslations } from "next-intl"; import { useCallback, useEffect, useState } from "react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; import { Select, @@ -23,6 +38,8 @@ import { } from "@/components/ui/table"; import { useDebounce } from "@/lib/hooks/use-debounce"; import type { ModelPrice } from "@/types/model-price"; +import { DeleteModelDialog } from "./delete-model-dialog"; +import { ModelPriceDialog } from "./model-price-dialog"; interface PriceListProps { initialPrices: ModelPrice[]; @@ -112,6 +129,16 @@ export function PriceList({ [] ); + // 监听价格数据变化事件(由其他组件触发) + useEffect(() => { + const handlePriceUpdate = () => { + fetchPrices(page, pageSize, debouncedSearchTerm); + }; + + window.addEventListener("price-data-updated", handlePriceUpdate); + return () => window.removeEventListener("price-data-updated", handlePriceUpdate); + }, [page, pageSize, debouncedSearchTerm, fetchPrices]); + // 当防抖后的搜索词变化时,触发搜索(重置到第一页) useEffect(() => { // 跳过初始渲染(当 debouncedSearchTerm 等于初始 searchTerm 时) @@ -229,12 +256,13 @@ export function PriceList({ {t("table.inputPrice")} {t("table.outputPrice")} {t("table.updatedAt")} + {t("table.actions")} {isLoading ? ( - +
{t("table.loading")} @@ -274,11 +302,46 @@ export function PriceList({ {new Date(price.createdAt).toLocaleDateString("zh-CN")} + + + + + + + fetchPrices(page, pageSize, debouncedSearchTerm)} + trigger={ + e.preventDefault()}> + + {t("actions.edit")} + + } + /> + fetchPrices(page, pageSize, debouncedSearchTerm)} + trigger={ + e.preventDefault()} + className="text-destructive focus:text-destructive" + > + + {t("actions.delete")} + + } + /> + + + )) ) : ( - +
{searchTerm ? ( <> diff --git a/src/app/[locale]/settings/prices/_components/sync-conflict-dialog.tsx b/src/app/[locale]/settings/prices/_components/sync-conflict-dialog.tsx new file mode 100644 index 000000000..2940ba240 --- /dev/null +++ b/src/app/[locale]/settings/prices/_components/sync-conflict-dialog.tsx @@ -0,0 +1,391 @@ +"use client"; + +import { AlertTriangle, ChevronLeft, ChevronRight, Eye, Search } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useMemo, useState } from "react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import type { ModelPriceData, SyncConflict } from "@/types/model-price"; + +interface SyncConflictDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + conflicts: SyncConflict[]; + onConfirm: (selectedModels: string[]) => void; + isLoading?: boolean; +} + +const PAGE_SIZE = 10; + +/** + * 格式化价格显示为每百万token的价格 + */ +function formatPrice(value?: number): string { + if (value === undefined || value === null) return "-"; + const pricePerMillion = value * 1000000; + if (pricePerMillion < 0.01) { + return `$${pricePerMillion.toFixed(4)}/M`; + } else if (pricePerMillion < 1) { + return `$${pricePerMillion.toFixed(3)}/M`; + } else if (pricePerMillion < 100) { + return `$${pricePerMillion.toFixed(2)}/M`; + } + return `$${pricePerMillion.toFixed(0)}/M`; +} + +/** + * 价格差异对比 Popover + */ +function PriceDiffPopover({ + manualPrice, + litellmPrice, +}: { + manualPrice: ModelPriceData; + litellmPrice: ModelPriceData; +}) { + const t = useTranslations("settings.prices.conflict"); + + const diffs = useMemo(() => { + const items: Array<{ + field: string; + manual: string; + litellm: string; + changed: boolean; + }> = []; + + // 输入价格 + const manualInput = formatPrice(manualPrice.input_cost_per_token); + const litellmInput = formatPrice(litellmPrice.input_cost_per_token); + items.push({ + field: t("diff.inputPrice"), + manual: manualInput, + litellm: litellmInput, + changed: manualInput !== litellmInput, + }); + + // 输出价格 + const manualOutput = formatPrice(manualPrice.output_cost_per_token); + const litellmOutput = formatPrice(litellmPrice.output_cost_per_token); + items.push({ + field: t("diff.outputPrice"), + manual: manualOutput, + litellm: litellmOutput, + changed: manualOutput !== litellmOutput, + }); + + // 图片价格(如果有) + if (manualPrice.output_cost_per_image || litellmPrice.output_cost_per_image) { + const manualImg = manualPrice.output_cost_per_image + ? `$${manualPrice.output_cost_per_image}/img` + : "-"; + const litellmImg = litellmPrice.output_cost_per_image + ? `$${litellmPrice.output_cost_per_image}/img` + : "-"; + items.push({ + field: t("diff.imagePrice"), + manual: manualImg, + litellm: litellmImg, + changed: manualImg !== litellmImg, + }); + } + + // 供应商 + const manualProvider = manualPrice.litellm_provider || "-"; + const litellmProvider = litellmPrice.litellm_provider || "-"; + items.push({ + field: t("diff.provider"), + manual: manualProvider, + litellm: litellmProvider, + changed: manualProvider !== litellmProvider, + }); + + // 类型 + const manualMode = manualPrice.mode || "-"; + const litellmMode = litellmPrice.mode || "-"; + items.push({ + field: t("diff.mode"), + manual: manualMode, + litellm: litellmMode, + changed: manualMode !== litellmMode, + }); + + return items; + }, [manualPrice, litellmPrice, t]); + + return ( + + + + + +
+
{t("diffTitle")}
+ + + + {t("diff.field")} + {t("diff.manual")} + {t("diff.litellm")} + + + + {diffs.map((diff) => ( + + {diff.field} + + {diff.changed ? ( + {diff.manual} + ) : ( + diff.manual + )} + + + {diff.changed ? ( + {diff.litellm} + ) : ( + diff.litellm + )} + + + ))} + +
+
+
+
+ ); +} + +/** + * 同步冲突对比弹窗 + */ +export function SyncConflictDialog({ + open, + onOpenChange, + conflicts, + onConfirm, + isLoading = false, +}: SyncConflictDialogProps) { + const t = useTranslations("settings.prices.conflict"); + const tCommon = useTranslations("settings.common"); + + const [selectedModels, setSelectedModels] = useState>(new Set()); + const [searchTerm, setSearchTerm] = useState(""); + const [page, setPage] = useState(1); + + // 过滤冲突列表 + const filteredConflicts = useMemo(() => { + if (!searchTerm.trim()) return conflicts; + const term = searchTerm.toLowerCase(); + return conflicts.filter((c) => c.modelName.toLowerCase().includes(term)); + }, [conflicts, searchTerm]); + + // 分页 + const totalPages = Math.ceil(filteredConflicts.length / PAGE_SIZE); + const paginatedConflicts = useMemo(() => { + const start = (page - 1) * PAGE_SIZE; + return filteredConflicts.slice(start, start + PAGE_SIZE); + }, [filteredConflicts, page]); + + // 全选/取消全选(仅当前页) + const allCurrentPageSelected = paginatedConflicts.every((c) => selectedModels.has(c.modelName)); + const someCurrentPageSelected = + paginatedConflicts.some((c) => selectedModels.has(c.modelName)) && !allCurrentPageSelected; + + const handleSelectAll = (checked: boolean) => { + const newSelected = new Set(selectedModels); + if (checked) { + paginatedConflicts.forEach((c) => newSelected.add(c.modelName)); + } else { + paginatedConflicts.forEach((c) => newSelected.delete(c.modelName)); + } + setSelectedModels(newSelected); + }; + + const handleSelectModel = (modelName: string, checked: boolean) => { + const newSelected = new Set(selectedModels); + if (checked) { + newSelected.add(modelName); + } else { + newSelected.delete(modelName); + } + setSelectedModels(newSelected); + }; + + const handleConfirm = () => { + onConfirm(Array.from(selectedModels)); + }; + + const handleCancel = () => { + // 取消时不覆盖任何手动模型 + onConfirm([]); + }; + + // 搜索时重置页码 + const handleSearchChange = (value: string) => { + setSearchTerm(value); + setPage(1); + }; + + return ( + + + + + + {t("title")} + + {t("description")} + + +
+ {/* 搜索框 */} +
+ + handleSearchChange(e.target.value)} + className="pl-9" + /> +
+ + {/* 冲突列表 */} +
+ + + + + { + if (el) { + (el as HTMLButtonElement & { indeterminate?: boolean }).indeterminate = + someCurrentPageSelected; + } + }} + onCheckedChange={handleSelectAll} + /> + + {t("table.modelName")} + {t("table.manualPrice")} + {t("table.litellmPrice")} + {t("table.action")} + + + + {paginatedConflicts.length > 0 ? ( + paginatedConflicts.map((conflict) => ( + + + + handleSelectModel(conflict.modelName, !!checked) + } + /> + + {conflict.modelName} + + + {formatPrice(conflict.manualPrice.input_cost_per_token)} + + + + + {formatPrice(conflict.litellmPrice.input_cost_per_token)} + + + + + + + )) + ) : ( + + + {searchTerm ? t("noMatch") : t("noConflicts")} + + + )} + +
+
+ + {/* 分页 */} + {totalPages > 1 && ( +
+ + {t("pagination.showing", { + from: (page - 1) * PAGE_SIZE + 1, + to: Math.min(page * PAGE_SIZE, filteredConflicts.length), + total: filteredConflicts.length, + })} + +
+ + + {page} / {totalPages} + + +
+
+ )} + + {/* 选中统计 */} +
+ {t("selectedCount", { count: selectedModels.size, total: conflicts.length })} +
+
+ + + + + +
+
+ ); +} diff --git a/src/app/[locale]/settings/prices/_components/sync-litellm-button.tsx b/src/app/[locale]/settings/prices/_components/sync-litellm-button.tsx index 78edbeeee..ed0f77cb0 100644 --- a/src/app/[locale]/settings/prices/_components/sync-litellm-button.tsx +++ b/src/app/[locale]/settings/prices/_components/sync-litellm-button.tsx @@ -5,8 +5,10 @@ import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useState } from "react"; import { toast } from "sonner"; -import { syncLiteLLMPrices } from "@/actions/model-prices"; +import { checkLiteLLMSyncConflicts, syncLiteLLMPrices } from "@/actions/model-prices"; import { Button } from "@/components/ui/button"; +import type { SyncConflict } from "@/types/model-price"; +import { SyncConflictDialog } from "./sync-conflict-dialog"; /** * LiteLLM 价格同步按钮组件 @@ -15,12 +17,20 @@ export function SyncLiteLLMButton() { const t = useTranslations("settings"); const router = useRouter(); const [syncing, setSyncing] = useState(false); + const [checking, setChecking] = useState(false); - const handleSync = async () => { + // 冲突弹窗状态 + const [conflictDialogOpen, setConflictDialogOpen] = useState(false); + const [conflicts, setConflicts] = useState([]); + + /** + * 执行同步(可选覆盖列表) + */ + const doSync = async (overwriteManual?: string[]) => { setSyncing(true); try { - const response = await syncLiteLLMPrices(); + const response = await syncLiteLLMPrices(overwriteManual); if (!response.ok) { toast.error(response.error || t("prices.sync.failed")); @@ -32,7 +42,7 @@ export function SyncLiteLLMButton() { return; } - const { added, updated, unchanged, failed } = response.data; + const { added, updated, unchanged, failed, skippedConflicts } = response.data; // 优先显示失败信息(更明显) if (failed.length > 0) { @@ -47,13 +57,16 @@ export function SyncLiteLLMButton() { // 显示成功信息 if (added.length > 0 || updated.length > 0) { - toast.success( - t("prices.sync.successWithChanges", { - added: added.length, - updated: updated.length, - unchanged: unchanged.length, - }) - ); + let message = t("prices.sync.successWithChanges", { + added: added.length, + updated: updated.length, + unchanged: unchanged.length, + }); + // 如果有跳过的冲突,追加提示 + if (skippedConflicts && skippedConflicts.length > 0) { + message += ` (${t("prices.sync.skippedConflicts", { count: skippedConflicts.length })})`; + } + toast.success(message); } else if (unchanged.length > 0) { toast.info(t("prices.sync.successNoChanges", { unchanged: unchanged.length })); } else if (failed.length === 0) { @@ -62,6 +75,7 @@ export function SyncLiteLLMButton() { // 刷新页面数据 router.refresh(); + window.dispatchEvent(new Event("price-data-updated")); } catch (error) { console.error("同步失败:", error); toast.error(t("prices.sync.failedError")); @@ -70,10 +84,66 @@ export function SyncLiteLLMButton() { } }; + /** + * 处理同步按钮点击 - 先检查冲突 + */ + const handleSync = async () => { + setChecking(true); + + try { + // 先检查是否有冲突 + const checkResult = await checkLiteLLMSyncConflicts(); + + if (!checkResult.ok) { + toast.error(checkResult.error || t("prices.sync.failed")); + return; + } + + if (checkResult.data?.hasConflicts && checkResult.data.conflicts.length > 0) { + // 有冲突,显示弹窗 + setConflicts(checkResult.data.conflicts); + setConflictDialogOpen(true); + } else { + // 无冲突,直接同步 + await doSync(); + } + } catch (error) { + console.error("检查冲突失败:", error); + toast.error(t("prices.sync.failedError")); + } finally { + setChecking(false); + } + }; + + /** + * 处理冲突弹窗确认 + */ + const handleConflictConfirm = async (selectedModels: string[]) => { + setConflictDialogOpen(false); + // 执行同步,传入要覆盖的模型列表 + await doSync(selectedModels); + }; + + const isLoading = syncing || checking; + return ( - + <> + + + + ); } diff --git a/src/app/[locale]/settings/prices/_components/upload-price-dialog.tsx b/src/app/[locale]/settings/prices/_components/upload-price-dialog.tsx index cf92d9ba5..358ec76ba 100644 --- a/src/app/[locale]/settings/prices/_components/upload-price-dialog.tsx +++ b/src/app/[locale]/settings/prices/_components/upload-price-dialog.tsx @@ -118,6 +118,7 @@ export function UploadPriceDialog({ setResult(response.data); const totalUpdates = response.data.added.length + response.data.updated.length; toast.success(t("dialog.updateSuccess", { count: totalUpdates })); + window.dispatchEvent(new Event("price-data-updated")); } catch (error) { console.error("更新失败:", error); toast.error(t("dialog.updateFailed")); diff --git a/src/app/[locale]/settings/prices/page.tsx b/src/app/[locale]/settings/prices/page.tsx index 3f4238bf8..f3b21cc51 100644 --- a/src/app/[locale]/settings/prices/page.tsx +++ b/src/app/[locale]/settings/prices/page.tsx @@ -3,6 +3,7 @@ import { Suspense } from "react"; import { getModelPrices, getModelPricesPaginated } from "@/actions/model-prices"; import { Section } from "@/components/section"; import { SettingsPageHeader } from "../_components/settings-page-header"; +import { ModelPriceDialog } from "./_components/model-price-dialog"; import { PriceList } from "./_components/price-list"; import { PricesSkeleton } from "./_components/prices-skeleton"; import { SyncLiteLLMButton } from "./_components/sync-litellm-button"; @@ -73,6 +74,7 @@ async function SettingsPricesContent({ searchParams }: SettingsPricesPageProps) description={t("prices.section.description")} actions={
+
diff --git a/src/drizzle/schema.ts b/src/drizzle/schema.ts index f162fef90..17154d356 100644 --- a/src/drizzle/schema.ts +++ b/src/drizzle/schema.ts @@ -366,6 +366,8 @@ export const modelPrices = pgTable('model_prices', { id: serial('id').primaryKey(), modelName: varchar('model_name').notNull(), priceData: jsonb('price_data').notNull(), + // 价格来源: 'litellm' = 从 LiteLLM 同步, 'manual' = 手动添加 + source: varchar('source', { length: 20 }).notNull().default('litellm').$type<'litellm' | 'manual'>(), createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(), }, (table) => ({ @@ -374,6 +376,8 @@ export const modelPrices = pgTable('model_prices', { // 基础索引 modelPricesModelNameIdx: index('idx_model_prices_model_name').on(table.modelName), modelPricesCreatedAtIdx: index('idx_model_prices_created_at').on(table.createdAt.desc()), + // 按来源过滤的索引 + modelPricesSourceIdx: index('idx_model_prices_source').on(table.source), })); // Error Rules table diff --git a/src/lib/price-sync.ts b/src/lib/price-sync.ts index bb3006c27..002933ab7 100644 --- a/src/lib/price-sync.ts +++ b/src/lib/price-sync.ts @@ -13,7 +13,7 @@ import { isClientAbortError } from "@/app/v1/_lib/proxy/errors"; import { logger } from "@/lib/logger"; const LITELLM_PRICE_URL = - "https://jsd-proxy.ygxz.in/gh/BerriAI/litellm/model_prices_and_context_window.json"; + "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json"; const CACHE_FILE_PATH = path.join(process.cwd(), "public", "cache", "litellm-prices.json"); const FETCH_TIMEOUT_MS = 10000; // 10 秒超时 diff --git a/src/repository/_shared/transformers.ts b/src/repository/_shared/transformers.ts index 1b144a705..218048ceb 100644 --- a/src/repository/_shared/transformers.ts +++ b/src/repository/_shared/transformers.ts @@ -141,6 +141,7 @@ export function toMessageRequest(dbMessage: any): MessageRequest { export function toModelPrice(dbPrice: any): ModelPrice { return { ...dbPrice, + source: dbPrice?.source ?? "litellm", // 默认为 litellm(向后兼容) createdAt: dbPrice?.createdAt ? new Date(dbPrice.createdAt) : new Date(), updatedAt: dbPrice?.updatedAt ? new Date(dbPrice.updatedAt) : new Date(), }; diff --git a/src/repository/model-price.ts b/src/repository/model-price.ts index ad7f98407..0d5ab8e0e 100644 --- a/src/repository/model-price.ts +++ b/src/repository/model-price.ts @@ -3,7 +3,7 @@ import { desc, eq, sql } from "drizzle-orm"; import { db } from "@/drizzle/db"; import { modelPrices } from "@/drizzle/schema"; -import type { ModelPrice, ModelPriceData } from "@/types/model-price"; +import type { ModelPrice, ModelPriceData, ModelPriceSource } from "@/types/model-price"; import { toModelPrice } from "./_shared/transformers"; /** @@ -13,6 +13,7 @@ export interface PaginationParams { page: number; pageSize: number; search?: string; // 可选的搜索关键词 + source?: ModelPriceSource; // 可选的来源过滤 } /** @@ -35,6 +36,7 @@ export async function findLatestPriceByModel(modelName: string): Promise { mp.id, mp.model_name, mp.price_data, + mp.source, mp.created_at, mp.updated_at, ROW_NUMBER() OVER (PARTITION BY mp.model_name ORDER BY mp.id DESC) as rn @@ -77,6 +80,7 @@ export async function findAllLatestPrices(): Promise { id, model_name as "modelName", price_data as "priceData", + source, created_at as "createdAt", updated_at as "updatedAt" FROM latest_records @@ -95,9 +99,25 @@ export async function findAllLatestPrices(): Promise { export async function findAllLatestPricesPaginated( params: PaginationParams ): Promise> { - const { page, pageSize, search } = params; + const { page, pageSize, search, source } = params; const offset = (page - 1) * pageSize; + // 构建 WHERE 条件 + const buildWhereCondition = () => { + const conditions: ReturnType[] = []; + if (search?.trim()) { + conditions.push(sql`model_name ILIKE ${`%${search.trim()}%`}`); + } + if (source) { + conditions.push(sql`source = ${source}`); + } + if (conditions.length === 0) return sql``; + if (conditions.length === 1) return sql`WHERE ${conditions[0]}`; + return sql`WHERE ${sql.join(conditions, sql` AND `)}`; + }; + + const whereCondition = buildWhereCondition(); + // 先获取总数 const countQuery = sql` WITH latest_prices AS ( @@ -105,7 +125,7 @@ export async function findAllLatestPricesPaginated( model_name, MAX(created_at) as max_created_at FROM model_prices - ${search?.trim() ? sql`WHERE model_name ILIKE ${`%${search.trim()}%`}` : sql``} + ${whereCondition} GROUP BY model_name ), latest_records AS ( @@ -132,7 +152,7 @@ export async function findAllLatestPricesPaginated( model_name, MAX(created_at) as max_created_at FROM model_prices - ${search?.trim() ? sql`WHERE model_name ILIKE ${`%${search.trim()}%`}` : sql``} + ${whereCondition} GROUP BY model_name ), latest_records AS ( @@ -140,6 +160,7 @@ export async function findAllLatestPricesPaginated( mp.id, mp.model_name, mp.price_data, + mp.source, mp.created_at, mp.updated_at, ROW_NUMBER() OVER (PARTITION BY mp.model_name ORDER BY mp.id DESC) as rn @@ -152,6 +173,7 @@ export async function findAllLatestPricesPaginated( id, model_name as "modelName", price_data as "priceData", + source, created_at as "createdAt", updated_at as "updatedAt" FROM latest_records @@ -183,21 +205,25 @@ export async function hasAnyPriceRecords(): Promise { /** * 创建新的价格记录 + * @param source - 价格来源,默认为 'litellm'(同步时使用),手动添加时传入 'manual' */ export async function createModelPrice( modelName: string, - priceData: ModelPriceData + priceData: ModelPriceData, + source: ModelPriceSource = "litellm" ): Promise { const [price] = await db .insert(modelPrices) .values({ modelName: modelName, priceData: priceData, + source: source, }) .returning({ id: modelPrices.id, modelName: modelPrices.modelName, priceData: modelPrices.priceData, + source: modelPrices.source, createdAt: modelPrices.createdAt, updatedAt: modelPrices.updatedAt, }); @@ -205,6 +231,88 @@ export async function createModelPrice( return toModelPrice(price); } +/** + * 更新或插入模型价格(先删除旧记录,再插入新记录) + * 用于手动维护单个模型价格,source 固定为 'manual' + */ +export async function upsertModelPrice( + modelName: string, + priceData: ModelPriceData +): Promise { + // 使用事务确保删除和插入的原子性 + return await db.transaction(async (tx) => { + // 先删除该模型的所有旧记录 + await tx.delete(modelPrices).where(eq(modelPrices.modelName, modelName)); + + // 插入新记录,source 固定为 'manual' + const [price] = await tx + .insert(modelPrices) + .values({ + modelName: modelName, + priceData: priceData, + source: "manual", + }) + .returning(); + return toModelPrice(price); + }); +} + +/** + * 删除指定模型的所有价格记录(硬删除) + */ +export async function deleteModelPriceByName(modelName: string): Promise { + await db.delete(modelPrices).where(eq(modelPrices.modelName, modelName)); +} + +/** + * 获取数据库中所有 source='manual' 的最新价格记录 + * 返回 Map + */ +export async function findAllManualPrices(): Promise> { + const query = sql` + WITH latest_prices AS ( + SELECT + model_name, + MAX(created_at) as max_created_at + FROM model_prices + WHERE source = 'manual' + GROUP BY model_name + ), + latest_records AS ( + SELECT + mp.id, + mp.model_name, + mp.price_data, + mp.source, + mp.created_at, + mp.updated_at, + ROW_NUMBER() OVER (PARTITION BY mp.model_name ORDER BY mp.id DESC) as rn + FROM model_prices mp + INNER JOIN latest_prices lp + ON mp.model_name = lp.model_name + AND mp.created_at = lp.max_created_at + ) + SELECT + id, + model_name as "modelName", + price_data as "priceData", + source, + created_at as "createdAt", + updated_at as "updatedAt" + FROM latest_records + WHERE rn = 1 + `; + + const result = await db.execute(query); + const prices = Array.from(result).map(toModelPrice); + + const priceMap = new Map(); + for (const price of prices) { + priceMap.set(price.modelName, price); + } + return priceMap; +} + /** * 批量创建价格记录 */ diff --git a/src/types/model-price.ts b/src/types/model-price.ts index 91c998664..b370719ad 100644 --- a/src/types/model-price.ts +++ b/src/types/model-price.ts @@ -50,6 +50,11 @@ export interface ModelPriceData { [key: string]: unknown; // 允许额外字段 } +/** + * 价格来源类型 + */ +export type ModelPriceSource = "litellm" | "manual"; + /** * 模型价格记录 */ @@ -57,6 +62,7 @@ export interface ModelPrice { id: number; modelName: string; priceData: ModelPriceData; + source: ModelPriceSource; createdAt: Date; updatedAt: Date; } @@ -77,4 +83,22 @@ export interface PriceUpdateResult { unchanged: string[]; // 未变化的模型 failed: string[]; // 处理失败的模型 total: number; // 总数 + skippedConflicts?: string[]; // 因冲突而跳过的手动添加模型 +} + +/** + * 同步冲突信息 + */ +export interface SyncConflict { + modelName: string; + manualPrice: ModelPriceData; // 当前手动添加的价格 + litellmPrice: ModelPriceData; // LiteLLM 中的价格 +} + +/** + * 同步冲突检查结果 + */ +export interface SyncConflictCheckResult { + hasConflicts: boolean; + conflicts: SyncConflict[]; } diff --git a/tests/unit/actions/model-prices.test.ts b/tests/unit/actions/model-prices.test.ts new file mode 100644 index 000000000..e6e7a94ad --- /dev/null +++ b/tests/unit/actions/model-prices.test.ts @@ -0,0 +1,448 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ModelPrice, ModelPriceData } from "@/types/model-price"; + +// Mock dependencies +const getSessionMock = vi.fn(); +const revalidatePathMock = vi.fn(); + +// Repository mocks +const findLatestPriceByModelMock = vi.fn(); +const createModelPriceMock = vi.fn(); +const upsertModelPriceMock = vi.fn(); +const deleteModelPriceByNameMock = vi.fn(); +const findAllManualPricesMock = vi.fn(); + +// Price sync mock +const getPriceTableJsonMock = vi.fn(); + +vi.mock("@/lib/auth", () => ({ + getSession: () => getSessionMock(), +})); + +vi.mock("next/cache", () => ({ + revalidatePath: () => revalidatePathMock(), +})); + +vi.mock("@/lib/logger", () => ({ + logger: { + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock("@/repository/model-price", () => ({ + findLatestPriceByModel: () => findLatestPriceByModelMock(), + createModelPrice: (...args: unknown[]) => createModelPriceMock(...args), + upsertModelPrice: (...args: unknown[]) => upsertModelPriceMock(...args), + deleteModelPriceByName: (...args: unknown[]) => deleteModelPriceByNameMock(...args), + findAllManualPrices: () => findAllManualPricesMock(), + findAllLatestPrices: vi.fn(async () => []), + findAllLatestPricesPaginated: vi.fn(async () => ({ + data: [], + total: 0, + page: 1, + pageSize: 50, + totalPages: 0, + })), + hasAnyPriceRecords: vi.fn(async () => false), +})); + +vi.mock("@/lib/price-sync", () => ({ + getPriceTableJson: () => getPriceTableJsonMock(), +})); + +// Helper to create mock ModelPrice +function makeMockPrice( + modelName: string, + priceData: Partial, + source: "litellm" | "manual" = "manual" +): ModelPrice { + const now = new Date(); + return { + id: Math.floor(Math.random() * 1000), + modelName, + priceData: { + mode: "chat", + input_cost_per_token: 0.000001, + output_cost_per_token: 0.000002, + ...priceData, + }, + source, + createdAt: now, + updatedAt: now, + }; +} + +describe("Model Price Actions", () => { + beforeEach(() => { + vi.clearAllMocks(); + // Default: admin session + getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); + }); + + describe("upsertSingleModelPrice", () => { + it("should create a new model price for admin", async () => { + const mockResult = makeMockPrice("gpt-5.2-codex", { + mode: "chat", + input_cost_per_token: 0.000015, + output_cost_per_token: 0.00006, + }); + upsertModelPriceMock.mockResolvedValue(mockResult); + + const { upsertSingleModelPrice } = await import("@/actions/model-prices"); + const result = await upsertSingleModelPrice({ + modelName: "gpt-5.2-codex", + mode: "chat", + litellmProvider: "openai", + inputCostPerToken: 0.000015, + outputCostPerToken: 0.00006, + }); + + expect(result.ok).toBe(true); + expect(result.data?.modelName).toBe("gpt-5.2-codex"); + expect(upsertModelPriceMock).toHaveBeenCalledWith( + "gpt-5.2-codex", + expect.objectContaining({ + mode: "chat", + litellm_provider: "openai", + input_cost_per_token: 0.000015, + output_cost_per_token: 0.00006, + }) + ); + }); + + it("should reject empty model name", async () => { + const { upsertSingleModelPrice } = await import("@/actions/model-prices"); + const result = await upsertSingleModelPrice({ + modelName: " ", + mode: "chat", + }); + + expect(result.ok).toBe(false); + expect(result.error).toContain("模型名称"); + expect(upsertModelPriceMock).not.toHaveBeenCalled(); + }); + + it("should reject non-admin users", async () => { + getSessionMock.mockResolvedValue({ user: { id: 2, role: "user" } }); + + const { upsertSingleModelPrice } = await import("@/actions/model-prices"); + const result = await upsertSingleModelPrice({ + modelName: "test-model", + mode: "chat", + }); + + expect(result.ok).toBe(false); + expect(result.error).toContain("无权限"); + expect(upsertModelPriceMock).not.toHaveBeenCalled(); + }); + + it("should handle image generation mode", async () => { + const mockResult = makeMockPrice("dall-e-3", { + mode: "image_generation", + output_cost_per_image: 0.04, + }); + upsertModelPriceMock.mockResolvedValue(mockResult); + + const { upsertSingleModelPrice } = await import("@/actions/model-prices"); + const result = await upsertSingleModelPrice({ + modelName: "dall-e-3", + mode: "image_generation", + litellmProvider: "openai", + outputCostPerImage: 0.04, + }); + + expect(result.ok).toBe(true); + expect(upsertModelPriceMock).toHaveBeenCalledWith( + "dall-e-3", + expect.objectContaining({ + mode: "image_generation", + output_cost_per_image: 0.04, + }) + ); + }); + + it("should handle repository errors gracefully", async () => { + upsertModelPriceMock.mockRejectedValue(new Error("Database error")); + + const { upsertSingleModelPrice } = await import("@/actions/model-prices"); + const result = await upsertSingleModelPrice({ + modelName: "test-model", + mode: "chat", + }); + + expect(result.ok).toBe(false); + expect(result.error).toBeDefined(); + }); + }); + + describe("deleteSingleModelPrice", () => { + it("should delete a model price for admin", async () => { + deleteModelPriceByNameMock.mockResolvedValue(undefined); + + const { deleteSingleModelPrice } = await import("@/actions/model-prices"); + const result = await deleteSingleModelPrice("gpt-5.2-codex"); + + expect(result.ok).toBe(true); + expect(deleteModelPriceByNameMock).toHaveBeenCalledWith("gpt-5.2-codex"); + }); + + it("should reject empty model name", async () => { + const { deleteSingleModelPrice } = await import("@/actions/model-prices"); + const result = await deleteSingleModelPrice(""); + + expect(result.ok).toBe(false); + expect(result.error).toContain("模型名称"); + expect(deleteModelPriceByNameMock).not.toHaveBeenCalled(); + }); + + it("should reject non-admin users", async () => { + getSessionMock.mockResolvedValue({ user: { id: 2, role: "user" } }); + + const { deleteSingleModelPrice } = await import("@/actions/model-prices"); + const result = await deleteSingleModelPrice("test-model"); + + expect(result.ok).toBe(false); + expect(result.error).toContain("无权限"); + expect(deleteModelPriceByNameMock).not.toHaveBeenCalled(); + }); + + it("should handle repository errors gracefully", async () => { + deleteModelPriceByNameMock.mockRejectedValue(new Error("Database error")); + + const { deleteSingleModelPrice } = await import("@/actions/model-prices"); + const result = await deleteSingleModelPrice("test-model"); + + expect(result.ok).toBe(false); + expect(result.error).toBeDefined(); + }); + }); + + describe("checkLiteLLMSyncConflicts", () => { + it("should return no conflicts when no manual prices exist", async () => { + findAllManualPricesMock.mockResolvedValue(new Map()); + getPriceTableJsonMock.mockResolvedValue( + JSON.stringify({ + "claude-3-opus": { mode: "chat", input_cost_per_token: 0.000015 }, + }) + ); + + const { checkLiteLLMSyncConflicts } = await import("@/actions/model-prices"); + const result = await checkLiteLLMSyncConflicts(); + + expect(result.ok).toBe(true); + expect(result.data?.hasConflicts).toBe(false); + expect(result.data?.conflicts).toHaveLength(0); + }); + + it("should detect conflicts when manual prices exist in LiteLLM", async () => { + const manualPrice = makeMockPrice("claude-3-opus", { + mode: "chat", + input_cost_per_token: 0.00001, + output_cost_per_token: 0.00002, + }); + + findAllManualPricesMock.mockResolvedValue(new Map([["claude-3-opus", manualPrice]])); + + getPriceTableJsonMock.mockResolvedValue( + JSON.stringify({ + "claude-3-opus": { + mode: "chat", + input_cost_per_token: 0.000015, + output_cost_per_token: 0.00006, + }, + }) + ); + + const { checkLiteLLMSyncConflicts } = await import("@/actions/model-prices"); + const result = await checkLiteLLMSyncConflicts(); + + expect(result.ok).toBe(true); + expect(result.data?.hasConflicts).toBe(true); + expect(result.data?.conflicts).toHaveLength(1); + expect(result.data?.conflicts[0]?.modelName).toBe("claude-3-opus"); + }); + + it("should not report conflicts for manual prices not in LiteLLM", async () => { + const manualPrice = makeMockPrice("custom-model", { + mode: "chat", + input_cost_per_token: 0.00001, + }); + + findAllManualPricesMock.mockResolvedValue(new Map([["custom-model", manualPrice]])); + + getPriceTableJsonMock.mockResolvedValue( + JSON.stringify({ + "claude-3-opus": { mode: "chat", input_cost_per_token: 0.000015 }, + }) + ); + + const { checkLiteLLMSyncConflicts } = await import("@/actions/model-prices"); + const result = await checkLiteLLMSyncConflicts(); + + expect(result.ok).toBe(true); + expect(result.data?.hasConflicts).toBe(false); + expect(result.data?.conflicts).toHaveLength(0); + }); + + it("should reject non-admin users", async () => { + getSessionMock.mockResolvedValue({ user: { id: 2, role: "user" } }); + + const { checkLiteLLMSyncConflicts } = await import("@/actions/model-prices"); + const result = await checkLiteLLMSyncConflicts(); + + expect(result.ok).toBe(false); + expect(result.error).toContain("无权限"); + }); + + it("should handle network errors gracefully", async () => { + findAllManualPricesMock.mockResolvedValue(new Map()); + getPriceTableJsonMock.mockResolvedValue(null); + + const { checkLiteLLMSyncConflicts } = await import("@/actions/model-prices"); + const result = await checkLiteLLMSyncConflicts(); + + expect(result.ok).toBe(false); + expect(result.error).toContain("CDN"); + }); + + it("should handle invalid JSON gracefully", async () => { + findAllManualPricesMock.mockResolvedValue(new Map()); + getPriceTableJsonMock.mockResolvedValue("invalid json {"); + + const { checkLiteLLMSyncConflicts } = await import("@/actions/model-prices"); + const result = await checkLiteLLMSyncConflicts(); + + expect(result.ok).toBe(false); + expect(result.error).toContain("JSON"); + }); + }); + + describe("processPriceTableInternal - source handling", () => { + it("should skip manual prices during sync by default", async () => { + const manualPrice = makeMockPrice("custom-model", { + mode: "chat", + input_cost_per_token: 0.00001, + }); + + findAllManualPricesMock.mockResolvedValue(new Map([["custom-model", manualPrice]])); + findLatestPriceByModelMock.mockResolvedValue(manualPrice); + + const { processPriceTableInternal } = await import("@/actions/model-prices"); + const result = await processPriceTableInternal( + JSON.stringify({ + "custom-model": { + mode: "chat", + input_cost_per_token: 0.000015, + }, + }) + ); + + expect(result.ok).toBe(true); + expect(result.data?.skippedConflicts).toContain("custom-model"); + expect(result.data?.unchanged).toContain("custom-model"); + expect(createModelPriceMock).not.toHaveBeenCalled(); + }); + + it("should overwrite manual prices when specified", async () => { + const manualPrice = makeMockPrice("custom-model", { + mode: "chat", + input_cost_per_token: 0.00001, + }); + + findAllManualPricesMock.mockResolvedValue(new Map([["custom-model", manualPrice]])); + findLatestPriceByModelMock.mockResolvedValue(manualPrice); + deleteModelPriceByNameMock.mockResolvedValue(undefined); + createModelPriceMock.mockResolvedValue( + makeMockPrice( + "custom-model", + { + mode: "chat", + input_cost_per_token: 0.000015, + }, + "litellm" + ) + ); + + const { processPriceTableInternal } = await import("@/actions/model-prices"); + const result = await processPriceTableInternal( + JSON.stringify({ + "custom-model": { + mode: "chat", + input_cost_per_token: 0.000015, + }, + }), + ["custom-model"] // Overwrite list + ); + + expect(result.ok).toBe(true); + expect(result.data?.updated).toContain("custom-model"); + expect(deleteModelPriceByNameMock).toHaveBeenCalledWith("custom-model"); + expect(createModelPriceMock).toHaveBeenCalled(); + }); + + it("should add new models with litellm source", async () => { + findAllManualPricesMock.mockResolvedValue(new Map()); + findLatestPriceByModelMock.mockResolvedValue(null); + createModelPriceMock.mockResolvedValue( + makeMockPrice( + "new-model", + { + mode: "chat", + }, + "litellm" + ) + ); + + const { processPriceTableInternal } = await import("@/actions/model-prices"); + const result = await processPriceTableInternal( + JSON.stringify({ + "new-model": { + mode: "chat", + input_cost_per_token: 0.000001, + }, + }) + ); + + expect(result.ok).toBe(true); + expect(result.data?.added).toContain("new-model"); + expect(createModelPriceMock).toHaveBeenCalledWith("new-model", expect.any(Object), "litellm"); + }); + + it("should skip metadata fields like sample_spec", async () => { + findAllManualPricesMock.mockResolvedValue(new Map()); + findLatestPriceByModelMock.mockResolvedValue(null); + + const { processPriceTableInternal } = await import("@/actions/model-prices"); + const result = await processPriceTableInternal( + JSON.stringify({ + sample_spec: { description: "This is metadata" }, + "real-model": { mode: "chat", input_cost_per_token: 0.000001 }, + }) + ); + + expect(result.ok).toBe(true); + expect(result.data?.total).toBe(1); // Only real-model + expect(result.data?.failed).not.toContain("sample_spec"); + }); + + it("should skip entries without mode field", async () => { + findAllManualPricesMock.mockResolvedValue(new Map()); + findLatestPriceByModelMock.mockResolvedValue(null); + + const { processPriceTableInternal } = await import("@/actions/model-prices"); + const result = await processPriceTableInternal( + JSON.stringify({ + "invalid-model": { input_cost_per_token: 0.000001 }, // No mode + "valid-model": { mode: "chat", input_cost_per_token: 0.000001 }, + }) + ); + + expect(result.ok).toBe(true); + expect(result.data?.failed).toContain("invalid-model"); + }); + }); +}); From c5cb60c32d45f9586134c9e24dce605bca9e48ce Mon Sep 17 00:00:00 2001 From: Ding <44717411+ding113@users.noreply.github.com> Date: Sat, 10 Jan 2026 17:53:44 +0800 Subject: [PATCH 09/17] fix: thinking enabled + tool_use first block (#577) * fix: disable thinking when tool_use is first block - Extend thinking signature rectifier to detect tool_use-first error and drop top-level thinking on retry - Add default error rule guidance for thinking/tool_use mismatch - Add unit tests for rectifier, forwarder retry path, and default rule sync * fix: improve thinking/tool_use error fallback --- .../thinking-signature-rectifier.test.ts | 38 ++++++ .../proxy/thinking-signature-rectifier.ts | 65 +++++++++ src/instrumentation.ts | 5 +- src/repository/error-rules.ts | 42 ++++++ src/types/special-settings.ts | 5 +- tests/unit/lib/emit-event.test.ts | 81 +++++++++++ .../unit/lib/session-manager-helpers.test.ts | 18 ++- ...arder-thinking-signature-rectifier.test.ts | 110 +++++++++++++++ ...ror-rules-default-thinking-tooluse.test.ts | 128 ++++++++++++++++++ 9 files changed, 484 insertions(+), 8 deletions(-) create mode 100644 tests/unit/lib/emit-event.test.ts create mode 100644 tests/unit/repository/error-rules-default-thinking-tooluse.test.ts diff --git a/src/app/v1/_lib/proxy/thinking-signature-rectifier.test.ts b/src/app/v1/_lib/proxy/thinking-signature-rectifier.test.ts index ae25421e2..fd9e95f75 100644 --- a/src/app/v1/_lib/proxy/thinking-signature-rectifier.test.ts +++ b/src/app/v1/_lib/proxy/thinking-signature-rectifier.test.ts @@ -21,6 +21,13 @@ describe("thinking-signature-rectifier", () => { expect(trigger).toBe("invalid_signature_in_thinking_block"); }); + test("应命中:thinking 启用但 assistant 首块为 tool_use(需关闭 thinking 兜底)", () => { + const trigger = detectThinkingSignatureRectifierTrigger( + "messages.69.content.0.type: Expected `thinking` or `redacted_thinking`, but found `tool_use`. When `thinking` is enabled, a final `assistant` message must start with a thinking block (preceeding the lastmost set of `tool_use` and `tool_result` blocks). To avoid this requirement, disable `thinking`." + ); + expect(trigger).toBe("assistant_message_must_start_with_thinking"); + }); + test("应命中:非法请求/illegal request/invalid request", () => { expect(detectThinkingSignatureRectifierTrigger("非法请求")).toBe("invalid_request"); expect(detectThinkingSignatureRectifierTrigger("illegal request format")).toBe( @@ -84,5 +91,36 @@ describe("thinking-signature-rectifier", () => { expect(result.removedRedactedThinkingBlocks).toBe(0); expect(result.removedSignatureFields).toBe(0); }); + + test("thinking 启用且 tool_use 续写缺少 thinking 前缀时,应删除顶层 thinking 字段", () => { + const message: Record = { + model: "claude-test", + thinking: { + type: "enabled", + budget_tokens: 1024, + }, + messages: [ + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "toolu_1", + name: "WebSearch", + input: { query: "q" }, + }, + ], + }, + { + role: "user", + content: [{ type: "tool_result", tool_use_id: "toolu_1", content: "ok" }], + }, + ], + }; + + const result = rectifyAnthropicRequestMessage(message); + expect(result.applied).toBe(true); + expect((message as any).thinking).toBeUndefined(); + }); }); }); diff --git a/src/app/v1/_lib/proxy/thinking-signature-rectifier.ts b/src/app/v1/_lib/proxy/thinking-signature-rectifier.ts index 22b114978..eb6fef99a 100644 --- a/src/app/v1/_lib/proxy/thinking-signature-rectifier.ts +++ b/src/app/v1/_lib/proxy/thinking-signature-rectifier.ts @@ -1,5 +1,6 @@ export type ThinkingSignatureRectifierTrigger = | "invalid_signature_in_thinking_block" + | "assistant_message_must_start_with_thinking" | "invalid_request"; export type ThinkingSignatureRectifierResult = { @@ -21,6 +22,23 @@ export function detectThinkingSignatureRectifierTrigger( const lower = errorMessage.toLowerCase(); + // Claude 官方错误提示:thinking 启用时,工具调用链路中的最后一条 assistant 消息必须以 thinking 开头 + // 典型信息: + // - Expected `thinking` or `redacted_thinking`, but found `tool_use` + // - a final `assistant` message must start with a thinking block + // + // 该场景通常发生在工具调用回合中途“切换 thinking 模式”或遗失 thinking block 时, + // 最安全的兜底是:在整流重试前关闭本次请求的顶层 thinking(避免继续触发 400)。 + const looksLikeThinkingEnabledButMissingThinkingPrefix = + lower.includes("must start with a thinking block") || + /expected\s*`?thinking`?\s*or\s*`?redacted_thinking`?.*found\s*`?tool_use`?/i.test( + errorMessage + ); + + if (looksLikeThinkingEnabledButMissingThinkingPrefix) { + return "assistant_message_must_start_with_thinking"; + } + // 兼容带/不带反引号、不同大小写的变体 const looksLikeInvalidSignatureInThinkingBlock = lower.includes("invalid") && @@ -114,6 +132,53 @@ export function rectifyAnthropicRequestMessage( } } + // 兜底:thinking 启用 + 工具调用链路中最后一条 assistant 消息未以 thinking/redacted_thinking 开头 + // 该组合会触发 Anthropic 400(Expected thinking..., but found tool_use)。 + // 为避免继续失败,整流阶段直接删除顶层 thinking 字段(只影响本次重试)。 + const thinking = (message as Record).thinking; + const thinkingEnabled = + thinking && + typeof thinking === "object" && + (thinking as Record).type === "enabled"; + + if (thinkingEnabled) { + let lastAssistantContent: unknown[] | null = null; + + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (!msg || typeof msg !== "object") continue; + const msgObj = msg as Record; + if (msgObj.role !== "assistant") continue; + if (!Array.isArray(msgObj.content)) continue; + lastAssistantContent = msgObj.content; + break; + } + + if (lastAssistantContent && lastAssistantContent.length > 0) { + const firstBlock = lastAssistantContent[0]; + const firstBlockType = + firstBlock && typeof firstBlock === "object" + ? (firstBlock as Record).type + : null; + + const missingThinkingPrefix = + firstBlockType !== "thinking" && firstBlockType !== "redacted_thinking"; + + // 仅在缺少 thinking 前缀时才需要进一步检查是否存在 tool_use + if (missingThinkingPrefix) { + const hasToolUse = lastAssistantContent.some((block) => { + if (!block || typeof block !== "object") return false; + return (block as Record).type === "tool_use"; + }); + + if (hasToolUse) { + delete (message as any).thinking; + applied = true; + } + } + } + } + return { applied, removedThinkingBlocks, diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 479d58c0e..535bca4d3 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -3,9 +3,11 @@ * 在服务器启动时自动执行数据库迁移 */ +// instrumentation 需要 Node.js runtime(依赖数据库与 Redis 等 Node 能力) +export const runtime = "nodejs"; + import { startCacheCleanup, stopCacheCleanup } from "@/lib/cache/session-cache"; import { logger } from "@/lib/logger"; -import { closeRedis } from "@/lib/redis"; const instrumentationState = globalThis as unknown as { __CCH_CACHE_CLEANUP_STARTED__?: boolean; @@ -78,6 +80,7 @@ export async function register() { } try { + const { closeRedis } = await import("@/lib/redis"); await closeRedis(); } catch (error) { logger.warn("[Instrumentation] Failed to close Redis connection", { diff --git a/src/repository/error-rules.ts b/src/repository/error-rules.ts index 1a4e41a2b..5fde75561 100644 --- a/src/repository/error-rules.ts +++ b/src/repository/error-rules.ts @@ -657,6 +657,48 @@ const DEFAULT_ERROR_RULES = [ }, }, }, + // thinking 已启用,但工具调用续写的 assistant 消息未以 thinking/redacted_thinking 块开头 + // 常见原因:工具调用回合中途切换 thinking 模式、或未原样回传上一轮 thinking/redacted_thinking 块(含 signature/data) + { + pattern: "expected\\s*`?thinking`?\\s*or\\s*`?redacted_thinking`?[^\\n]*found\\s*`?tool_use`?", + category: "thinking_error", + description: + "Thinking enabled but the last assistant tool_use message does not start with thinking/redacted_thinking", + matchType: "regex" as const, + isDefault: true, + isEnabled: true, + priority: 68, + overrideResponse: { + type: "error", + error: { + type: "thinking_error", + message: + "thinking 已启用,但工具调用续写的 assistant 消息未以 thinking/redacted_thinking 块开头。" + + "请在 tool_result 续写请求中原样回传上一轮 assistant 的 thinking/redacted_thinking 块(含 signature/data)," + + "或关闭 thinking 后重试。", + }, + }, + }, + { + pattern: "must start with a thinking block", + category: "thinking_error", + description: + "Thinking enabled but the last assistant tool_use message does not start with thinking/redacted_thinking", + matchType: "contains" as const, + isDefault: true, + isEnabled: true, + priority: 69, + overrideResponse: { + type: "error", + error: { + type: "thinking_error", + message: + "thinking 已启用,但工具调用续写的 assistant 消息未以 thinking/redacted_thinking 块开头。" + + "请在 tool_result 续写请求中原样回传上一轮 assistant 的 thinking/redacted_thinking 块(含 signature/data)," + + "或关闭 thinking 后重试。", + }, + }, + }, { pattern: "thinking.*format.*invalid|Expected.*thinking.*but found|clear_thinking.*requires.*thinking.*enabled", diff --git a/src/types/special-settings.ts b/src/types/special-settings.ts index a989e9bde..fdbcac1a6 100644 --- a/src/types/special-settings.ts +++ b/src/types/special-settings.ts @@ -100,7 +100,10 @@ export type ThinkingSignatureRectifierSpecialSetting = { hit: boolean; providerId: number | null; providerName: string | null; - trigger: "invalid_signature_in_thinking_block" | "invalid_request"; + trigger: + | "invalid_signature_in_thinking_block" + | "assistant_message_must_start_with_thinking" + | "invalid_request"; attemptNumber: number; retryAttemptNumber: number; removedThinkingBlocks: number; diff --git a/tests/unit/lib/emit-event.test.ts b/tests/unit/lib/emit-event.test.ts new file mode 100644 index 000000000..f5427b330 --- /dev/null +++ b/tests/unit/lib/emit-event.test.ts @@ -0,0 +1,81 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +const mocks = vi.hoisted(() => { + return { + emitErrorRulesUpdated: vi.fn(), + emitSensitiveWordsUpdated: vi.fn(), + emitRequestFiltersUpdated: vi.fn(), + publishCacheInvalidation: vi.fn(async () => {}), + }; +}); + +vi.mock("@/lib/event-emitter", () => ({ + eventEmitter: { + emitErrorRulesUpdated: mocks.emitErrorRulesUpdated, + emitSensitiveWordsUpdated: mocks.emitSensitiveWordsUpdated, + emitRequestFiltersUpdated: mocks.emitRequestFiltersUpdated, + }, +})); + +vi.mock("@/lib/redis/pubsub", () => ({ + CHANNEL_ERROR_RULES_UPDATED: "cch:cache:error_rules:updated", + CHANNEL_REQUEST_FILTERS_UPDATED: "cch:cache:request_filters:updated", + publishCacheInvalidation: mocks.publishCacheInvalidation, +})); + +describe.sequential("emit-event", () => { + const prevRuntime = process.env.NEXT_RUNTIME; + + beforeEach(() => { + vi.clearAllMocks(); + process.env.NEXT_RUNTIME = "nodejs"; + }); + + afterEach(() => { + process.env.NEXT_RUNTIME = prevRuntime; + }); + + test("emitErrorRulesUpdated:Node.js runtime 下应触发本地事件并广播缓存失效", async () => { + const { emitErrorRulesUpdated } = await import("@/lib/emit-event"); + await emitErrorRulesUpdated(); + + expect(mocks.emitErrorRulesUpdated).toHaveBeenCalledTimes(1); + expect(mocks.publishCacheInvalidation).toHaveBeenCalledTimes(1); + expect(mocks.publishCacheInvalidation).toHaveBeenCalledWith("cch:cache:error_rules:updated"); + }); + + test("emitSensitiveWordsUpdated:Node.js runtime 下仅触发本地事件", async () => { + const { emitSensitiveWordsUpdated } = await import("@/lib/emit-event"); + await emitSensitiveWordsUpdated(); + + expect(mocks.emitSensitiveWordsUpdated).toHaveBeenCalledTimes(1); + expect(mocks.publishCacheInvalidation).not.toHaveBeenCalled(); + }); + + test("emitRequestFiltersUpdated:Node.js runtime 下应触发本地事件并广播缓存失效", async () => { + const { emitRequestFiltersUpdated } = await import("@/lib/emit-event"); + await emitRequestFiltersUpdated(); + + expect(mocks.emitRequestFiltersUpdated).toHaveBeenCalledTimes(1); + expect(mocks.publishCacheInvalidation).toHaveBeenCalledTimes(1); + expect(mocks.publishCacheInvalidation).toHaveBeenCalledWith( + "cch:cache:request_filters:updated" + ); + }); + + test("Edge runtime 下应静默跳过(不触发任何事件/广播)", async () => { + process.env.NEXT_RUNTIME = "edge"; + + const { emitErrorRulesUpdated, emitSensitiveWordsUpdated, emitRequestFiltersUpdated } = + await import("@/lib/emit-event"); + + await emitErrorRulesUpdated(); + await emitSensitiveWordsUpdated(); + await emitRequestFiltersUpdated(); + + expect(mocks.emitErrorRulesUpdated).not.toHaveBeenCalled(); + expect(mocks.emitSensitiveWordsUpdated).not.toHaveBeenCalled(); + expect(mocks.emitRequestFiltersUpdated).not.toHaveBeenCalled(); + expect(mocks.publishCacheInvalidation).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/lib/session-manager-helpers.test.ts b/tests/unit/lib/session-manager-helpers.test.ts index 084082868..bc3fce41c 100644 --- a/tests/unit/lib/session-manager-helpers.test.ts +++ b/tests/unit/lib/session-manager-helpers.test.ts @@ -1,6 +1,11 @@ import { describe, expect, test, vi } from "vitest"; const loggerWarnMock = vi.fn(); +const PARSE_HEADER_RECORD_WARN_MESSAGE = "SessionManager: Failed to parse header record JSON"; + +function getParseHeaderRecordWarnCalls(): unknown[][] { + return loggerWarnMock.mock.calls.filter((call) => call[0] === PARSE_HEADER_RECORD_WARN_MESSAGE); +} vi.mock("server-only", () => ({})); @@ -33,7 +38,7 @@ describe("SessionManager 辅助函数", () => { const { parseHeaderRecord } = await loadHelpers(); expect(parseHeaderRecord('{"a":"1","b":"2"}')).toEqual({ a: "1", b: "2" }); - expect(loggerWarnMock).not.toHaveBeenCalled(); + expect(getParseHeaderRecordWarnCalls()).toHaveLength(0); }); test("parseHeaderRecord:空对象应返回空记录", async () => { @@ -41,7 +46,7 @@ describe("SessionManager 辅助函数", () => { const { parseHeaderRecord } = await loadHelpers(); expect(parseHeaderRecord("{}")).toEqual({}); - expect(loggerWarnMock).not.toHaveBeenCalled(); + expect(getParseHeaderRecordWarnCalls()).toHaveLength(0); }); test("parseHeaderRecord:只保留字符串值", async () => { @@ -51,7 +56,7 @@ describe("SessionManager 辅助函数", () => { expect(parseHeaderRecord('{"a":"1","b":2,"c":true,"d":null,"e":{},"f":[]}')).toEqual({ a: "1", }); - expect(loggerWarnMock).not.toHaveBeenCalled(); + expect(getParseHeaderRecordWarnCalls()).toHaveLength(0); }); test("parseHeaderRecord:无效 JSON 应返回 null 并记录 warn", async () => { @@ -59,9 +64,10 @@ describe("SessionManager 辅助函数", () => { const { parseHeaderRecord } = await loadHelpers(); expect(parseHeaderRecord("{bad json")).toBe(null); - expect(loggerWarnMock).toHaveBeenCalledTimes(1); + const calls = getParseHeaderRecordWarnCalls(); + expect(calls).toHaveLength(1); - const [message, meta] = loggerWarnMock.mock.calls[0] ?? []; + const [message, meta] = calls[0] ?? []; expect(message).toBe("SessionManager: Failed to parse header record JSON"); expect(meta).toEqual(expect.objectContaining({ error: expect.anything() })); }); @@ -73,7 +79,7 @@ describe("SessionManager 辅助函数", () => { expect(parseHeaderRecord('["a"]')).toBe(null); expect(parseHeaderRecord("null")).toBe(null); expect(parseHeaderRecord("1")).toBe(null); - expect(loggerWarnMock).not.toHaveBeenCalled(); + expect(getParseHeaderRecordWarnCalls()).toHaveLength(0); }); test("headersToSanitizedObject:单个 header 应正确转换", async () => { diff --git a/tests/unit/proxy/proxy-forwarder-thinking-signature-rectifier.test.ts b/tests/unit/proxy/proxy-forwarder-thinking-signature-rectifier.test.ts index 2c8505ea6..d4a0e763b 100644 --- a/tests/unit/proxy/proxy-forwarder-thinking-signature-rectifier.test.ts +++ b/tests/unit/proxy/proxy-forwarder-thinking-signature-rectifier.test.ts @@ -205,6 +205,116 @@ describe("ProxyForwarder - thinking signature rectifier", () => { expect(mocks.updateMessageRequestDetails).toHaveBeenCalledTimes(1); }); + test("thinking 启用但 assistant 首块为 tool_use 的 400 错误时,应关闭 thinking 并对同供应商重试一次", async () => { + const session = createSession(); + session.setProvider(createAnthropicProvider()); + + const msg = session.request.message as any; + msg.thinking = { type: "enabled", budget_tokens: 1024 }; + msg.messages = [ + { role: "user", content: [{ type: "text", text: "hi" }] }, + { + role: "assistant", + content: [{ type: "tool_use", id: "toolu_1", name: "WebSearch", input: { query: "q" } }], + }, + { role: "user", content: [{ type: "tool_result", tool_use_id: "toolu_1", content: "ok" }] }, + ]; + + const doForward = vi.spyOn(ProxyForwarder as any, "doForward"); + + doForward.mockImplementationOnce(async () => { + throw new ProxyError( + "messages.69.content.0.type: Expected `thinking` or `redacted_thinking`, but found `tool_use`. When `thinking` is enabled, a final `assistant` message must start with a thinking block (preceeding the lastmost set of `tool_use` and `tool_result` blocks). To avoid this requirement, disable `thinking`.", + 400, + { + body: "", + providerId: 1, + providerName: "anthropic-1", + } + ); + }); + + doForward.mockImplementationOnce(async (s: ProxySession) => { + const bodyMsg = s.request.message as any; + expect(bodyMsg.thinking).toBeUndefined(); + + const body = JSON.stringify({ + type: "message", + content: [{ type: "text", text: "ok" }], + }); + + return new Response(body, { + status: 200, + headers: { + "content-type": "application/json", + "content-length": String(body.length), + }, + }); + }); + + const response = await ProxyForwarder.send(session); + + expect(response.status).toBe(200); + expect(doForward).toHaveBeenCalledTimes(2); + expect(mocks.updateMessageRequestDetails).toHaveBeenCalledTimes(1); + }); + + test("移除 thinking block 后若 tool_use 置顶且 thinking 仍启用,应同时关闭 thinking 再重试", async () => { + const session = createSession(); + session.setProvider(createAnthropicProvider()); + + const msg = session.request.message as any; + msg.thinking = { type: "enabled", budget_tokens: 1024 }; + msg.messages = [ + { + role: "assistant", + content: [ + { type: "thinking", thinking: "t", signature: "sig_thinking" }, + { type: "tool_use", id: "toolu_1", name: "WebSearch", input: { query: "q" } }, + ], + }, + { role: "user", content: [{ type: "tool_result", tool_use_id: "toolu_1", content: "ok" }] }, + ]; + + const doForward = vi.spyOn(ProxyForwarder as any, "doForward"); + + doForward.mockImplementationOnce(async () => { + throw new ProxyError("Invalid `signature` in `thinking` block", 400, { + body: "", + providerId: 1, + providerName: "anthropic-1", + }); + }); + + doForward.mockImplementationOnce(async (s: ProxySession) => { + const bodyMsg = s.request.message as any; + const blocks = bodyMsg.messages[0].content as any[]; + + expect(blocks.some((b) => b.type === "thinking")).toBe(false); + expect(blocks.some((b) => b.type === "redacted_thinking")).toBe(false); + expect(bodyMsg.thinking).toBeUndefined(); + + const body = JSON.stringify({ + type: "message", + content: [{ type: "text", text: "ok" }], + }); + + return new Response(body, { + status: 200, + headers: { + "content-type": "application/json", + "content-length": String(body.length), + }, + }); + }); + + const response = await ProxyForwarder.send(session); + + expect(response.status).toBe(200); + expect(doForward).toHaveBeenCalledTimes(2); + expect(mocks.updateMessageRequestDetails).toHaveBeenCalledTimes(1); + }); + test("匹配触发但无可整流内容时不应做无意义重试", async () => { const session = createSession(); session.setProvider(createAnthropicProvider()); diff --git a/tests/unit/repository/error-rules-default-thinking-tooluse.test.ts b/tests/unit/repository/error-rules-default-thinking-tooluse.test.ts new file mode 100644 index 000000000..4f58fd481 --- /dev/null +++ b/tests/unit/repository/error-rules-default-thinking-tooluse.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, test, vi } from "vitest"; + +// 该测试通过 mock 仓储层验证默认规则内容,不需要真实 DB/Redis。 +// 禁用 tests/setup.ts 中基于 DSN/Redis 的默认同步与清理协调,避免无关依赖引入。 +process.env.DSN = ""; +process.env.AUTO_CLEANUP_TEST_DATA = "false"; + +const capturedInsertedRules: any[] = []; + +vi.mock("drizzle-orm", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + // 仅用于构造查询条件参数,单测不关心其实现细节 + desc: vi.fn((...args: unknown[]) => ({ args, op: "desc" })), + eq: vi.fn((...args: unknown[]) => ({ args, op: "eq" })), + inArray: vi.fn((...args: unknown[]) => ({ args, op: "inArray" })), + }; +}); + +vi.mock("@/drizzle/schema", () => ({ + // 仅需提供被 syncDefaultErrorRules 用到的字段占位符 + errorRules: { + id: "error_rules.id", + pattern: "error_rules.pattern", + isDefault: "error_rules.is_default", + }, +})); + +vi.mock("@/drizzle/db", () => ({ + db: { + transaction: vi.fn(async (fn: (tx: any) => Promise) => { + const tx = { + query: { + errorRules: { + findMany: vi.fn(async () => []), + }, + }, + delete: vi.fn(() => ({ + where: vi.fn(async () => []), + })), + insert: vi.fn(() => ({ + values: (rule: any) => { + capturedInsertedRules.push(rule); + return { + onConflictDoNothing: () => ({ + returning: vi.fn(async () => [{ id: 1 }]), + }), + }; + }, + })), + update: vi.fn(() => ({ + set: vi.fn(() => ({ + where: vi.fn(async () => []), + })), + })), + }; + + await fn(tx); + }), + }, +})); + +vi.mock("@/lib/emit-event", () => ({ + emitErrorRulesUpdated: vi.fn(async () => {}), +})); + +vi.mock("@/lib/logger", () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + trace: vi.fn(), + }, +})); + +describe("syncDefaultErrorRules - 默认 thinking/tool_use 兜底规则", () => { + test("应包含 must start with a thinking block 的默认规则,并提供可操作提示", async () => { + capturedInsertedRules.length = 0; + vi.resetModules(); + + const { syncDefaultErrorRules } = await import("@/repository/error-rules"); + await syncDefaultErrorRules(); + + const rule = capturedInsertedRules.find( + (r) => r.pattern === "must start with a thinking block" + ); + expect(rule).toBeTruthy(); + + expect(rule.matchType).toBe("contains"); + expect(rule.category).toBe("thinking_error"); + + // 覆写响应需为 Claude 错误格式,且包含清晰的自助修复建议 + expect(rule.overrideResponse?.type).toBe("error"); + expect(rule.overrideResponse?.error?.type).toBe("thinking_error"); + expect(rule.overrideResponse?.error?.message).toContain("tool_result"); + expect(rule.overrideResponse?.error?.message).toContain("signature"); + expect(rule.overrideResponse?.error?.message).toContain("关闭"); + }); + + test("应包含 Expected thinking/redacted_thinking but found tool_use 的默认规则,并提供可操作提示", async () => { + capturedInsertedRules.length = 0; + vi.resetModules(); + + const { syncDefaultErrorRules } = await import("@/repository/error-rules"); + await syncDefaultErrorRules(); + + const rule = capturedInsertedRules.find( + (r) => + typeof r?.pattern === "string" && + r.pattern.includes("redacted_thinking") && + r.pattern.includes("tool_use") && + r.pattern.toLowerCase().includes("expected") + ); + expect(rule).toBeTruthy(); + + expect(rule.matchType).toBe("regex"); + expect(rule.category).toBe("thinking_error"); + expect(rule.priority).toBe(68); + + // 覆写响应需为 Claude 错误格式,且包含清晰的自助修复建议 + expect(rule.overrideResponse?.type).toBe("error"); + expect(rule.overrideResponse?.error?.type).toBe("thinking_error"); + expect(rule.overrideResponse?.error?.message).toContain("tool_result"); + expect(rule.overrideResponse?.error?.message).toContain("signature"); + expect(rule.overrideResponse?.error?.message).toContain("关闭"); + }); +}); From 0c682be1be8fceadb1442cb14a9ae373640a4743 Mon Sep 17 00:00:00 2001 From: YangQing-Lin <56943790+YangQing-Lin@users.noreply.github.com> Date: Sat, 10 Jan 2026 18:18:49 +0800 Subject: [PATCH 10/17] PR: fix(db): make drizzle migrations idempotent (#578) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(db): make latest drizzle migrations idempotent Use ADD COLUMN IF NOT EXISTS in 0044_uneven_donald_blake.sql (line 1) Use ADD COLUMN IF NOT EXISTS in 0049_shocking_ultimatum.sql (line 1) Use ADD COLUMN IF NOT EXISTS in 0050_flippant_jack_flag.sql (line 1) Use ADD COLUMN IF NOT EXISTS in 0051_silent_maelstrom.sql (line 1) Use ADD COLUMN IF NOT EXISTS in 0052_model_price_source.sql (line 1) * config: 添加 .codex 目录到 .gitignore --- .gitignore | 1 + drizzle/0049_shocking_ultimatum.sql | 2 +- drizzle/0050_flippant_jack_flag.sql | 4 ++-- drizzle/0051_silent_maelstrom.sql | 2 +- drizzle/0052_model_price_source.sql | 4 ++-- 5 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index 0908e34db..eb4139039 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,7 @@ next-env.d.ts # tooling & logs .cursor/ .claude/ +.codex/ .serena/ !src/app/dashboard/logs/ !src/app/settings/logs/ diff --git a/drizzle/0049_shocking_ultimatum.sql b/drizzle/0049_shocking_ultimatum.sql index 6a37f923f..42c58a963 100644 --- a/drizzle/0049_shocking_ultimatum.sql +++ b/drizzle/0049_shocking_ultimatum.sql @@ -1 +1 @@ -ALTER TABLE "message_request" ADD COLUMN "special_settings" jsonb; \ No newline at end of file +ALTER TABLE "message_request" ADD COLUMN IF NOT EXISTS "special_settings" jsonb; diff --git a/drizzle/0050_flippant_jack_flag.sql b/drizzle/0050_flippant_jack_flag.sql index 505093d5f..027b9c3f5 100644 --- a/drizzle/0050_flippant_jack_flag.sql +++ b/drizzle/0050_flippant_jack_flag.sql @@ -1,2 +1,2 @@ -ALTER TABLE "system_settings" ADD COLUMN "enable_response_fixer" boolean DEFAULT true NOT NULL;--> statement-breakpoint -ALTER TABLE "system_settings" ADD COLUMN "response_fixer_config" jsonb DEFAULT '{"fixTruncatedJson":true,"fixSseFormat":true,"fixEncoding":true,"maxJsonDepth":200,"maxFixSize":1048576}'::jsonb; \ No newline at end of file +ALTER TABLE "system_settings" ADD COLUMN IF NOT EXISTS "enable_response_fixer" boolean DEFAULT true NOT NULL;--> statement-breakpoint +ALTER TABLE "system_settings" ADD COLUMN IF NOT EXISTS "response_fixer_config" jsonb DEFAULT '{"fixTruncatedJson":true,"fixSseFormat":true,"fixEncoding":true,"maxJsonDepth":200,"maxFixSize":1048576}'::jsonb; diff --git a/drizzle/0051_silent_maelstrom.sql b/drizzle/0051_silent_maelstrom.sql index 987702efd..cbcda452a 100644 --- a/drizzle/0051_silent_maelstrom.sql +++ b/drizzle/0051_silent_maelstrom.sql @@ -1 +1 @@ -ALTER TABLE "system_settings" ADD COLUMN "enable_thinking_signature_rectifier" boolean DEFAULT true NOT NULL; \ No newline at end of file +ALTER TABLE "system_settings" ADD COLUMN IF NOT EXISTS "enable_thinking_signature_rectifier" boolean DEFAULT true NOT NULL; diff --git a/drizzle/0052_model_price_source.sql b/drizzle/0052_model_price_source.sql index 5075e48f4..21b44c2f9 100644 --- a/drizzle/0052_model_price_source.sql +++ b/drizzle/0052_model_price_source.sql @@ -1,2 +1,2 @@ -ALTER TABLE "model_prices" ADD COLUMN "source" varchar(20) DEFAULT 'litellm' NOT NULL;--> statement-breakpoint -CREATE INDEX IF NOT EXISTS "idx_model_prices_source" ON "model_prices" USING btree ("source"); \ No newline at end of file +ALTER TABLE "model_prices" ADD COLUMN IF NOT EXISTS "source" varchar(20) DEFAULT 'litellm' NOT NULL;--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "idx_model_prices_source" ON "model_prices" USING btree ("source"); From 373220d3e140a4684e80fd00b6bd472963f3f21f Mon Sep 17 00:00:00 2001 From: Ding <44717411+ding113@users.noreply.github.com> Date: Sat, 10 Jan 2026 21:48:28 +0800 Subject: [PATCH 11/17] refactor(prices): TOML cloud price table + billing fail-open (#580) * refactor(prices): sync TOML cloud price table and harden billing * fix(prices): address PR review feedback --- drizzle/meta/0052_snapshot.json | 25 +- drizzle/meta/_journal.json | 4 +- messages/en/settings.json | 42 +- messages/ja/settings.json | 42 +- messages/ru/settings.json | 42 +- messages/zh-CN/settings.json | 42 +- messages/zh-TW/settings.json | 42 +- package.json | 1 + src/actions/model-prices.ts | 152 ++++++-- .../prices/_components/price-list.tsx | 364 +++++++++++++---- .../_components/sync-litellm-button.tsx | 20 +- .../_components/upload-price-dialog.tsx | 19 +- src/app/[locale]/settings/prices/page.tsx | 19 +- src/app/api/prices/route.ts | 21 +- src/app/v1/_lib/proxy/response-handler.ts | 365 ++++++++++-------- src/app/v1/_lib/proxy/session.ts | 40 +- src/instrumentation.ts | 62 +++ src/lib/price-sync.ts | 128 ------ src/lib/price-sync/cloud-price-table.ts | 107 +++++ src/lib/price-sync/cloud-price-updater.ts | 104 +++++ src/lib/utils/price-data.ts | 40 ++ src/repository/model-price.ts | 165 +++----- src/types/model-price.ts | 2 + .../integration/billing-model-source.test.ts | 87 ++++- tests/unit/actions/model-prices.test.ts | 113 ++++-- tests/unit/api/prices-route.test.ts | 76 ++++ .../unit/price-sync/cloud-price-table.test.ts | 225 +++++++++++ .../price-sync/cloud-price-updater.test.ts | 249 ++++++++++++ tests/unit/proxy/pricing-no-price.test.ts | 245 ++++++++++++ .../prices/price-list-zero-price-ui.test.tsx | 85 ++++ 30 files changed, 2287 insertions(+), 641 deletions(-) delete mode 100644 src/lib/price-sync.ts create mode 100644 src/lib/price-sync/cloud-price-table.ts create mode 100644 src/lib/price-sync/cloud-price-updater.ts create mode 100644 src/lib/utils/price-data.ts create mode 100644 tests/unit/api/prices-route.test.ts create mode 100644 tests/unit/price-sync/cloud-price-table.test.ts create mode 100644 tests/unit/price-sync/cloud-price-updater.test.ts create mode 100644 tests/unit/proxy/pricing-no-price.test.ts create mode 100644 tests/unit/settings/prices/price-list-zero-price-ui.test.tsx diff --git a/drizzle/meta/0052_snapshot.json b/drizzle/meta/0052_snapshot.json index 62239cbb4..970c0d530 100644 --- a/drizzle/meta/0052_snapshot.json +++ b/drizzle/meta/0052_snapshot.json @@ -1,5 +1,5 @@ { - "id": "e7a58fbf-6e7a-4c5f-a0ac-255fcf6439d7", + "id": "313bc169-3d11-418a-a91a-89d7a10a5d1f", "prevId": "c7b01fc8-2ed8-4359-a233-9fa3a2f7e8ec", "version": "7", "dialect": "postgresql", @@ -796,6 +796,13 @@ "primaryKey": false, "notNull": true }, + "source": { + "name": "source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'litellm'" + }, "created_at": { "name": "created_at", "type": "timestamp with time zone", @@ -809,13 +816,6 @@ "primaryKey": false, "notNull": false, "default": "now()" - }, - "source": { - "name": "source", - "type": "varchar(20)", - "primaryKey": false, - "notNull": true, - "default": "'litellm'" } }, "indexes": { @@ -1942,6 +1942,13 @@ "notNull": true, "default": false }, + "enable_thinking_signature_rectifier": { + "name": "enable_thinking_signature_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, "enable_response_fixer": { "name": "enable_response_fixer", "type": "boolean", @@ -2371,4 +2378,4 @@ "schemas": {}, "tables": {} } -} +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 9473c9cce..d33b7e70c 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -369,9 +369,9 @@ { "idx": 52, "version": "7", - "when": 1767924921400, + "when": 1768052041185, "tag": "0052_model_price_source", "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/messages/en/settings.json b/messages/en/settings.json index 0572ae511..6a75d4652 100644 --- a/messages/en/settings.json +++ b/messages/en/settings.json @@ -543,8 +543,32 @@ "description": "Manage AI model pricing configuration" }, "searchPlaceholder": "Search model name...", + "filters": { + "all": "All", + "local": "Local", + "anthropic": "Anthropic", + "openai": "OpenAI", + "vertex": "Vertex" + }, + "badges": { + "local": "Local" + }, + "capabilities": { + "assistantPrefill": "Assistant prefill", + "computerUse": "Computer use", + "functionCalling": "Function calling", + "pdfInput": "PDF input", + "promptCaching": "Prompt caching", + "reasoning": "Reasoning", + "responseSchema": "Response schema", + "toolChoice": "Tool choice", + "vision": "Vision", + "statusSupported": "Supported", + "statusUnsupported": "Not supported", + "tooltip": "{label}: {status}" + }, "sync": { - "button": "Sync LiteLLM Prices", + "button": "Sync Cloud Price Table", "syncing": "Syncing...", "checking": "Checking conflicts...", "successWithChanges": "Price table updated: {added} added, {updated} updated, {unchanged} unchanged", @@ -554,6 +578,7 @@ "failedNoResult": "Price table updated but no result returned", "noModels": "No model prices found", "partialFailure": "Partial update succeeded, but {failed} models failed", + "failedModels": "Failed models: {models}", "skippedConflicts": "Skipped {count} manual models" }, "conflict": { @@ -589,8 +614,8 @@ }, "table": { "modelName": "Model Name", - "type": "Type", "provider": "Provider", + "capabilities": "Capabilities", "inputPrice": "Input Price ($/M)", "outputPrice": "Output Price ($/M)", "updatedAt": "Updated At", @@ -608,6 +633,7 @@ "showing": "Showing {from}-{to} of {total}", "previous": "Previous", "next": "Next", + "perPageLabel": "Per page", "perPage": "{size} per page" }, "stats": { @@ -617,22 +643,22 @@ }, "dialog": { "title": "Update Model Price Table", - "description": "Select and upload JSON file containing model pricing data", - "selectFile": "Click to select JSON file or drag and drop here", + "description": "Select and upload JSON or TOML file containing model pricing data", + "selectFile": "Click to select JSON/TOML file or drag and drop here", "fileSizeLimit": "File size cannot exceed 10MB", "fileSizeLimitSmall": "File size not exceeding 10MB", - "invalidFileType": "Please select a JSON format file", + "invalidFileType": "Please select a JSON or TOML file", "fileTooLarge": "File size exceeds 10MB limit", "upload": "Upload and Update", "uploading": "Uploading...", "updatePriceTable": "Update Price Table", "updating": "Updating...", - "selectJson": "Select JSON File", + "selectJson": "Select File", "updateSuccess": "Price table updated successfully, {count} models updated", "updateFailed": "Update failed", "systemHasBuiltIn": "System has built-in price table", "manualDownload": "You can also manually download", - "latestPriceTable": "latest price table", + "latestPriceTable": "cloud price table", "andUploadViaButton": ", and upload via button above", "supportedModels": "Currently supports {count} models", "results": { @@ -641,6 +667,7 @@ "success": "Success: {success}", "failed": "Failed: {failed}", "skipped": "Skipped: {skipped}", + "more": " (+{count})", "details": "Details", "viewDetails": "View detailed logs" } @@ -664,6 +691,7 @@ }, "actions": { "edit": "Edit", + "more": "More actions", "delete": "Delete" }, "toast": { diff --git a/messages/ja/settings.json b/messages/ja/settings.json index c02d1d290..ba96490e7 100644 --- a/messages/ja/settings.json +++ b/messages/ja/settings.json @@ -534,8 +534,32 @@ "description": "AIモデルの価格設定を管理します" }, "searchPlaceholder": "モデル名を検索...", + "filters": { + "all": "すべて", + "local": "ローカル", + "anthropic": "Anthropic", + "openai": "OpenAI", + "vertex": "Vertex" + }, + "badges": { + "local": "ローカル" + }, + "capabilities": { + "assistantPrefill": "アシスタント事前入力", + "computerUse": "コンピューター利用", + "functionCalling": "関数呼び出し", + "pdfInput": "PDF入力", + "promptCaching": "プロンプトキャッシュ", + "reasoning": "推論", + "responseSchema": "レスポンススキーマ", + "toolChoice": "ツール選択", + "vision": "ビジョン", + "statusSupported": "対応", + "statusUnsupported": "未対応", + "tooltip": "{label}: {status}" + }, "sync": { - "button": "LiteLLM価格を同期", + "button": "クラウド価格表を同期", "syncing": "同期中...", "checking": "競合を確認中...", "successWithChanges": "価格表を更新: {added}件追加、{updated}件更新、{unchanged}件変化なし", @@ -545,6 +569,7 @@ "failedNoResult": "価格表は更新されましたが結果が返されていません", "noModels": "モデル価格が見つかりません", "partialFailure": "一部更新が成功しましたが、{failed}件のモデルが失敗しました", + "failedModels": "失敗モデル: {models}", "skippedConflicts": "{count}件の手動モデルをスキップしました" }, "conflict": { @@ -580,8 +605,8 @@ }, "table": { "modelName": "モデル名", - "type": "タイプ", "provider": "プロバイダー", + "capabilities": "機能", "inputPrice": "入力価格 ($/M)", "outputPrice": "出力価格 ($/M)", "updatedAt": "更新日時", @@ -599,6 +624,7 @@ "showing": "{from}〜{to}件を表示(全{total}件)", "previous": "前へ", "next": "次へ", + "perPageLabel": "1ページあたり", "perPage": "1ページあたり{size}件" }, "stats": { @@ -608,22 +634,22 @@ }, "dialog": { "title": "モデル価格表を更新", - "description": "モデル価格データを含むJSONファイルを選択してアップロード", - "selectFile": "JSONファイルをクリックして選択、またはドラッグしてください", + "description": "モデル価格データを含むJSONまたはTOMLファイルを選択してアップロード", + "selectFile": "JSON/TOMLファイルをクリックして選択、またはドラッグしてください", "fileSizeLimit": "ファイルサイズは10MBを超えることはできません", "fileSizeLimitSmall": "ファイルサイズは10MB以下です", - "invalidFileType": "JSON形式のファイルを選択してください", + "invalidFileType": "JSONまたはTOML形式のファイルを選択してください", "fileTooLarge": "ファイルサイズが10MBを超えています", "upload": "アップロードして更新", "uploading": "アップロード中...", "updatePriceTable": "価格表を更新", "updating": "更新中...", - "selectJson": "JSONファイルを選択", + "selectJson": "ファイルを選択", "updateSuccess": "価格表が正常に更新されました。{count}個のモデルを更新しました", "updateFailed": "更新に失敗しました", "systemHasBuiltIn": "システムは組み込み価格表を持っています", "manualDownload": "手動でダウンロードすることもできます", - "latestPriceTable": "最新価格表", + "latestPriceTable": "クラウド価格表", "andUploadViaButton": "、上のボタンでアップロードしてください", "supportedModels": "現在{count}個のモデルをサポート", "results": { @@ -632,6 +658,7 @@ "success": "成功: {success}", "failed": "失敗: {failed}", "skipped": "スキップ: {skipped}", + "more": " (+{count})", "details": "詳細", "viewDetails": "詳細ログを表示" } @@ -655,6 +682,7 @@ }, "actions": { "edit": "編集", + "more": "その他の操作", "delete": "削除" }, "toast": { diff --git a/messages/ru/settings.json b/messages/ru/settings.json index 4fe8730ae..c610de547 100644 --- a/messages/ru/settings.json +++ b/messages/ru/settings.json @@ -534,8 +534,32 @@ "description": "Управление ценами AI моделей" }, "searchPlaceholder": "Поиск по названию модели...", + "filters": { + "all": "Все", + "local": "Локальные", + "anthropic": "Anthropic", + "openai": "OpenAI", + "vertex": "Vertex" + }, + "badges": { + "local": "Локальная" + }, + "capabilities": { + "assistantPrefill": "Предзаполнение ассистента", + "computerUse": "Использование компьютера", + "functionCalling": "Вызов функций", + "pdfInput": "Ввод PDF", + "promptCaching": "Кэширование промпта", + "reasoning": "Рассуждение", + "responseSchema": "Схема ответа", + "toolChoice": "Выбор инструментов", + "vision": "Зрение", + "statusSupported": "Поддерживается", + "statusUnsupported": "Не поддерживается", + "tooltip": "{label}: {status}" + }, "sync": { - "button": "Синхронизировать цены LiteLLM", + "button": "Синхронизировать облачный прайс-лист", "syncing": "Синхронизация...", "checking": "Проверка конфликтов...", "successWithChanges": "Обновление прайс-листа: добавлено {added}, обновлено {updated}, без изменений {unchanged}", @@ -545,6 +569,7 @@ "failedNoResult": "Прайс-лист обновлен но результат не возвращен", "noModels": "Цены моделей не найдены", "partialFailure": "Частичное обновление выполнено, но {failed} моделей не удалось обновить", + "failedModels": "Не удалось обновить модели: {models}", "skippedConflicts": "Пропущено {count} ручных моделей" }, "conflict": { @@ -580,8 +605,8 @@ }, "table": { "modelName": "Название модели", - "type": "Тип", "provider": "Поставщик", + "capabilities": "Возможности", "inputPrice": "Цена ввода ($/M)", "outputPrice": "Цена вывода ($/M)", "updatedAt": "Обновлено", @@ -599,6 +624,7 @@ "showing": "Показано {from}-{to} из {total}", "previous": "Назад", "next": "Вперёд", + "perPageLabel": "На странице", "perPage": "{size} на странице" }, "stats": { @@ -608,22 +634,22 @@ }, "dialog": { "title": "Обновить прайс-лист", - "description": "Выберите и загрузите JSON файл с данными о ценах моделей", - "selectFile": "Нажмите для выбора JSON или перетащите сюда", + "description": "Выберите и загрузите JSON или TOML файл с данными о ценах моделей", + "selectFile": "Нажмите для выбора JSON/TOML или перетащите сюда", "fileSizeLimit": "Размер файла не может превышать 10MB", "fileSizeLimitSmall": "Размер файла не превышает 10MB", - "invalidFileType": "Пожалуйста, выберите файл в формате JSON", + "invalidFileType": "Пожалуйста, выберите файл JSON или TOML", "fileTooLarge": "Размер файла превышает лимит 10MB", "upload": "Загрузить и обновить", "uploading": "Загрузка...", "updatePriceTable": "Обновить прайс-лист", "updating": "Обновление...", - "selectJson": "Выбрать JSON файл", + "selectJson": "Выбрать файл", "updateSuccess": "Прайс-лист успешно обновлён, {count} моделей обновлено", "updateFailed": "Ошибка обновления", "systemHasBuiltIn": "Система имеет встроенный прайс-лист", "manualDownload": "Вы также можете скачать вручную", - "latestPriceTable": "последний прайс-лист", + "latestPriceTable": "облачный прайс-лист", "andUploadViaButton": ", и загрузить через кнопку выше", "supportedModels": "Поддерживается {count} моделей", "results": { @@ -632,6 +658,7 @@ "success": "Успешно: {success}", "failed": "Ошибок: {failed}", "skipped": "Пропущено: {skipped}", + "more": " (+{count})", "details": "Подробности", "viewDetails": "Просмотреть подробный журнал" } @@ -655,6 +682,7 @@ }, "actions": { "edit": "Редактировать", + "more": "Больше действий", "delete": "Удалить" }, "toast": { diff --git a/messages/zh-CN/settings.json b/messages/zh-CN/settings.json index a63c08b93..99381c57c 100644 --- a/messages/zh-CN/settings.json +++ b/messages/zh-CN/settings.json @@ -1262,8 +1262,32 @@ "description": "管理 AI 模型的价格配置" }, "searchPlaceholder": "搜索模型名称...", + "filters": { + "all": "全部", + "local": "本地", + "anthropic": "Anthropic", + "openai": "OpenAI", + "vertex": "Vertex" + }, + "badges": { + "local": "本地" + }, + "capabilities": { + "assistantPrefill": "助手预填充", + "computerUse": "电脑使用", + "functionCalling": "函数调用", + "pdfInput": "PDF 输入", + "promptCaching": "Prompt 缓存", + "reasoning": "推理", + "responseSchema": "响应 Schema", + "toolChoice": "工具选择", + "vision": "视觉", + "statusSupported": "支持", + "statusUnsupported": "不支持", + "tooltip": "{label}: {status}" + }, "sync": { - "button": "同步 LiteLLM 价格", + "button": "同步云端价格表", "syncing": "同步中...", "checking": "检查冲突...", "successWithChanges": "价格表更新: 新增 {added} 个,更新 {updated} 个,未变化 {unchanged} 个", @@ -1273,6 +1297,7 @@ "failedNoResult": "价格表更新成功但未返回处理结果", "noModels": "未找到支持的模型价格", "partialFailure": "部分更新成功,但有 {failed} 个模型失败", + "failedModels": "失败模型: {models}", "skippedConflicts": "跳过 {count} 个手动模型" }, "conflict": { @@ -1308,8 +1333,8 @@ }, "table": { "modelName": "模型名称", - "type": "类型", "provider": "提供商", + "capabilities": "能力", "inputPrice": "输入价格 ($/M)", "outputPrice": "输出价格 ($/M)", "updatedAt": "更新时间", @@ -1327,6 +1352,7 @@ "showing": "显示 {from}-{to} 条,共 {total} 条", "previous": "上一页", "next": "下一页", + "perPageLabel": "每页", "perPage": "每页 {size} 条" }, "stats": { @@ -1336,22 +1362,22 @@ }, "dialog": { "title": "更新模型价格表", - "description": "选择包含模型价格数据的 JSON 文件并上传", - "selectFile": "点击选择 JSON 文件或拖拽到此处", + "description": "选择包含模型价格数据的 JSON 或 TOML 文件并上传", + "selectFile": "点击选择 JSON/TOML 文件或拖拽到此处", "fileSizeLimit": "文件大小不能超过 10MB", "fileSizeLimitSmall": "文件大小不超过 10MB", - "invalidFileType": "请选择 JSON 格式的文件", + "invalidFileType": "请选择 JSON 或 TOML 格式的文件", "fileTooLarge": "文件大小超过 10MB 限制", "upload": "上传并更新", "uploading": "上传中...", "updatePriceTable": "更新价格表", "updating": "更新中...", - "selectJson": "选择 JSON 文件", + "selectJson": "选择文件", "updateSuccess": "价格表更新成功,共更新 {count} 个模型", "updateFailed": "更新失败", "systemHasBuiltIn": "系统已内置价格表", "manualDownload": "你也可以手动下载", - "latestPriceTable": "最新价格表", + "latestPriceTable": "云端价格表", "andUploadViaButton": ",并通过上方按钮上传", "supportedModels": "当前支持 {count} 个模型", "results": { @@ -1360,6 +1386,7 @@ "success": "成功: {success}", "failed": "失败: {failed}", "skipped": "跳过: {skipped}", + "more": " (+{count})", "details": "详细信息", "viewDetails": "查看详细日志" } @@ -1383,6 +1410,7 @@ }, "actions": { "edit": "编辑", + "more": "更多操作", "delete": "删除" }, "toast": { diff --git a/messages/zh-TW/settings.json b/messages/zh-TW/settings.json index 2cf3fe91b..078dc8d17 100644 --- a/messages/zh-TW/settings.json +++ b/messages/zh-TW/settings.json @@ -534,8 +534,32 @@ "description": "管理 AI 模型的價格設定" }, "searchPlaceholder": "搜尋模型名稱...", + "filters": { + "all": "全部", + "local": "本地", + "anthropic": "Anthropic", + "openai": "OpenAI", + "vertex": "Vertex" + }, + "badges": { + "local": "本地" + }, + "capabilities": { + "assistantPrefill": "助手預填充", + "computerUse": "電腦使用", + "functionCalling": "函數呼叫", + "pdfInput": "PDF 輸入", + "promptCaching": "Prompt 快取", + "reasoning": "推理", + "responseSchema": "回應 Schema", + "toolChoice": "工具選擇", + "vision": "視覺", + "statusSupported": "支援", + "statusUnsupported": "不支援", + "tooltip": "{label}: {status}" + }, "sync": { - "button": "同步 LiteLLM 價格", + "button": "同步雲端價格表", "syncing": "同步中...", "checking": "檢查衝突...", "successWithChanges": "價格表更新: 新增 {added} 個,更新 {updated} 個,未變化 {unchanged} 個", @@ -545,6 +569,7 @@ "failedNoResult": "價格表更新成功但未返回處理結果", "noModels": "未找到支援的模型價格", "partialFailure": "部分更新成功,但有 {failed} 個模型失敗", + "failedModels": "失敗模型: {models}", "skippedConflicts": "跳過 {count} 個手動模型" }, "conflict": { @@ -580,8 +605,8 @@ }, "table": { "modelName": "模型名稱", - "type": "類型", "provider": "提供商", + "capabilities": "能力", "inputPrice": "輸入價格 ($/M)", "outputPrice": "輸出價格 ($/M)", "updatedAt": "更新時間", @@ -599,6 +624,7 @@ "showing": "顯示 {from}-{to} 條,共 {total} 條", "previous": "上一頁", "next": "下一頁", + "perPageLabel": "每頁", "perPage": "每頁 {size} 條" }, "stats": { @@ -608,22 +634,22 @@ }, "dialog": { "title": "更新模型價格表", - "description": "選擇包含模型價格資料的 JSON 檔案並上傳", - "selectFile": "點擊選擇 JSON 檔案或拖曳到此處", + "description": "選擇包含模型價格資料的 JSON 或 TOML 檔案並上傳", + "selectFile": "點擊選擇 JSON/TOML 檔案或拖曳到此處", "fileSizeLimit": "檔案大小不能超過 10MB", "fileSizeLimitSmall": "檔案大小不超過 10MB", - "invalidFileType": "請選擇 JSON 格式的檔案", + "invalidFileType": "請選擇 JSON 或 TOML 格式的檔案", "fileTooLarge": "檔案大小超過 10MB 限制", "upload": "上傳並更新", "uploading": "上傳中...", "updatePriceTable": "更新價格表", "updating": "更新中...", - "selectJson": "選擇 JSON 檔案", + "selectJson": "選擇檔案", "updateSuccess": "價格表更新成功,共更新 {count} 個模型", "updateFailed": "更新失敗", "systemHasBuiltIn": "系統已內置價格表", "manualDownload": "你也可以手動下載", - "latestPriceTable": "最新價格表", + "latestPriceTable": "雲端價格表", "andUploadViaButton": ",並透過上方按鈕上傳", "supportedModels": "目前支援 {count} 個模型", "results": { @@ -632,6 +658,7 @@ "success": "成功: {success}", "failed": "失敗: {failed}", "skipped": "跳過: {skipped}", + "more": " (+{count})", "details": "詳細資訊", "viewDetails": "檢視詳細記錄" } @@ -655,6 +682,7 @@ }, "actions": { "edit": "編輯", + "more": "更多操作", "delete": "刪除" }, "toast": { diff --git a/package.json b/package.json index b1b43852e..9f51e3525 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "@hono/swagger-ui": "^0.5", "@hono/zod-openapi": "^1", "@hookform/resolvers": "^5", + "@iarna/toml": "^2.2.5", "@lobehub/icons": "^2", "@radix-ui/react-alert-dialog": "^1", "@radix-ui/react-avatar": "^1", diff --git a/src/actions/model-prices.ts b/src/actions/model-prices.ts index 9d25caef7..85cf2d697 100644 --- a/src/actions/model-prices.ts +++ b/src/actions/model-prices.ts @@ -3,14 +3,16 @@ import { revalidatePath } from "next/cache"; import { getSession } from "@/lib/auth"; import { logger } from "@/lib/logger"; -import { getPriceTableJson } from "@/lib/price-sync"; +import { + fetchCloudPriceTableToml, + parseCloudPriceTableToml, +} from "@/lib/price-sync/cloud-price-table"; import { createModelPrice, deleteModelPriceByName, findAllLatestPrices, findAllLatestPricesPaginated, findAllManualPrices, - findLatestPriceByModel, hasAnyPriceRecords, type PaginatedResult, type PaginationParams, @@ -30,8 +32,38 @@ import type { ActionResult } from "./types"; * 检查价格数据是否相同 */ function isPriceDataEqual(data1: ModelPriceData, data2: ModelPriceData): boolean { - // 深度比较两个价格对象 - return JSON.stringify(data1) === JSON.stringify(data2); + const stableStringify = (value: unknown): string => { + const seen = new WeakSet(); + + const canonicalize = (node: unknown): unknown => { + if (node === null || node === undefined) return node; + if (typeof node !== "object") return node; + + if (seen.has(node as object)) { + return null; + } + seen.add(node as object); + + if (Array.isArray(node)) { + return node.map(canonicalize); + } + + const obj = node as Record; + const result: Record = Object.create(null); + for (const key of Object.keys(obj).sort()) { + // 防御:避免 __proto__/constructor/prototype 触发原型链污染 + if (key === "__proto__" || key === "constructor" || key === "prototype") { + continue; + } + result[key] = canonicalize(obj[key]); + } + return result; + }; + + return JSON.stringify(canonicalize(value)); + }; + + return stableStringify(data1) === stableStringify(data2); } /** @@ -77,6 +109,13 @@ export async function processPriceTableInternal( // 获取所有手动添加的模型(用于冲突检测) const manualPrices = await findAllManualPrices(); + // 批量获取数据库中“每个模型的最新价格”,避免 N+1 查询 + const existingLatestPrices = await findAllLatestPrices(); + const existingByModelName = new Map(); + for (const price of existingLatestPrices) { + existingByModelName.set(price.modelName, price); + } + const result: PriceUpdateResult = { added: [], updated: [], @@ -113,8 +152,7 @@ export async function processPriceTableInternal( continue; } - // 查找该模型的最新价格 - const existingPrice = await findLatestPriceByModel(modelName); + const existingPrice = existingByModelName.get(modelName) ?? null; if (!existingPrice) { // 模型不存在,新增记录 @@ -139,7 +177,14 @@ export async function processPriceTableInternal( } // 刷新页面数据 - revalidatePath("/settings/prices"); + try { + revalidatePath("/settings/prices"); + } catch (error) { + // 在后台任务/启动阶段可能没有 Next.js 的请求上下文,此处允许降级 + logger.debug("[ModelPrices] revalidatePath skipped", { + error: error instanceof Error ? error.message : String(error), + }); + } return { ok: true, data: result }; } catch (error) { @@ -151,10 +196,14 @@ export async function processPriceTableInternal( /** * 上传并更新模型价格表(Web UI 入口,包含权限检查) + * + * 支持格式: + * - JSON:PriceTableJson(内部入库格式) + * - TOML:云端价格表格式(会提取 models 表后再入库) * @param overwriteManual - 可选,要覆盖的手动添加模型名称列表 */ export async function uploadPriceTable( - jsonContent: string, + content: string, overwriteManual?: string[] ): Promise> { // 权限检查:只有管理员可以上传价格表 @@ -163,7 +212,18 @@ export async function uploadPriceTable( return { ok: false, error: "无权限执行此操作" }; } - // 调用核心逻辑 + // 先尝试 JSON;失败则按 TOML 解析(用于云端价格表文件直接上传) + let jsonContent = content; + try { + JSON.parse(content); + } catch { + const parseResult = parseCloudPriceTableToml(content); + if (!parseResult.ok) { + return { ok: false, error: parseResult.error }; + } + jsonContent = JSON.stringify(parseResult.data.models); + } + return processPriceTableInternal(jsonContent, overwriteManual); } @@ -284,23 +344,22 @@ export async function checkLiteLLMSyncConflicts(): Promise(initialSourceFilter); + const [litellmProviderFilter, setLitellmProviderFilter] = useState(initialLitellmProviderFilter); const [prices, setPrices] = useState(initialPrices); const [total, setTotal] = useState(initialTotal); const [page, setPage] = useState(initialPage); @@ -67,51 +86,79 @@ export function PriceList({ // 使用防抖,避免频繁请求 const debouncedSearchTerm = useDebounce(searchTerm, 500); + const lastDebouncedSearchTerm = useRef(debouncedSearchTerm); // 计算总页数 const totalPages = Math.ceil(total / pageSize); - // 从 URL 搜索参数中读取初始状态(仅在挂载时执行一次) - useEffect(() => { - const urlParams = new URLSearchParams(window.location.search); - const searchParam = urlParams.get("search"); - const pageParam = urlParams.get("page"); - const sizeParam = urlParams.get("size"); + // 更新 URL 搜索参数 + const updateURL = useCallback( + ( + newSearchTerm: string, + newPage: number, + newPageSize: number, + newSourceFilter: ModelPriceSource | "", + newLitellmProviderFilter: string + ) => { + const url = new URL(window.location.href); + if (newSearchTerm) { + url.searchParams.set("search", newSearchTerm); + } else { + url.searchParams.delete("search"); + } + if (newPage > 1) { + url.searchParams.set("page", newPage.toString()); + } else { + url.searchParams.delete("page"); + } + if (newPageSize !== 50) { + url.searchParams.set("pageSize", newPageSize.toString()); + url.searchParams.delete("size"); + } else { + url.searchParams.delete("pageSize"); + url.searchParams.delete("size"); + } - if (searchParam) setSearchTerm(searchParam); - if (pageParam) setPage(parseInt(pageParam, 10)); - if (sizeParam) setPageSize(parseInt(sizeParam, 10)); - }, []); // 空依赖数组,仅在挂载时执行一次 + if (newSourceFilter) { + url.searchParams.set("source", newSourceFilter); + } else { + url.searchParams.delete("source"); + } - // 更新 URL 搜索参数 - const updateURL = useCallback((newSearchTerm: string, newPage: number, newPageSize: number) => { - const url = new URL(window.location.href); - if (newSearchTerm) { - url.searchParams.set("search", newSearchTerm); - } else { - url.searchParams.delete("search"); - } - if (newPage > 1) { - url.searchParams.set("page", newPage.toString()); - } else { - url.searchParams.delete("page"); - } - if (newPageSize !== 50) { - url.searchParams.set("size", newPageSize.toString()); - } else { - url.searchParams.delete("size"); - } - window.history.replaceState({}, "", url.toString()); - }, []); + if (newLitellmProviderFilter) { + url.searchParams.set("litellmProvider", newLitellmProviderFilter); + } else { + url.searchParams.delete("litellmProvider"); + } + window.history.replaceState({}, "", url.toString()); + }, + [] + ); // 获取价格数据 const fetchPrices = useCallback( - async (newPage: number, newPageSize: number, newSearchTerm: string) => { + async ( + newPage: number, + newPageSize: number, + newSearchTerm: string, + newSourceFilter: ModelPriceSource | "", + newLitellmProviderFilter: string + ) => { setIsLoading(true); try { - const response = await fetch( - `/api/prices?page=${newPage}&pageSize=${newPageSize}&search=${encodeURIComponent(newSearchTerm)}` - ); + const url = new URL("/api/prices", window.location.origin); + url.searchParams.set("page", newPage.toString()); + url.searchParams.set("pageSize", newPageSize.toString()); + url.searchParams.set("search", newSearchTerm); + + if (newSourceFilter) { + url.searchParams.set("source", newSourceFilter); + } + if (newLitellmProviderFilter) { + url.searchParams.set("litellmProvider", newLitellmProviderFilter); + } + + const response = await fetch(url.toString()); const result = await response.json(); if (result.ok) { @@ -132,24 +179,25 @@ export function PriceList({ // 监听价格数据变化事件(由其他组件触发) useEffect(() => { const handlePriceUpdate = () => { - fetchPrices(page, pageSize, debouncedSearchTerm); + fetchPrices(page, pageSize, debouncedSearchTerm, sourceFilter, litellmProviderFilter); }; window.addEventListener("price-data-updated", handlePriceUpdate); return () => window.removeEventListener("price-data-updated", handlePriceUpdate); - }, [page, pageSize, debouncedSearchTerm, fetchPrices]); + }, [page, pageSize, debouncedSearchTerm, fetchPrices, sourceFilter, litellmProviderFilter]); // 当防抖后的搜索词变化时,触发搜索(重置到第一页) useEffect(() => { - // 跳过初始渲染(当 debouncedSearchTerm 等于初始 searchTerm 时) - if (debouncedSearchTerm !== searchTerm) return; + if (debouncedSearchTerm === lastDebouncedSearchTerm.current) { + return; + } + lastDebouncedSearchTerm.current = debouncedSearchTerm; const newPage = 1; // 搜索时重置到第一页 setPage(newPage); - updateURL(debouncedSearchTerm, newPage, pageSize); - fetchPrices(newPage, pageSize, debouncedSearchTerm); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [debouncedSearchTerm, fetchPrices, pageSize, searchTerm, updateURL]); // 仅依赖 debouncedSearchTerm + updateURL(debouncedSearchTerm, newPage, pageSize, sourceFilter, litellmProviderFilter); + fetchPrices(newPage, pageSize, debouncedSearchTerm, sourceFilter, litellmProviderFilter); + }, [debouncedSearchTerm, fetchPrices, litellmProviderFilter, pageSize, sourceFilter, updateURL]); // 搜索输入处理(只更新状态,不触发请求) const handleSearchChange = (value: string) => { @@ -161,16 +209,16 @@ export function PriceList({ const newPage = Math.max(1, Math.min(page, Math.ceil(total / newPageSize))); setPageSize(newPageSize); setPage(newPage); - updateURL(debouncedSearchTerm, newPage, newPageSize); - fetchPrices(newPage, newPageSize, debouncedSearchTerm); + updateURL(debouncedSearchTerm, newPage, newPageSize, sourceFilter, litellmProviderFilter); + fetchPrices(newPage, newPageSize, debouncedSearchTerm, sourceFilter, litellmProviderFilter); }; // 页面跳转处理 const handlePageChange = (newPage: number) => { if (newPage < 1 || newPage > totalPages) return; setPage(newPage); - updateURL(debouncedSearchTerm, newPage, pageSize); - fetchPrices(newPage, pageSize, debouncedSearchTerm); + updateURL(debouncedSearchTerm, newPage, pageSize, sourceFilter, litellmProviderFilter); + fetchPrices(newPage, pageSize, debouncedSearchTerm, sourceFilter, litellmProviderFilter); }; // 移除客户端过滤逻辑(现在由后端处理) @@ -180,7 +228,7 @@ export function PriceList({ * 格式化价格显示为每百万token的价格 */ const formatPrice = (value?: number): string => { - if (!value) return "-"; + if (value === undefined || value === null) return "-"; // 将每token的价格转换为每百万token的价格 const pricePerMillion = value * 1000000; // 格式化为合适的小数位数 @@ -198,10 +246,10 @@ export function PriceList({ /** * 获取模型类型标签 */ - const getModeLabel = (mode?: string) => { + const getModeBadge = (mode?: string) => { switch (mode) { case "chat": - return {t("table.typeChat")}; + return null; case "image_generation": return {t("table.typeImage")}; case "completion": @@ -211,8 +259,121 @@ export function PriceList({ } }; + const capabilityItems: Array<{ + key: + | "supports_assistant_prefill" + | "supports_computer_use" + | "supports_function_calling" + | "supports_pdf_input" + | "supports_prompt_caching" + | "supports_reasoning" + | "supports_response_schema" + | "supports_tool_choice" + | "supports_vision"; + icon: React.ComponentType<{ className?: string }>; + label: string; + }> = [ + { key: "supports_function_calling", icon: Code2, label: t("capabilities.functionCalling") }, + { key: "supports_tool_choice", icon: Terminal, label: t("capabilities.toolChoice") }, + { key: "supports_response_schema", icon: Braces, label: t("capabilities.responseSchema") }, + { key: "supports_prompt_caching", icon: Database, label: t("capabilities.promptCaching") }, + { key: "supports_vision", icon: Eye, label: t("capabilities.vision") }, + { key: "supports_pdf_input", icon: FileText, label: t("capabilities.pdfInput") }, + { key: "supports_reasoning", icon: Sparkles, label: t("capabilities.reasoning") }, + { key: "supports_computer_use", icon: Monitor, label: t("capabilities.computerUse") }, + { key: "supports_assistant_prefill", icon: Pencil, label: t("capabilities.assistantPrefill") }, + ]; + + const applyFilters = useCallback( + (next: { source: ModelPriceSource | ""; litellmProvider: string }) => { + setSourceFilter(next.source); + setLitellmProviderFilter(next.litellmProvider); + + const newPage = 1; + setPage(newPage); + updateURL(debouncedSearchTerm, newPage, pageSize, next.source, next.litellmProvider); + fetchPrices(newPage, pageSize, debouncedSearchTerm, next.source, next.litellmProvider); + }, + [debouncedSearchTerm, fetchPrices, pageSize, updateURL] + ); + return (
+ {/* 快捷筛选 */} +
+ + + + + + + + + +
+ {/* 搜索和页面大小控制 */}
@@ -225,9 +386,7 @@ export function PriceList({ />
- - {t("pagination.perPage", { size: "" }).replace(/\d+/, "")} - + {t("pagination.perPageLabel")} @@ -237,7 +239,8 @@ export function UploadPriceDialog({
{result.added.slice(0, 3).join(", ")} - {result.added.length > 3 && ` (+${result.added.length - 3})`} + {result.added.length > 3 && + t("dialog.results.more", { count: result.added.length - 3 })}
)} @@ -252,7 +255,8 @@ export function UploadPriceDialog({
{result.updated.slice(0, 3).join(", ")} - {result.updated.length > 3 && ` (+${result.updated.length - 3})`} + {result.updated.length > 3 && + t("dialog.results.more", { count: result.updated.length - 3 })}
)} @@ -277,7 +281,8 @@ export function UploadPriceDialog({
{result.failed.slice(0, 3).join(", ")} - {result.failed.length > 3 && ` (+${result.failed.length - 3})`} + {result.failed.length > 3 && + t("dialog.results.more", { count: result.failed.length - 3 })}
)} diff --git a/src/app/[locale]/settings/prices/page.tsx b/src/app/[locale]/settings/prices/page.tsx index f3b21cc51..0f14528ca 100644 --- a/src/app/[locale]/settings/prices/page.tsx +++ b/src/app/[locale]/settings/prices/page.tsx @@ -18,6 +18,8 @@ interface SettingsPricesPageProps { pageSize?: string; size?: string; search?: string; + source?: string; + litellmProvider?: string; }>; } @@ -41,9 +43,19 @@ async function SettingsPricesContent({ searchParams }: SettingsPricesPageProps) // 解析分页参数 const page = parseInt(params.page || "1", 10); const pageSize = parseInt(params.pageSize || params.size || "50", 10); + const search = params.search?.trim() || undefined; + const source = + params.source === "manual" || params.source === "litellm" ? params.source : undefined; + const litellmProvider = params.litellmProvider?.trim() || undefined; - // 获取分页数据(搜索在客户端处理) - const pricesResult = await getModelPricesPaginated({ page, pageSize }); + // 获取分页数据(搜索与过滤在 SQL 层面执行) + const pricesResult = await getModelPricesPaginated({ + page, + pageSize, + search, + source, + litellmProvider, + }); const isRequired = params.required === "true"; // 如果获取分页数据失败,降级到获取所有数据 @@ -85,6 +97,9 @@ async function SettingsPricesContent({ searchParams }: SettingsPricesPageProps) initialTotal={initialTotal} initialPage={initialPage} initialPageSize={initialPageSize} + initialSearchTerm={search ?? ""} + initialSourceFilter={source ?? ""} + initialLitellmProviderFilter={litellmProvider ?? ""} /> ); diff --git a/src/app/api/prices/route.ts b/src/app/api/prices/route.ts index 274f59214..304d22e28 100644 --- a/src/app/api/prices/route.ts +++ b/src/app/api/prices/route.ts @@ -10,6 +10,8 @@ import type { PaginationParams } from "@/repository/model-price"; * - page: 页码 (默认: 1) * - pageSize: 每页大小 (默认: 50) * - search: 搜索关键词 (可选) + * - source: 价格来源过滤 (可选: manual|litellm) + * - litellmProvider: 云端提供商过滤 (可选,如 anthropic/openai/vertex_ai-language-models) */ export async function GET(request: NextRequest) { try { @@ -22,24 +24,35 @@ export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url); // 解析查询参数 - const page = parseInt(searchParams.get("page") || "1", 10); - const pageSize = parseInt(searchParams.get("pageSize") || searchParams.get("size") || "50", 10); + const page = Number.parseInt(searchParams.get("page") || "1", 10); + const pageSize = Number.parseInt( + searchParams.get("pageSize") || searchParams.get("size") || "50", + 10 + ); const search = searchParams.get("search") || ""; + const source = searchParams.get("source") || ""; + const litellmProvider = searchParams.get("litellmProvider") || ""; // 参数验证 - if (page < 1) { + if (!Number.isFinite(page) || page < 1) { return NextResponse.json({ ok: false, error: "页码必须大于0" }, { status: 400 }); } - if (pageSize < 1 || pageSize > 200) { + if (!Number.isFinite(pageSize) || pageSize < 1 || pageSize > 200) { return NextResponse.json({ ok: false, error: "每页大小必须在1-200之间" }, { status: 400 }); } + if (source && source !== "manual" && source !== "litellm") { + return NextResponse.json({ ok: false, error: "source 参数无效" }, { status: 400 }); + } + // 构建分页参数 const paginationParams: PaginationParams = { page, pageSize, search: search || undefined, // 传递搜索关键词给后端 + source: source ? (source as PaginationParams["source"]) : undefined, + litellmProvider: litellmProvider || undefined, }; // 获取分页数据(搜索在 SQL 层面执行) diff --git a/src/app/v1/_lib/proxy/response-handler.ts b/src/app/v1/_lib/proxy/response-handler.ts index 32a42328c..0af019603 100644 --- a/src/app/v1/_lib/proxy/response-handler.ts +++ b/src/app/v1/_lib/proxy/response-handler.ts @@ -2,11 +2,13 @@ import { ResponseFixer } from "@/app/v1/_lib/proxy/response-fixer"; import { AsyncTaskManager } from "@/lib/async-task-manager"; import { getEnvConfig } from "@/lib/config/env.schema"; import { logger } from "@/lib/logger"; +import { requestCloudPriceTableSync } from "@/lib/price-sync/cloud-price-updater"; import { ProxyStatusTracker } from "@/lib/proxy-status-tracker"; import { RateLimitService } from "@/lib/rate-limit"; import { SessionManager } from "@/lib/session-manager"; import { SessionTracker } from "@/lib/session-tracker"; import { calculateRequestCost } from "@/lib/utils/cost-calculation"; +import { hasValidPriceData } from "@/lib/utils/price-data"; import { parseSSEData } from "@/lib/utils/sse"; import { updateMessageRequestCost, @@ -360,19 +362,25 @@ export class ProxyResponseHandler { if (session.sessionId && usageMetrics) { // 计算成本(复用相同逻辑) let costUsdStr: string | undefined; - if (session.request.model) { - const priceData = await session.getCachedPriceDataByBillingSource(); - if (priceData) { - const cost = calculateRequestCost( - usageMetrics, - priceData, - provider.costMultiplier, - session.getContext1mApplied() - ); - if (cost.gt(0)) { - costUsdStr = cost.toString(); + try { + if (session.request.model) { + const priceData = await session.getCachedPriceDataByBillingSource(); + if (priceData) { + const cost = calculateRequestCost( + usageMetrics, + priceData, + provider.costMultiplier, + session.getContext1mApplied() + ); + if (cost.gt(0)) { + costUsdStr = cost.toString(); + } } } + } catch (error) { + logger.error("[ResponseHandler] Failed to calculate session cost, skipping", { + error: error instanceof Error ? error.message : String(error), + }); } void SessionManager.updateSessionUsage(session.sessionId, { @@ -922,19 +930,25 @@ export class ProxyResponseHandler { // 更新 session 使用量到 Redis(用于实时监控) if (session.sessionId && usageForCost) { let costUsdStr: string | undefined; - if (session.request.model) { - const priceData = await session.getCachedPriceDataByBillingSource(); - if (priceData) { - const cost = calculateRequestCost( - usageForCost, - priceData, - provider.costMultiplier, - session.getContext1mApplied() - ); - if (cost.gt(0)) { - costUsdStr = cost.toString(); + try { + if (session.request.model) { + const priceData = await session.getCachedPriceDataByBillingSource(); + if (priceData) { + const cost = calculateRequestCost( + usageForCost, + priceData, + provider.costMultiplier, + session.getContext1mApplied() + ); + if (cost.gt(0)) { + costUsdStr = cost.toString(); + } } } + } catch (error) { + logger.error("[ResponseHandler] Failed to calculate session cost (stream), skipping", { + error: error instanceof Error ? error.message : String(error), + }); } void SessionManager.updateSessionUsage(session.sessionId, { @@ -1640,97 +1654,116 @@ async function updateRequestCostFromUsage( return; } - // 获取系统设置中的计费模型来源配置 - const systemSettings = await getSystemSettings(); - const billingModelSource = systemSettings.billingModelSource; - - // 根据配置决定计费模型优先级 - let primaryModel: string | null; - let fallbackModel: string | null; - - if (billingModelSource === "original") { - // 优先使用重定向前的原始模型 - primaryModel = originalModel; - fallbackModel = redirectedModel; - } else { - // 优先使用重定向后的实际模型 - primaryModel = redirectedModel; - fallbackModel = originalModel; - } + try { + // 获取系统设置中的计费模型来源配置 + const systemSettings = await getSystemSettings(); + const billingModelSource = systemSettings.billingModelSource; + + // 根据配置决定计费模型优先级 + let primaryModel: string | null; + let fallbackModel: string | null; + + if (billingModelSource === "original") { + // 优先使用重定向前的原始模型 + primaryModel = originalModel; + fallbackModel = redirectedModel; + } else { + // 优先使用重定向后的实际模型 + primaryModel = redirectedModel; + fallbackModel = originalModel; + } - logger.debug("[CostCalculation] Billing model source config", { - messageId, - billingModelSource, - primaryModel, - fallbackModel, - }); + logger.debug("[CostCalculation] Billing model source config", { + messageId, + billingModelSource, + primaryModel, + fallbackModel, + }); - // Fallback 逻辑:优先主要模型,找不到则用备选模型 - let priceData = null; - let usedModelForPricing = null; + // Fallback 逻辑:优先主要模型,找不到则用备选模型 + let priceData = null; + let usedModelForPricing = null; - // Step 1: 尝试主要模型 - if (primaryModel) { - priceData = await findLatestPriceByModel(primaryModel); - if (priceData?.priceData) { - usedModelForPricing = primaryModel; - logger.debug("[CostCalculation] Using primary model for pricing", { - messageId, - model: primaryModel, - billingModelSource, - }); + const resolveValidPriceData = async (modelName: string) => { + const record = await findLatestPriceByModel(modelName); + const data = record?.priceData; + if (!data || !hasValidPriceData(data)) { + return null; + } + return record; + }; + + // Step 1: 尝试主要模型 + if (primaryModel) { + const resolved = await resolveValidPriceData(primaryModel); + if (resolved) { + priceData = resolved; + usedModelForPricing = primaryModel; + logger.debug("[CostCalculation] Using primary model for pricing", { + messageId, + model: primaryModel, + billingModelSource, + }); + } + } + + // Step 2: Fallback 到备选模型 + if (!priceData && fallbackModel && fallbackModel !== primaryModel) { + const resolved = await resolveValidPriceData(fallbackModel); + if (resolved) { + priceData = resolved; + usedModelForPricing = fallbackModel; + logger.warn("[CostCalculation] Primary model price not found, using fallback model", { + messageId, + primaryModel, + fallbackModel, + billingModelSource, + }); + } } - } - // Step 2: Fallback 到备选模型 - if (!priceData && fallbackModel && fallbackModel !== primaryModel) { - priceData = await findLatestPriceByModel(fallbackModel); - if (priceData?.priceData) { - usedModelForPricing = fallbackModel; - logger.warn("[CostCalculation] Primary model price not found, using fallback model", { + // Step 3: 完全失败(无价格或价格表暂不可用):不计费放行,并异步触发一次同步 + if (!priceData?.priceData) { + logger.warn("[CostCalculation] No price data found, skipping billing", { messageId, - primaryModel, - fallbackModel, + originalModel, + redirectedModel, billingModelSource, }); + + requestCloudPriceTableSync({ reason: "missing-model" }); + return; } - } - // Step 3: 完全失败 - if (!priceData?.priceData) { - logger.error("[CostCalculation] No price data found for any model", { + // 计算费用 + const cost = calculateRequestCost(usage, priceData.priceData, costMultiplier, context1mApplied); + + logger.info("[CostCalculation] Cost calculated successfully", { messageId, - originalModel, - redirectedModel, + usedModelForPricing, billingModelSource, - note: "Cost will be $0. Please check price table or model name.", + costUsd: cost.toString(), + costMultiplier, + usage, }); - return; - } - - // 计算费用 - const cost = calculateRequestCost(usage, priceData.priceData, costMultiplier, context1mApplied); - logger.info("[CostCalculation] Cost calculated successfully", { - messageId, - usedModelForPricing, - billingModelSource, - costUsd: cost.toString(), - costMultiplier, - usage, - }); - - if (cost.gt(0)) { - await updateMessageRequestCost(messageId, cost); - } else { - logger.warn("[CostCalculation] Calculated cost is zero or negative", { + if (cost.gt(0)) { + await updateMessageRequestCost(messageId, cost); + } else { + logger.warn("[CostCalculation] Calculated cost is zero or negative", { + messageId, + usedModelForPricing, + costUsd: cost.toString(), + priceData: { + inputCost: priceData.priceData.input_cost_per_token, + outputCost: priceData.priceData.output_cost_per_token, + }, + }); + } + } catch (error) { + logger.error("[CostCalculation] Failed to update request cost, skipping billing", { messageId, - usedModelForPricing, - costUsd: cost.toString(), - priceData: { - inputCost: priceData.priceData.input_cost_per_token, - outputCost: priceData.priceData.output_cost_per_token, - }, + error: error instanceof Error ? error.message : String(error), }); } } @@ -1739,7 +1772,7 @@ async function updateRequestCostFromUsage( * 统一的请求统计处理方法 * 用于消除 Gemini 透传、普通非流式、普通流式之间的重复统计逻辑 */ -async function finalizeRequestStats( +export async function finalizeRequestStats( session: ProxySession, responseText: string, statusCode: number, @@ -1806,19 +1839,25 @@ async function finalizeRequestStats( // 6. 更新 session usage if (session.sessionId) { let costUsdStr: string | undefined; - if (session.request.model) { - const priceData = await session.getCachedPriceDataByBillingSource(); - if (priceData) { - const cost = calculateRequestCost( - normalizedUsage, - priceData, - provider.costMultiplier, - session.getContext1mApplied() - ); - if (cost.gt(0)) { - costUsdStr = cost.toString(); + try { + if (session.request.model) { + const priceData = await session.getCachedPriceDataByBillingSource(); + if (priceData) { + const cost = calculateRequestCost( + normalizedUsage, + priceData, + provider.costMultiplier, + session.getContext1mApplied() + ); + if (cost.gt(0)) { + costUsdStr = cost.toString(); + } } } + } catch (error) { + logger.error("[ResponseHandler] Failed to calculate session cost (finalize), skipping", { + error: error instanceof Error ? error.message : String(error), + }); } void SessionManager.updateSessionUsage(session.sessionId, { @@ -1858,62 +1897,68 @@ async function finalizeRequestStats( async function trackCostToRedis(session: ProxySession, usage: UsageMetrics | null): Promise { if (!usage || !session.sessionId) return; - const messageContext = session.messageContext; - const provider = session.provider; - const key = session.authState?.key; - const user = session.authState?.user; + try { + const messageContext = session.messageContext; + const provider = session.provider; + const key = session.authState?.key; + const user = session.authState?.user; - if (!messageContext || !provider || !key || !user) return; + if (!messageContext || !provider || !key || !user) return; - const modelName = session.request.model; - if (!modelName) return; + const modelName = session.request.model; + if (!modelName) return; - // 计算成本(应用倍率)- 使用 session 缓存避免重复查询 - const priceData = await session.getCachedPriceDataByBillingSource(); - if (!priceData) return; + // 计算成本(应用倍率)- 使用 session 缓存避免重复查询 + const priceData = await session.getCachedPriceDataByBillingSource(); + if (!priceData) return; - const cost = calculateRequestCost( - usage, - priceData, - provider.costMultiplier, - session.getContext1mApplied() - ); - if (cost.lte(0)) return; - - const costFloat = parseFloat(cost.toString()); - - // 追踪到 Redis(使用 session.sessionId) - await RateLimitService.trackCost( - key.id, - provider.id, - session.sessionId, // 直接使用 session.sessionId - costFloat, - { - keyResetTime: key.dailyResetTime, - keyResetMode: key.dailyResetMode, - providerResetTime: provider.dailyResetTime, - providerResetMode: provider.dailyResetMode, - requestId: messageContext.id, - createdAtMs: messageContext.createdAt.getTime(), - } - ); + const cost = calculateRequestCost( + usage, + priceData, + provider.costMultiplier, + session.getContext1mApplied() + ); + if (cost.lte(0)) return; - // 新增:追踪用户层每日消费 - await RateLimitService.trackUserDailyCost( - user.id, - costFloat, - user.dailyResetTime, - user.dailyResetMode, - { - requestId: messageContext.id, - createdAtMs: messageContext.createdAt.getTime(), - } - ); + const costFloat = parseFloat(cost.toString()); - // 刷新 session 时间戳(滑动窗口) - void SessionTracker.refreshSession(session.sessionId, key.id, provider.id).catch((error) => { - logger.error("[ResponseHandler] Failed to refresh session tracker:", error); - }); + // 追踪到 Redis(使用 session.sessionId) + await RateLimitService.trackCost( + key.id, + provider.id, + session.sessionId, // 直接使用 session.sessionId + costFloat, + { + keyResetTime: key.dailyResetTime, + keyResetMode: key.dailyResetMode, + providerResetTime: provider.dailyResetTime, + providerResetMode: provider.dailyResetMode, + requestId: messageContext.id, + createdAtMs: messageContext.createdAt.getTime(), + } + ); + + // 新增:追踪用户层每日消费 + await RateLimitService.trackUserDailyCost( + user.id, + costFloat, + user.dailyResetTime, + user.dailyResetMode, + { + requestId: messageContext.id, + createdAtMs: messageContext.createdAt.getTime(), + } + ); + + // 刷新 session 时间戳(滑动窗口) + void SessionTracker.refreshSession(session.sessionId, key.id, provider.id).catch((error) => { + logger.error("[ResponseHandler] Failed to refresh session tracker:", error); + }); + } catch (error) { + logger.error("[ResponseHandler] Failed to track cost to Redis, skipping", { + error: error instanceof Error ? error.message : String(error), + }); + } } /** diff --git a/src/app/v1/_lib/proxy/session.ts b/src/app/v1/_lib/proxy/session.ts index 35bb67267..9f9366d62 100644 --- a/src/app/v1/_lib/proxy/session.ts +++ b/src/app/v1/_lib/proxy/session.ts @@ -2,6 +2,7 @@ import crypto from "node:crypto"; import type { Context } from "hono"; import { logger } from "@/lib/logger"; import { clientRequestsContext1m as clientRequestsContext1mHelper } from "@/lib/special-attributes"; +import { hasValidPriceData } from "@/lib/utils/price-data"; import { findLatestPriceByModel } from "@/repository/model-price"; import { findAllProviders } from "@/repository/provider"; import type { CacheTtlResolved } from "@/types/cache"; @@ -770,45 +771,6 @@ export class ProxySession { } } -/** - * 判断价格数据是否包含至少一个可用于计费的价格字段。 - * 避免把数据库中的 `{}` 或仅包含元信息的记录当成有效价格。 - */ -function hasValidPriceData(priceData: ModelPriceData): boolean { - const numericCosts = [ - priceData.input_cost_per_token, - priceData.output_cost_per_token, - priceData.cache_creation_input_token_cost, - priceData.cache_creation_input_token_cost_above_1hr, - priceData.cache_read_input_token_cost, - priceData.input_cost_per_token_above_200k_tokens, - priceData.output_cost_per_token_above_200k_tokens, - priceData.cache_creation_input_token_cost_above_200k_tokens, - priceData.cache_read_input_token_cost_above_200k_tokens, - priceData.output_cost_per_image, - ]; - - if ( - numericCosts.some((value) => typeof value === "number" && Number.isFinite(value) && value >= 0) - ) { - return true; - } - - const searchCosts = priceData.search_context_cost_per_query; - if (searchCosts) { - const searchCostFields = [ - searchCosts.search_context_size_high, - searchCosts.search_context_size_low, - searchCosts.search_context_size_medium, - ]; - return searchCostFields.some( - (value) => typeof value === "number" && Number.isFinite(value) && value >= 0 - ); - } - - return false; -} - function formatHeadersForLog(headers: Headers): string { const collected: string[] = []; headers.forEach((value, key) => { diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 535bca4d3..572307ace 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -13,6 +13,8 @@ const instrumentationState = globalThis as unknown as { __CCH_CACHE_CLEANUP_STARTED__?: boolean; __CCH_SHUTDOWN_HOOKS_REGISTERED__?: boolean; __CCH_SHUTDOWN_IN_PROGRESS__?: boolean; + __CCH_CLOUD_PRICE_SYNC_STARTED__?: boolean; + __CCH_CLOUD_PRICE_SYNC_INTERVAL_ID__?: ReturnType; }; /** @@ -40,6 +42,46 @@ async function syncErrorRulesAndInitializeDetector(): Promise { logger.info("Error rule detector cache loaded successfully"); } +/** + * 启动云端价格表定时同步(每 30 分钟一次)。 + * + * 约束: + * - 使用 globalThis 状态去重,避免开发环境热重载重复注册 + * - 失败不阻塞启动,仅记录日志 + */ +async function startCloudPriceSyncScheduler(): Promise { + if (instrumentationState.__CCH_CLOUD_PRICE_SYNC_STARTED__) { + return; + } + + try { + const { requestCloudPriceTableSync } = await import("@/lib/price-sync/cloud-price-updater"); + const intervalMs = 30 * 60 * 1000; + + // 启动后立即触发一次(避免首次 30 分钟空窗期) + requestCloudPriceTableSync({ reason: "scheduled", throttleMs: 0 }); + + instrumentationState.__CCH_CLOUD_PRICE_SYNC_INTERVAL_ID__ = setInterval(() => { + try { + requestCloudPriceTableSync({ reason: "scheduled", throttleMs: 0 }); + } catch (error) { + logger.warn("[Instrumentation] Cloud price sync scheduler tick failed", { + error: error instanceof Error ? error.message : String(error), + }); + } + }, intervalMs); + + instrumentationState.__CCH_CLOUD_PRICE_SYNC_STARTED__ = true; + logger.info("[Instrumentation] Cloud price sync scheduler started", { + intervalSeconds: intervalMs / 1000, + }); + } catch (error) { + logger.warn("[Instrumentation] Cloud price sync scheduler init failed", { + error: error instanceof Error ? error.message : String(error), + }); + } +} + export async function register() { // 仅在服务器端执行 if (process.env.NEXT_RUNTIME === "nodejs") { @@ -99,6 +141,18 @@ export async function register() { error: error instanceof Error ? error.message : String(error), }); } + + try { + if (instrumentationState.__CCH_CLOUD_PRICE_SYNC_INTERVAL_ID__) { + clearInterval(instrumentationState.__CCH_CLOUD_PRICE_SYNC_INTERVAL_ID__); + instrumentationState.__CCH_CLOUD_PRICE_SYNC_INTERVAL_ID__ = undefined; + instrumentationState.__CCH_CLOUD_PRICE_SYNC_STARTED__ = false; + } + } catch (error) { + logger.warn("[Instrumentation] Failed to stop cloud price sync scheduler", { + error: error instanceof Error ? error.message : String(error), + }); + } }; process.once("SIGTERM", () => { @@ -130,6 +184,9 @@ export async function register() { const { ensurePriceTable } = await import("@/lib/price-sync/seed-initializer"); await ensurePriceTable(); + // 启动云端价格表定时同步 + await startCloudPriceSyncScheduler(); + // 同步错误规则并初始化检测器(非关键功能,允许优雅降级) try { await syncErrorRulesAndInitializeDetector(); @@ -177,6 +234,11 @@ export async function register() { const { ensurePriceTable } = await import("@/lib/price-sync/seed-initializer"); await ensurePriceTable(); + // 启动云端价格表定时同步(仅在数据库可用时启用,避免本地无 DB 时反复报错) + if (isConnected) { + await startCloudPriceSyncScheduler(); + } + // 同步错误规则并初始化检测器(非关键功能,允许优雅降级) try { await syncErrorRulesAndInitializeDetector(); diff --git a/src/lib/price-sync.ts b/src/lib/price-sync.ts deleted file mode 100644 index 002933ab7..000000000 --- a/src/lib/price-sync.ts +++ /dev/null @@ -1,128 +0,0 @@ -/** - * LiteLLM 价格表自动同步服务 - * - * 核心功能: - * 1. 从 CDN 获取 LiteLLM 价格表 - * 2. 失败时使用本地缓存降级 - * 3. 成功后更新数据库并刷新缓存 - */ - -import fs from "node:fs/promises"; -import path from "node:path"; -import { isClientAbortError } from "@/app/v1/_lib/proxy/errors"; -import { logger } from "@/lib/logger"; - -const LITELLM_PRICE_URL = - "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json"; -const CACHE_FILE_PATH = path.join(process.cwd(), "public", "cache", "litellm-prices.json"); -const FETCH_TIMEOUT_MS = 10000; // 10 秒超时 - -/** - * 确保缓存目录存在 - */ -async function ensureCacheDirectory(): Promise { - const cacheDir = path.dirname(CACHE_FILE_PATH); - try { - await fs.access(cacheDir); - } catch { - await fs.mkdir(cacheDir, { recursive: true }); - } -} - -/** - * 从 CDN 获取 LiteLLM 价格表 JSON 字符串 - * @returns JSON 字符串或 null(失败时) - */ -export async function fetchLiteLLMPrices(): Promise { - try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); - - const response = await fetch(LITELLM_PRICE_URL, { - signal: controller.signal, - headers: { - Accept: "application/json", - }, - }); - - clearTimeout(timeoutId); - - if (!response.ok) { - logger.error("❌ Failed to fetch LiteLLM prices: HTTP ${response.status}"); - return null; - } - - const jsonText = await response.text(); - - // 验证 JSON 格式 - JSON.parse(jsonText); - - logger.info("Successfully fetched LiteLLM prices from CDN"); - return jsonText; - } catch (error) { - if (error instanceof Error) { - if (isClientAbortError(error)) { - logger.error("❌ Fetch LiteLLM prices timeout after 10s"); - } else { - logger.error("❌ Failed to fetch LiteLLM prices:", { context: error.message }); - } - } - return null; - } -} - -/** - * 从本地缓存读取价格表 - * @returns JSON 字符串或 null(缓存不存在或损坏) - */ -export async function readCachedPrices(): Promise { - try { - const cached = await fs.readFile(CACHE_FILE_PATH, "utf-8"); - - // 验证 JSON 格式 - JSON.parse(cached); - - logger.info("📦 Using cached LiteLLM prices"); - return cached; - } catch (error) { - if (error instanceof Error && "code" in error && error.code === "ENOENT") { - logger.info("ℹ️ No cached prices found"); - } else { - logger.error("❌ Failed to read cached prices:", error); - } - return null; - } -} - -/** - * 将价格表保存到本地缓存 - * @param jsonText - JSON 字符串 - */ -export async function saveCachedPrices(jsonText: string): Promise { - try { - await ensureCacheDirectory(); - await fs.writeFile(CACHE_FILE_PATH, jsonText, "utf-8"); - logger.info("💾 Saved prices to cache"); - } catch (error) { - logger.error("❌ Failed to save prices to cache:", error); - } -} - -/** - * 获取价格表 JSON(优先 CDN,降级缓存) - * @returns JSON 字符串或 null - */ -export async function getPriceTableJson(): Promise { - // 优先从 CDN 获取 - const jsonText = await fetchLiteLLMPrices(); - - if (jsonText) { - // 成功后更新缓存 - await saveCachedPrices(jsonText); - return jsonText; - } - - // 失败时降级使用缓存 - logger.info("⚠️ CDN fetch failed, trying cache..."); - return await readCachedPrices(); -} diff --git a/src/lib/price-sync/cloud-price-table.ts b/src/lib/price-sync/cloud-price-table.ts new file mode 100644 index 000000000..e24747726 --- /dev/null +++ b/src/lib/price-sync/cloud-price-table.ts @@ -0,0 +1,107 @@ +import TOML from "@iarna/toml"; +import type { ModelPriceData } from "@/types/model-price"; + +export const CLOUD_PRICE_TABLE_URL = "https://claude-code-hub.app/config/prices-base.toml"; +const FETCH_TIMEOUT_MS = 10000; + +export type CloudPriceTable = { + metadata?: Record; + models: Record; +}; + +export type CloudPriceTableResult = { ok: true; data: T } | { ok: false; error: string }; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function parseCloudPriceTableToml(tomlText: string): CloudPriceTableResult { + try { + const parsed = TOML.parse(tomlText) as unknown; + if (!isRecord(parsed)) { + return { ok: false, error: "价格表格式无效:根节点不是对象" }; + } + + const modelsValue = parsed.models; + if (!isRecord(modelsValue)) { + return { ok: false, error: "价格表格式无效:缺少 models 表" }; + } + + const models: Record = Object.create(null); + for (const [modelName, value] of Object.entries(modelsValue)) { + if (modelName === "__proto__" || modelName === "constructor" || modelName === "prototype") { + continue; + } + if (!isRecord(value)) continue; + models[modelName] = value as unknown as ModelPriceData; + } + + if (Object.keys(models).length === 0) { + return { ok: false, error: "价格表格式无效:models 为空" }; + } + + const metadataValue = parsed.metadata; + const metadata = isRecord(metadataValue) ? metadataValue : undefined; + + return { ok: true, data: { metadata, models } }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { ok: false, error: `价格表 TOML 解析失败: ${message}` }; + } +} + +export async function fetchCloudPriceTableToml( + url: string = CLOUD_PRICE_TABLE_URL +): Promise> { + const expectedUrl = (() => { + try { + return new URL(url); + } catch { + return null; + } + })(); + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + + try { + const response = await fetch(url, { + signal: controller.signal, + headers: { + Accept: "text/plain", + }, + cache: "no-store", + }); + + if (expectedUrl && typeof response.url === "string" && response.url) { + try { + const finalUrl = new URL(response.url); + if ( + finalUrl.protocol !== expectedUrl.protocol || + finalUrl.host !== expectedUrl.host || + finalUrl.pathname !== expectedUrl.pathname + ) { + return { ok: false, error: "云端价格表拉取失败:重定向到非预期地址" }; + } + } catch { + // response.url 无法解析时不阻断(仅作安全硬化),继续按原路径处理 + } + } + + if (!response.ok) { + return { ok: false, error: `云端价格表拉取失败:HTTP ${response.status}` }; + } + + const tomlText = await response.text(); + if (!tomlText.trim()) { + return { ok: false, error: "云端价格表拉取失败:内容为空" }; + } + + return { ok: true, data: tomlText }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { ok: false, error: `云端价格表拉取失败:${message}` }; + } finally { + clearTimeout(timeoutId); + } +} diff --git a/src/lib/price-sync/cloud-price-updater.ts b/src/lib/price-sync/cloud-price-updater.ts new file mode 100644 index 000000000..a64b28164 --- /dev/null +++ b/src/lib/price-sync/cloud-price-updater.ts @@ -0,0 +1,104 @@ +import { AsyncTaskManager } from "@/lib/async-task-manager"; +import { logger } from "@/lib/logger"; +import type { PriceUpdateResult } from "@/types/model-price"; +import { + type CloudPriceTableResult, + fetchCloudPriceTableToml, + parseCloudPriceTableToml, +} from "./cloud-price-table"; + +/** + * 拉取云端 TOML 价格表并写入数据库(不覆盖 manual,本地优先)。 + * + * 说明: + * - 这里复用现有的批处理入库逻辑(processPriceTableInternal),以保持行为一致 + * - 任何失败都以 ok=false 返回,不抛出异常,避免影响调用方主流程 + */ +export async function syncCloudPriceTableToDatabase( + overwriteManual?: string[] +): Promise> { + const tomlResult = await fetchCloudPriceTableToml(); + if (!tomlResult.ok) { + return tomlResult; + } + + const parseResult = parseCloudPriceTableToml(tomlResult.data); + if (!parseResult.ok) { + return { ok: false, error: parseResult.error }; + } + + try { + const { processPriceTableInternal } = await import("@/actions/model-prices"); + const jsonContent = JSON.stringify(parseResult.data.models); + const result = await processPriceTableInternal(jsonContent, overwriteManual); + + if (!result.ok) { + return { ok: false, error: result.error ?? "云端价格表写入失败" }; + } + if (!result.data) { + return { ok: false, error: "云端价格表写入失败:返回结果为空" }; + } + + return { ok: true, data: result.data }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { ok: false, error: `云端价格表写入失败:${message}` }; + } +} + +const DEFAULT_THROTTLE_MS = 5 * 60 * 1000; + +/** + * 请求一次云端价格表同步(异步执行,自动去重与节流)。 + * + * 适用场景: + * - 请求命中“未知模型/无价格”时触发异步同步,保证后续请求可命中价格 + */ +export function requestCloudPriceTableSync(options: { + reason: "missing-model" | "scheduled" | "manual"; + throttleMs?: number; +}): void { + const throttleMs = options.throttleMs ?? DEFAULT_THROTTLE_MS; + const taskId = "cloud-price-table-sync"; + + // 去重:已有任务在跑则不重复触发 + const active = AsyncTaskManager.getActiveTasks(); + if (active.some((t) => t.taskId === taskId)) { + return; + } + + // 节流:避免短时间内频繁拉取云端价格表 + const g = globalThis as unknown as { __CCH_CLOUD_PRICE_SYNC_LAST_AT__?: number }; + const lastAt = g.__CCH_CLOUD_PRICE_SYNC_LAST_AT__ ?? 0; + const now = Date.now(); + if (now - lastAt < throttleMs) { + return; + } + + AsyncTaskManager.register( + taskId, + (async () => { + try { + const result = await syncCloudPriceTableToDatabase(); + if (!result.ok) { + logger.warn("[PriceSync] Cloud price sync task failed", { + reason: options.reason, + error: result.error, + }); + return; + } + + logger.info("[PriceSync] Cloud price sync task completed", { + reason: options.reason, + added: result.data.added.length, + updated: result.data.updated.length, + skippedConflicts: result.data.skippedConflicts?.length ?? 0, + total: result.data.total, + }); + } finally { + g.__CCH_CLOUD_PRICE_SYNC_LAST_AT__ = Date.now(); + } + })(), + "cloud_price_table_sync" + ); +} diff --git a/src/lib/utils/price-data.ts b/src/lib/utils/price-data.ts new file mode 100644 index 000000000..d8770fae0 --- /dev/null +++ b/src/lib/utils/price-data.ts @@ -0,0 +1,40 @@ +import type { ModelPriceData } from "@/types/model-price"; + +/** + * 判断价格数据是否包含至少一个可用于计费的价格字段。 + * 避免把数据库中的 `{}` 或仅包含元信息的记录当成有效价格。 + */ +export function hasValidPriceData(priceData: ModelPriceData): boolean { + const numericCosts = [ + priceData.input_cost_per_token, + priceData.output_cost_per_token, + priceData.cache_creation_input_token_cost, + priceData.cache_creation_input_token_cost_above_1hr, + priceData.cache_read_input_token_cost, + priceData.input_cost_per_token_above_200k_tokens, + priceData.output_cost_per_token_above_200k_tokens, + priceData.cache_creation_input_token_cost_above_200k_tokens, + priceData.cache_read_input_token_cost_above_200k_tokens, + priceData.output_cost_per_image, + ]; + + if ( + numericCosts.some((value) => typeof value === "number" && Number.isFinite(value) && value >= 0) + ) { + return true; + } + + const searchCosts = priceData.search_context_cost_per_query; + if (searchCosts) { + const searchCostFields = [ + searchCosts.search_context_size_high, + searchCosts.search_context_size_low, + searchCosts.search_context_size_medium, + ]; + return searchCostFields.some( + (value) => typeof value === "number" && Number.isFinite(value) && value >= 0 + ); + } + + return false; +} diff --git a/src/repository/model-price.ts b/src/repository/model-price.ts index 0d5ab8e0e..764d0e442 100644 --- a/src/repository/model-price.ts +++ b/src/repository/model-price.ts @@ -3,6 +3,7 @@ import { desc, eq, sql } from "drizzle-orm"; import { db } from "@/drizzle/db"; import { modelPrices } from "@/drizzle/schema"; +import { logger } from "@/lib/logger"; import type { ModelPrice, ModelPriceData, ModelPriceSource } from "@/types/model-price"; import { toModelPrice } from "./_shared/transformers"; @@ -14,6 +15,7 @@ export interface PaginationParams { pageSize: number; search?: string; // 可选的搜索关键词 source?: ModelPriceSource; // 可选的来源过滤 + litellmProvider?: string; // 可选的云端提供商过滤(price_data.litellm_provider) } /** @@ -31,61 +33,58 @@ export interface PaginatedResult { * 获取指定模型的最新价格 */ export async function findLatestPriceByModel(modelName: string): Promise { - const [price] = await db - .select({ + try { + const selection = { id: modelPrices.id, modelName: modelPrices.modelName, priceData: modelPrices.priceData, source: modelPrices.source, createdAt: modelPrices.createdAt, updatedAt: modelPrices.updatedAt, - }) - .from(modelPrices) - .where(eq(modelPrices.modelName, modelName)) - .orderBy(desc(modelPrices.createdAt)) - .limit(1); + }; - if (!price) return null; - return toModelPrice(price); + const [price] = await db + .select(selection) + .from(modelPrices) + .where(eq(modelPrices.modelName, modelName)) + .orderBy( + // 本地手动配置优先(哪怕云端数据更新得更晚) + sql`(${modelPrices.source} = 'manual') DESC`, + sql`${modelPrices.createdAt} DESC NULLS LAST`, + desc(modelPrices.id) + ) + .limit(1); + + if (!price) return null; + return toModelPrice(price); + } catch (error) { + logger.error("[ModelPrice] Failed to query latest price by model", { + modelName, + error: error instanceof Error ? error.message : String(error), + }); + return null; + } } /** * 获取所有模型的最新价格(非分页版本,保持向后兼容) - * 注意:使用原生SQL,因为涉及到ROW_NUMBER()窗口函数 + * 注意:使用原生 SQL(DISTINCT ON),并确保 manual 来源优先 */ export async function findAllLatestPrices(): Promise { const query = sql` - WITH latest_prices AS ( - SELECT - model_name, - MAX(created_at) as max_created_at - FROM model_prices - GROUP BY model_name - ), - latest_records AS ( - SELECT - mp.id, - mp.model_name, - mp.price_data, - mp.source, - mp.created_at, - mp.updated_at, - ROW_NUMBER() OVER (PARTITION BY mp.model_name ORDER BY mp.id DESC) as rn - FROM model_prices mp - INNER JOIN latest_prices lp - ON mp.model_name = lp.model_name - AND mp.created_at = lp.max_created_at - ) - SELECT + SELECT DISTINCT ON (model_name) id, model_name as "modelName", price_data as "priceData", source, created_at as "createdAt", updated_at as "updatedAt" - FROM latest_records - WHERE rn = 1 - ORDER BY model_name + FROM model_prices + ORDER BY + model_name, + (source = 'manual') DESC, + created_at DESC NULLS LAST, + id DESC `; const result = await db.execute(query); @@ -94,12 +93,12 @@ export async function findAllLatestPrices(): Promise { /** * 分页获取所有模型的最新价格 - * 注意:使用原生SQL,因为涉及到ROW_NUMBER()窗口函数 + * 注意:使用原生 SQL(DISTINCT ON),并确保 manual 来源优先 */ export async function findAllLatestPricesPaginated( params: PaginationParams ): Promise> { - const { page, pageSize, search, source } = params; + const { page, pageSize, search, source, litellmProvider } = params; const offset = (page - 1) * pageSize; // 构建 WHERE 条件 @@ -111,6 +110,9 @@ export async function findAllLatestPricesPaginated( if (source) { conditions.push(sql`source = ${source}`); } + if (litellmProvider?.trim()) { + conditions.push(sql`price_data->>'litellm_provider' = ${litellmProvider.trim()}`); + } if (conditions.length === 0) return sql``; if (conditions.length === 1) return sql`WHERE ${conditions[0]}`; return sql`WHERE ${sql.join(conditions, sql` AND `)}`; @@ -120,26 +122,9 @@ export async function findAllLatestPricesPaginated( // 先获取总数 const countQuery = sql` - WITH latest_prices AS ( - SELECT - model_name, - MAX(created_at) as max_created_at - FROM model_prices - ${whereCondition} - GROUP BY model_name - ), - latest_records AS ( - SELECT - mp.id, - ROW_NUMBER() OVER (PARTITION BY mp.model_name ORDER BY mp.id DESC) as rn - FROM model_prices mp - INNER JOIN latest_prices lp - ON mp.model_name = lp.model_name - AND mp.created_at = lp.max_created_at - ) - SELECT COUNT(*) as total - FROM latest_records - WHERE rn = 1 + SELECT COUNT(DISTINCT model_name) as total + FROM model_prices + ${whereCondition} `; const [countResult] = await db.execute(countQuery); @@ -147,38 +132,20 @@ export async function findAllLatestPricesPaginated( // 获取分页数据 const dataQuery = sql` - WITH latest_prices AS ( - SELECT - model_name, - MAX(created_at) as max_created_at - FROM model_prices - ${whereCondition} - GROUP BY model_name - ), - latest_records AS ( - SELECT - mp.id, - mp.model_name, - mp.price_data, - mp.source, - mp.created_at, - mp.updated_at, - ROW_NUMBER() OVER (PARTITION BY mp.model_name ORDER BY mp.id DESC) as rn - FROM model_prices mp - INNER JOIN latest_prices lp - ON mp.model_name = lp.model_name - AND mp.created_at = lp.max_created_at - ) - SELECT + SELECT DISTINCT ON (model_name) id, model_name as "modelName", price_data as "priceData", source, created_at as "createdAt", updated_at as "updatedAt" - FROM latest_records - WHERE rn = 1 - ORDER BY model_name + FROM model_prices + ${whereCondition} + ORDER BY + model_name, + (source = 'manual') DESC, + created_at DESC NULLS LAST, + id DESC LIMIT ${pageSize} OFFSET ${offset} `; @@ -270,37 +237,19 @@ export async function deleteModelPriceByName(modelName: string): Promise { */ export async function findAllManualPrices(): Promise> { const query = sql` - WITH latest_prices AS ( - SELECT - model_name, - MAX(created_at) as max_created_at - FROM model_prices - WHERE source = 'manual' - GROUP BY model_name - ), - latest_records AS ( - SELECT - mp.id, - mp.model_name, - mp.price_data, - mp.source, - mp.created_at, - mp.updated_at, - ROW_NUMBER() OVER (PARTITION BY mp.model_name ORDER BY mp.id DESC) as rn - FROM model_prices mp - INNER JOIN latest_prices lp - ON mp.model_name = lp.model_name - AND mp.created_at = lp.max_created_at - ) - SELECT + SELECT DISTINCT ON (model_name) id, model_name as "modelName", price_data as "priceData", source, created_at as "createdAt", updated_at as "updatedAt" - FROM latest_records - WHERE rn = 1 + FROM model_prices + WHERE source = 'manual' + ORDER BY + model_name, + created_at DESC NULLS LAST, + id DESC `; const result = await db.execute(query); diff --git a/src/types/model-price.ts b/src/types/model-price.ts index b370719ad..75ae4e81b 100644 --- a/src/types/model-price.ts +++ b/src/types/model-price.ts @@ -28,7 +28,9 @@ export interface ModelPriceData { }; // 模型能力信息 + display_name?: string; litellm_provider?: string; + providers?: string[]; max_input_tokens?: number; max_output_tokens?: number; max_tokens?: number; diff --git a/tests/integration/billing-model-source.test.ts b/tests/integration/billing-model-source.test.ts index e7182237d..ae3ea8a41 100644 --- a/tests/integration/billing-model-source.test.ts +++ b/tests/integration/billing-model-source.test.ts @@ -1,8 +1,9 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ModelPrice, ModelPriceData } from "@/types/model-price"; import type { SystemSettings } from "@/types/system-config"; const asyncTasks: Promise[] = []; +const cloudPriceSyncRequests: Array<{ reason: string }> = []; vi.mock("@/lib/async-task-manager", () => ({ AsyncTaskManager: { @@ -25,6 +26,12 @@ vi.mock("@/lib/logger", () => ({ }, })); +vi.mock("@/lib/price-sync/cloud-price-updater", () => ({ + requestCloudPriceTableSync: (payload: { reason: string }) => { + cloudPriceSyncRequests.push(payload); + }, +})); + vi.mock("@/repository/model-price", () => ({ findLatestPriceByModel: vi.fn(), })); @@ -82,6 +89,10 @@ import { import { findLatestPriceByModel } from "@/repository/model-price"; import { getSystemSettings } from "@/repository/system-config"; +beforeEach(() => { + cloudPriceSyncRequests.splice(0, cloudPriceSyncRequests.length); +}); + function makeSystemSettings( billingModelSource: SystemSettings["billingModelSource"] ): SystemSettings { @@ -358,3 +369,77 @@ describe("Billing model source - Redis session cost vs DB cost", () => { expect(original.sessionCostUsd).not.toBe(redirected.sessionCostUsd); }); }); + +describe("价格表缺失/查询失败:不计费放行", () => { + async function runNoPriceScenario(options: { + billingModelSource: SystemSettings["billingModelSource"]; + isStream: boolean; + priceLookup: "none" | "throws"; + }): Promise<{ dbCostCalls: number; rateLimitCalls: number }> { + const usage = { input_tokens: 2, output_tokens: 3 }; + const originalModel = "original-model"; + const redirectedModel = "redirected-model"; + + vi.mocked(getSystemSettings).mockResolvedValue(makeSystemSettings(options.billingModelSource)); + if (options.priceLookup === "none") { + vi.mocked(findLatestPriceByModel).mockResolvedValue(null); + } else { + vi.mocked(findLatestPriceByModel).mockImplementation(async () => { + throw new Error("db query failed"); + }); + } + + vi.mocked(updateMessageRequestDetails).mockResolvedValue(undefined); + vi.mocked(updateMessageRequestDuration).mockResolvedValue(undefined); + vi.mocked(SessionManager.storeSessionResponse).mockResolvedValue(undefined); + vi.mocked(RateLimitService.trackUserDailyCost).mockResolvedValue(undefined); + vi.mocked(SessionTracker.refreshSession).mockResolvedValue(undefined); + + vi.mocked(updateMessageRequestCost).mockResolvedValue(undefined); + vi.mocked(RateLimitService.trackCost).mockResolvedValue(undefined); + vi.mocked(SessionManager.updateSessionUsage).mockResolvedValue(undefined); + + const session = createSession({ + originalModel, + redirectedModel, + sessionId: `sess-no-price-${options.billingModelSource}-${options.isStream ? "s" : "n"}`, + messageId: options.isStream ? 3001 : 3000, + }); + + const response = options.isStream + ? createStreamResponse(usage) + : createNonStreamResponse(usage); + const clientResponse = await ProxyResponseHandler.dispatch(session, response); + await clientResponse.text(); + + await drainAsyncTasks(); + + return { + dbCostCalls: vi.mocked(updateMessageRequestCost).mock.calls.length, + rateLimitCalls: vi.mocked(RateLimitService.trackCost).mock.calls.length, + }; + } + + it("无价格:不写入 DB cost,不追踪限流 cost,并触发一次异步同步", async () => { + const result = await runNoPriceScenario({ + billingModelSource: "redirected", + isStream: false, + priceLookup: "none", + }); + + expect(result.dbCostCalls).toBe(0); + expect(result.rateLimitCalls).toBe(0); + expect(cloudPriceSyncRequests).toEqual([{ reason: "missing-model" }]); + }); + + it("价格查询抛错:不应影响响应,不写入 DB cost,不追踪限流 cost", async () => { + const result = await runNoPriceScenario({ + billingModelSource: "original", + isStream: true, + priceLookup: "throws", + }); + + expect(result.dbCostCalls).toBe(0); + expect(result.rateLimitCalls).toBe(0); + }); +}); diff --git a/tests/unit/actions/model-prices.test.ts b/tests/unit/actions/model-prices.test.ts index e6e7a94ad..a464440fa 100644 --- a/tests/unit/actions/model-prices.test.ts +++ b/tests/unit/actions/model-prices.test.ts @@ -7,13 +7,14 @@ const revalidatePathMock = vi.fn(); // Repository mocks const findLatestPriceByModelMock = vi.fn(); +const findAllLatestPricesMock = vi.fn(); const createModelPriceMock = vi.fn(); const upsertModelPriceMock = vi.fn(); const deleteModelPriceByNameMock = vi.fn(); const findAllManualPricesMock = vi.fn(); // Price sync mock -const getPriceTableJsonMock = vi.fn(); +const fetchCloudPriceTableTomlMock = vi.fn(); vi.mock("@/lib/auth", () => ({ getSession: () => getSessionMock(), @@ -39,7 +40,7 @@ vi.mock("@/repository/model-price", () => ({ upsertModelPrice: (...args: unknown[]) => upsertModelPriceMock(...args), deleteModelPriceByName: (...args: unknown[]) => deleteModelPriceByNameMock(...args), findAllManualPrices: () => findAllManualPricesMock(), - findAllLatestPrices: vi.fn(async () => []), + findAllLatestPrices: () => findAllLatestPricesMock(), findAllLatestPricesPaginated: vi.fn(async () => ({ data: [], total: 0, @@ -50,9 +51,13 @@ vi.mock("@/repository/model-price", () => ({ hasAnyPriceRecords: vi.fn(async () => false), })); -vi.mock("@/lib/price-sync", () => ({ - getPriceTableJson: () => getPriceTableJsonMock(), -})); +vi.mock("@/lib/price-sync/cloud-price-table", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchCloudPriceTableToml: (...args: unknown[]) => fetchCloudPriceTableTomlMock(...args), + }; +}); // Helper to create mock ModelPrice function makeMockPrice( @@ -81,6 +86,7 @@ describe("Model Price Actions", () => { vi.clearAllMocks(); // Default: admin session getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); + findAllLatestPricesMock.mockResolvedValue([]); }); describe("upsertSingleModelPrice", () => { @@ -224,11 +230,12 @@ describe("Model Price Actions", () => { describe("checkLiteLLMSyncConflicts", () => { it("should return no conflicts when no manual prices exist", async () => { findAllManualPricesMock.mockResolvedValue(new Map()); - getPriceTableJsonMock.mockResolvedValue( - JSON.stringify({ - "claude-3-opus": { mode: "chat", input_cost_per_token: 0.000015 }, - }) - ); + fetchCloudPriceTableTomlMock.mockResolvedValue({ + ok: true, + data: ['[models."claude-3-opus"]', 'mode = "chat"', "input_cost_per_token = 0.000015"].join( + "\n" + ), + }); const { checkLiteLLMSyncConflicts } = await import("@/actions/model-prices"); const result = await checkLiteLLMSyncConflicts(); @@ -247,15 +254,15 @@ describe("Model Price Actions", () => { findAllManualPricesMock.mockResolvedValue(new Map([["claude-3-opus", manualPrice]])); - getPriceTableJsonMock.mockResolvedValue( - JSON.stringify({ - "claude-3-opus": { - mode: "chat", - input_cost_per_token: 0.000015, - output_cost_per_token: 0.00006, - }, - }) - ); + fetchCloudPriceTableTomlMock.mockResolvedValue({ + ok: true, + data: [ + '[models."claude-3-opus"]', + 'mode = "chat"', + "input_cost_per_token = 0.000015", + "output_cost_per_token = 0.00006", + ].join("\n"), + }); const { checkLiteLLMSyncConflicts } = await import("@/actions/model-prices"); const result = await checkLiteLLMSyncConflicts(); @@ -274,11 +281,12 @@ describe("Model Price Actions", () => { findAllManualPricesMock.mockResolvedValue(new Map([["custom-model", manualPrice]])); - getPriceTableJsonMock.mockResolvedValue( - JSON.stringify({ - "claude-3-opus": { mode: "chat", input_cost_per_token: 0.000015 }, - }) - ); + fetchCloudPriceTableTomlMock.mockResolvedValue({ + ok: true, + data: ['[models."claude-3-opus"]', 'mode = "chat"', "input_cost_per_token = 0.000015"].join( + "\n" + ), + }); const { checkLiteLLMSyncConflicts } = await import("@/actions/model-prices"); const result = await checkLiteLLMSyncConflicts(); @@ -300,24 +308,30 @@ describe("Model Price Actions", () => { it("should handle network errors gracefully", async () => { findAllManualPricesMock.mockResolvedValue(new Map()); - getPriceTableJsonMock.mockResolvedValue(null); + fetchCloudPriceTableTomlMock.mockResolvedValue({ + ok: false, + error: "云端价格表拉取失败:mock", + }); const { checkLiteLLMSyncConflicts } = await import("@/actions/model-prices"); const result = await checkLiteLLMSyncConflicts(); expect(result.ok).toBe(false); - expect(result.error).toContain("CDN"); + expect(result.error).toContain("云端"); }); - it("should handle invalid JSON gracefully", async () => { + it("should handle invalid TOML gracefully", async () => { findAllManualPricesMock.mockResolvedValue(new Map()); - getPriceTableJsonMock.mockResolvedValue("invalid json {"); + fetchCloudPriceTableTomlMock.mockResolvedValue({ + ok: true, + data: "[models\ninvalid = true", + }); const { checkLiteLLMSyncConflicts } = await import("@/actions/model-prices"); const result = await checkLiteLLMSyncConflicts(); expect(result.ok).toBe(false); - expect(result.error).toContain("JSON"); + expect(result.error).toContain("TOML"); }); }); @@ -329,7 +343,7 @@ describe("Model Price Actions", () => { }); findAllManualPricesMock.mockResolvedValue(new Map([["custom-model", manualPrice]])); - findLatestPriceByModelMock.mockResolvedValue(manualPrice); + findAllLatestPricesMock.mockResolvedValue([manualPrice]); const { processPriceTableInternal } = await import("@/actions/model-prices"); const result = await processPriceTableInternal( @@ -354,7 +368,7 @@ describe("Model Price Actions", () => { }); findAllManualPricesMock.mockResolvedValue(new Map([["custom-model", manualPrice]])); - findLatestPriceByModelMock.mockResolvedValue(manualPrice); + findAllLatestPricesMock.mockResolvedValue([manualPrice]); deleteModelPriceByNameMock.mockResolvedValue(undefined); createModelPriceMock.mockResolvedValue( makeMockPrice( @@ -386,7 +400,7 @@ describe("Model Price Actions", () => { it("should add new models with litellm source", async () => { findAllManualPricesMock.mockResolvedValue(new Map()); - findLatestPriceByModelMock.mockResolvedValue(null); + findAllLatestPricesMock.mockResolvedValue([]); createModelPriceMock.mockResolvedValue( makeMockPrice( "new-model", @@ -414,7 +428,7 @@ describe("Model Price Actions", () => { it("should skip metadata fields like sample_spec", async () => { findAllManualPricesMock.mockResolvedValue(new Map()); - findLatestPriceByModelMock.mockResolvedValue(null); + findAllLatestPricesMock.mockResolvedValue([]); const { processPriceTableInternal } = await import("@/actions/model-prices"); const result = await processPriceTableInternal( @@ -431,7 +445,7 @@ describe("Model Price Actions", () => { it("should skip entries without mode field", async () => { findAllManualPricesMock.mockResolvedValue(new Map()); - findLatestPriceByModelMock.mockResolvedValue(null); + findAllLatestPricesMock.mockResolvedValue([]); const { processPriceTableInternal } = await import("@/actions/model-prices"); const result = await processPriceTableInternal( @@ -444,5 +458,36 @@ describe("Model Price Actions", () => { expect(result.ok).toBe(true); expect(result.data?.failed).toContain("invalid-model"); }); + + it("should ignore dangerous keys when comparing price data", async () => { + const existing = makeMockPrice( + "safe-model", + { + mode: "chat", + input_cost_per_token: 0.000001, + output_cost_per_token: 0.000002, + }, + "litellm" + ); + + findAllManualPricesMock.mockResolvedValue(new Map()); + findAllLatestPricesMock.mockResolvedValue([existing]); + + const { processPriceTableInternal } = await import("@/actions/model-prices"); + const result = await processPriceTableInternal( + JSON.stringify({ + "safe-model": { + mode: "chat", + input_cost_per_token: 0.000001, + output_cost_per_token: 0.000002, + constructor: { prototype: { polluted: true } }, + }, + }) + ); + + expect(result.ok).toBe(true); + expect(result.data?.unchanged).toContain("safe-model"); + expect(createModelPriceMock).not.toHaveBeenCalled(); + }); }); }); diff --git a/tests/unit/api/prices-route.test.ts b/tests/unit/api/prices-route.test.ts new file mode 100644 index 000000000..9f25f6356 --- /dev/null +++ b/tests/unit/api/prices-route.test.ts @@ -0,0 +1,76 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + getSession: vi.fn(), + getModelPricesPaginated: vi.fn(), +})); + +vi.mock("@/lib/auth", () => ({ + getSession: mocks.getSession, +})); + +vi.mock("@/actions/model-prices", () => ({ + getModelPricesPaginated: mocks.getModelPricesPaginated, +})); + +describe("GET /api/prices", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 403 when session is missing", async () => { + mocks.getSession.mockResolvedValue(null); + + const { GET } = await import("@/app/api/prices/route"); + const response = await GET({ url: "http://localhost/api/prices" } as any); + expect(response.status).toBe(403); + }); + + it("returns 403 when user is not admin", async () => { + mocks.getSession.mockResolvedValue({ user: { role: "user" } }); + + const { GET } = await import("@/app/api/prices/route"); + const response = await GET({ url: "http://localhost/api/prices" } as any); + expect(response.status).toBe(403); + }); + + it("returns 400 when page is NaN", async () => { + mocks.getSession.mockResolvedValue({ user: { role: "admin" } }); + + const { GET } = await import("@/app/api/prices/route"); + const response = await GET({ url: "http://localhost/api/prices?page=abc&pageSize=50" } as any); + expect(response.status).toBe(400); + }); + + it("returns 400 when pageSize is NaN", async () => { + mocks.getSession.mockResolvedValue({ user: { role: "admin" } }); + + const { GET } = await import("@/app/api/prices/route"); + const response = await GET({ url: "http://localhost/api/prices?page=1&pageSize=abc" } as any); + expect(response.status).toBe(400); + }); + + it("returns ok=true when params are valid", async () => { + mocks.getSession.mockResolvedValue({ user: { role: "admin" } }); + mocks.getModelPricesPaginated.mockResolvedValue({ + ok: true, + data: { + data: [], + total: 0, + page: 1, + pageSize: 50, + totalPages: 0, + }, + }); + + const { GET } = await import("@/app/api/prices/route"); + const response = await GET({ url: "http://localhost/api/prices?page=1&pageSize=50" } as any); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.ok).toBe(true); + expect(mocks.getModelPricesPaginated).toHaveBeenCalledWith( + expect.objectContaining({ page: 1, pageSize: 50 }) + ); + }); +}); diff --git a/tests/unit/price-sync/cloud-price-table.test.ts b/tests/unit/price-sync/cloud-price-table.test.ts new file mode 100644 index 000000000..7acc1ff27 --- /dev/null +++ b/tests/unit/price-sync/cloud-price-table.test.ts @@ -0,0 +1,225 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + fetchCloudPriceTableToml, + parseCloudPriceTableToml, +} from "@/lib/price-sync/cloud-price-table"; + +describe("parseCloudPriceTableToml", () => { + it('parses [models."..."] tables into a model map', () => { + const toml = [ + "[metadata]", + 'version = "test"', + "", + '[models."m1"]', + 'display_name = "Model One"', + 'mode = "chat"', + 'litellm_provider = "anthropic"', + "input_cost_per_token = 0.000001", + "supports_vision = true", + "", + '[models."m1".pricing."anthropic"]', + "input_cost_per_token = 0.000001", + "", + '[models."m2"]', + 'mode = "image_generation"', + 'litellm_provider = "openai"', + "output_cost_per_image = 0.02", + "", + ].join("\n"); + + const result = parseCloudPriceTableToml(toml); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(Object.keys(result.data.models).sort()).toEqual(["m1", "m2"]); + expect(result.data.metadata?.version).toBe("test"); + + expect(result.data.models.m1.display_name).toBe("Model One"); + expect(result.data.models.m1.mode).toBe("chat"); + expect(result.data.models.m1.litellm_provider).toBe("anthropic"); + expect(result.data.models.m1.supports_vision).toBe(true); + + const pricing = result.data.models.m1.pricing as { + anthropic?: { input_cost_per_token?: number }; + }; + expect(pricing.anthropic?.input_cost_per_token).toBe(0.000001); + }); + + it("returns an error when models table is missing", () => { + const toml = ["[metadata]", 'version = "test"'].join("\n"); + const result = parseCloudPriceTableToml(toml); + expect(result.ok).toBe(false); + }); + + it("returns an error when TOML is invalid", () => { + const toml = "[models\ninvalid = true"; + const result = parseCloudPriceTableToml(toml); + expect(result.ok).toBe(false); + }); + + it("returns an error when models table is empty", () => { + const toml = ["[models]"].join("\n"); + const result = parseCloudPriceTableToml(toml); + expect(result.ok).toBe(false); + }); + + it("ignores reserved keys in models table", () => { + const toml = [ + '[models."__proto__"]', + 'mode = "chat"', + "input_cost_per_token = 0.000001", + "", + '[models."safe-model"]', + 'mode = "chat"', + "input_cost_per_token = 0.000001", + "", + ].join("\n"); + + const result = parseCloudPriceTableToml(toml); + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(Object.keys(result.data.models)).toEqual(["safe-model"]); + }); + + it("returns an error when root is not an object (defensive)", async () => { + vi.resetModules(); + vi.doMock("@iarna/toml", () => ({ + default: { + parse: () => 123, + }, + })); + + const mod = await import("@/lib/price-sync/cloud-price-table"); + const result = mod.parseCloudPriceTableToml("[models]"); + expect(result.ok).toBe(false); + + vi.doUnmock("@iarna/toml"); + }); +}); + +describe("fetchCloudPriceTableToml", () => { + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it("returns ok=true when response is ok and body is non-empty", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + status: 200, + text: async () => "toml content", + })) + ); + + const result = await fetchCloudPriceTableToml("https://example.test/prices.toml"); + expect(result.ok).toBe(true); + }); + + it("returns ok=false when response is not ok", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: false, + status: 404, + text: async () => "not found", + })) + ); + + const result = await fetchCloudPriceTableToml("https://example.test/prices.toml"); + expect(result.ok).toBe(false); + }); + + it("returns ok=false when response url redirects to unexpected host", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + status: 200, + url: "https://evil.test/prices.toml", + text: async () => "toml content", + })) + ); + + const result = await fetchCloudPriceTableToml("https://example.test/prices.toml"); + expect(result.ok).toBe(false); + }); + + it("returns ok=false when response url redirects to unexpected pathname", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + status: 200, + url: "https://example.test/evil.toml", + text: async () => "toml content", + })) + ); + + const result = await fetchCloudPriceTableToml("https://example.test/prices.toml"); + expect(result.ok).toBe(false); + }); + + it("returns ok=false when url is invalid and fetch throws", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw new Error("Invalid URL"); + }) + ); + + const result = await fetchCloudPriceTableToml("not-a-url"); + expect(result.ok).toBe(false); + }); + + it("returns ok=false when response body is empty", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + status: 200, + text: async () => " ", + })) + ); + + const result = await fetchCloudPriceTableToml("https://example.test/prices.toml"); + expect(result.ok).toBe(false); + }); + + it("returns ok=false when request times out and aborts", async () => { + vi.useFakeTimers(); + + vi.stubGlobal( + "fetch", + vi.fn( + async (_url: string, init?: { signal?: AbortSignal }) => + await new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => { + reject(new Error("AbortError")); + }); + }) + ) + ); + + const promise = fetchCloudPriceTableToml("https://example.test/prices.toml"); + await vi.advanceTimersByTimeAsync(10000); + + const result = await promise; + expect(result.ok).toBe(false); + }); + + it("returns ok=false when fetch throws a non-Error value", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw "boom"; + }) + ); + + const result = await fetchCloudPriceTableToml("https://example.test/prices.toml"); + expect(result.ok).toBe(false); + }); +}); diff --git a/tests/unit/price-sync/cloud-price-updater.test.ts b/tests/unit/price-sync/cloud-price-updater.test.ts new file mode 100644 index 000000000..6b2b69582 --- /dev/null +++ b/tests/unit/price-sync/cloud-price-updater.test.ts @@ -0,0 +1,249 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { CloudPriceTableResult } from "@/lib/price-sync/cloud-price-table"; +import { logger } from "@/lib/logger"; +import { + syncCloudPriceTableToDatabase, + requestCloudPriceTableSync, +} from "@/lib/price-sync/cloud-price-updater"; +import { AsyncTaskManager } from "@/lib/async-task-manager"; +import { processPriceTableInternal } from "@/actions/model-prices"; + +const asyncTasks: Promise[] = []; + +vi.mock("@/lib/logger", () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + trace: vi.fn(), + }, +})); + +vi.mock("@/lib/async-task-manager", () => ({ + AsyncTaskManager: { + getActiveTasks: vi.fn(() => []), + register: vi.fn((_taskId: string, promise: Promise) => { + asyncTasks.push(promise); + return new AbortController(); + }), + }, +})); + +vi.mock("@/actions/model-prices", () => ({ + processPriceTableInternal: vi.fn(async () => ({ + ok: true, + data: { + added: [], + updated: [], + unchanged: [], + failed: [], + total: 0, + }, + })), +})); + +describe("syncCloudPriceTableToDatabase", () => { + beforeEach(() => { + vi.clearAllMocks(); + asyncTasks.splice(0, asyncTasks.length); + vi.unstubAllGlobals(); + }); + + it("returns ok=false when cloud fetch fails with HTTP error", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: false, + status: 500, + text: async () => "server error", + })) + ); + + const result = await syncCloudPriceTableToDatabase(); + expect(result.ok).toBe(false); + }); + + it("returns ok=false when cloud fetch returns empty body", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + status: 200, + text: async () => " ", + })) + ); + + const result = await syncCloudPriceTableToDatabase(); + expect(result.ok).toBe(false); + }); + + it("returns ok=false when TOML is missing models table", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + status: 200, + text: async () => ["[metadata]", 'version = "test"'].join("\n"), + })) + ); + + const result = await syncCloudPriceTableToDatabase(); + expect(result.ok).toBe(false); + }); + + it("returns ok=false when processPriceTableInternal returns ok=false", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + status: 200, + text: async () => ['[models."m1"]', "input_cost_per_token = 0.000001"].join("\n"), + })) + ); + + vi.mocked(processPriceTableInternal).mockResolvedValue({ + ok: false, + error: "write failed", + } as unknown as CloudPriceTableResult); + + const result = await syncCloudPriceTableToDatabase(); + expect(result.ok).toBe(false); + }); + + it("returns ok=false when processPriceTableInternal returns ok=true but data is empty", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + status: 200, + text: async () => ['[models."m1"]', "input_cost_per_token = 0.000001"].join("\n"), + })) + ); + + vi.mocked(processPriceTableInternal).mockResolvedValue({ + ok: true, + data: undefined, + } as unknown as CloudPriceTableResult); + + const result = await syncCloudPriceTableToDatabase(); + expect(result.ok).toBe(false); + }); + + it("returns ok=true when TOML parses and write succeeds", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + status: 200, + text: async () => + ['[models."m1"]', 'display_name = "Model One"', "input_cost_per_token = 0.000001"].join( + "\n" + ), + })) + ); + + vi.mocked(processPriceTableInternal).mockResolvedValue({ + ok: true, + data: { + added: ["m1"], + updated: [], + unchanged: [], + failed: [], + total: 1, + }, + } as any); + + const result = await syncCloudPriceTableToDatabase(); + expect(result.ok).toBe(true); + expect(processPriceTableInternal).toHaveBeenCalledTimes(1); + }); +}); + +describe("requestCloudPriceTableSync", () => { + beforeEach(() => { + vi.clearAllMocks(); + asyncTasks.splice(0, asyncTasks.length); + vi.unstubAllGlobals(); + delete (globalThis as unknown as { __CCH_CLOUD_PRICE_SYNC_LAST_AT__?: number }) + .__CCH_CLOUD_PRICE_SYNC_LAST_AT__; + }); + + it("does nothing when same task is already active", () => { + vi.mocked(AsyncTaskManager.getActiveTasks).mockReturnValue([ + { taskId: "cloud-price-table-sync" }, + ] as any); + + requestCloudPriceTableSync({ reason: "missing-model", throttleMs: 0 }); + + expect(AsyncTaskManager.register).not.toHaveBeenCalled(); + }); + + it("throttles when called within throttle window", () => { + ( + globalThis as unknown as { __CCH_CLOUD_PRICE_SYNC_LAST_AT__?: number } + ).__CCH_CLOUD_PRICE_SYNC_LAST_AT__ = Date.now(); + + requestCloudPriceTableSync({ reason: "missing-model", throttleMs: 60_000 }); + + expect(AsyncTaskManager.register).not.toHaveBeenCalled(); + }); + + it("registers a task and updates throttle timestamp after completion", async () => { + let resolveFetch: (value: unknown) => void; + const fetchPromise = new Promise((resolve) => { + resolveFetch = resolve; + }); + + vi.stubGlobal( + "fetch", + vi.fn(async () => await fetchPromise) + ); + + vi.mocked(processPriceTableInternal).mockResolvedValue({ + ok: true, + data: { + added: ["m1"], + updated: [], + unchanged: [], + failed: [], + total: 1, + }, + } as any); + + requestCloudPriceTableSync({ reason: "missing-model", throttleMs: 0 }); + + expect(AsyncTaskManager.register).toHaveBeenCalledTimes(1); + + const g = globalThis as unknown as { __CCH_CLOUD_PRICE_SYNC_LAST_AT__?: number }; + expect(g.__CCH_CLOUD_PRICE_SYNC_LAST_AT__).toBeUndefined(); + + resolveFetch!({ + ok: true, + status: 200, + text: async () => ['[models."m1"]', "input_cost_per_token = 0.000001"].join("\n"), + }); + + await Promise.all(asyncTasks.splice(0, asyncTasks.length)); + + expect(processPriceTableInternal).toHaveBeenCalledTimes(1); + expect(vi.mocked(logger.info)).toHaveBeenCalled(); + expect(typeof g.__CCH_CLOUD_PRICE_SYNC_LAST_AT__).toBe("number"); + }); + + it("logs warn when sync task fails", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: false, + status: 500, + text: async () => "server error", + })) + ); + + requestCloudPriceTableSync({ reason: "scheduled", throttleMs: 0 }); + await Promise.all(asyncTasks.splice(0, asyncTasks.length)); + + expect(vi.mocked(logger.warn)).toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/proxy/pricing-no-price.test.ts b/tests/unit/proxy/pricing-no-price.test.ts new file mode 100644 index 000000000..46f8d247a --- /dev/null +++ b/tests/unit/proxy/pricing-no-price.test.ts @@ -0,0 +1,245 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { SystemSettings } from "@/types/system-config"; + +const { cloudSyncRequests, requestCloudPriceTableSyncMock } = vi.hoisted(() => { + const cloudSyncRequests: Array<{ reason: string }> = []; + const requestCloudPriceTableSyncMock = vi.fn((payload: { reason: string }) => { + cloudSyncRequests.push(payload); + }); + return { cloudSyncRequests, requestCloudPriceTableSyncMock }; +}); + +vi.mock("@/lib/price-sync/cloud-price-updater", () => ({ + requestCloudPriceTableSync: requestCloudPriceTableSyncMock, +})); + +vi.mock("@/lib/logger", () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + trace: vi.fn(), + }, +})); + +vi.mock("@/repository/model-price", () => ({ + findLatestPriceByModel: vi.fn(), +})); + +vi.mock("@/repository/system-config", () => ({ + getSystemSettings: vi.fn(), +})); + +vi.mock("@/repository/message", () => ({ + updateMessageRequestCost: vi.fn(), + updateMessageRequestDetails: vi.fn(), + updateMessageRequestDuration: vi.fn(), +})); + +vi.mock("@/lib/session-manager", () => ({ + SessionManager: { + updateSessionUsage: vi.fn(async () => {}), + storeSessionResponse: vi.fn(async () => {}), + extractCodexPromptCacheKey: vi.fn(), + updateSessionWithCodexCacheKey: vi.fn(async () => {}), + }, +})); + +vi.mock("@/lib/rate-limit", () => ({ + RateLimitService: { + trackCost: vi.fn(), + trackUserDailyCost: vi.fn(), + }, +})); + +vi.mock("@/lib/proxy-status-tracker", () => ({ + ProxyStatusTracker: { + getInstance: () => ({ + endRequest: vi.fn(), + }), + }, +})); + +import { finalizeRequestStats } from "@/app/v1/_lib/proxy/response-handler"; +import { ProxySession } from "@/app/v1/_lib/proxy/session"; +import { RateLimitService } from "@/lib/rate-limit"; +import { updateMessageRequestCost } from "@/repository/message"; +import { findLatestPriceByModel } from "@/repository/model-price"; +import { getSystemSettings } from "@/repository/system-config"; + +function makeSystemSettings( + billingModelSource: SystemSettings["billingModelSource"] +): SystemSettings { + const now = new Date(); + return { + id: 1, + siteTitle: "test", + allowGlobalUsageView: false, + currencyDisplay: "USD", + billingModelSource, + enableAutoCleanup: false, + cleanupRetentionDays: 30, + cleanupSchedule: "0 2 * * *", + cleanupBatchSize: 10000, + enableClientVersionCheck: false, + verboseProviderError: false, + enableHttp2: false, + interceptAnthropicWarmupRequests: false, + enableResponseFixer: true, + responseFixerConfig: { + fixTruncatedJson: true, + fixSseFormat: true, + fixEncoding: true, + maxJsonDepth: 200, + maxFixSize: 1024 * 1024, + }, + createdAt: now, + updatedAt: now, + }; +} + +function createSession({ + originalModel, + redirectedModel, +}: { + originalModel: string; + redirectedModel: string; +}): ProxySession { + const session = new ( + ProxySession as unknown as { + new (init: { + startTime: number; + method: string; + requestUrl: URL; + headers: Headers; + headerLog: string; + request: { message: Record; log: string; model: string | null }; + userAgent: string | null; + context: unknown; + clientAbortSignal: AbortSignal | null; + }): ProxySession; + } + )({ + startTime: Date.now(), + method: "POST", + requestUrl: new URL("http://localhost/v1/messages"), + headers: new Headers(), + headerLog: "", + request: { message: {}, log: "(test)", model: redirectedModel }, + userAgent: null, + context: {}, + clientAbortSignal: null, + }); + + session.setOriginalModel(originalModel); + session.setSessionId("sess-test"); + + const provider = { + id: 99, + name: "test-provider", + providerType: "claude", + costMultiplier: 1.0, + streamingIdleTimeoutMs: 0, + } as any; + + const user = { + id: 123, + name: "test-user", + dailyResetTime: "00:00", + dailyResetMode: "fixed", + } as any; + + const key = { + id: 456, + name: "test-key", + dailyResetTime: "00:00", + dailyResetMode: "fixed", + } as any; + + session.setProvider(provider); + session.setAuthState({ + user, + key, + apiKey: "sk-test", + success: true, + }); + session.setMessageContext({ + id: 2000, + createdAt: new Date(), + user, + key, + apiKey: "sk-test", + }); + + return session; +} + +describe("价格表缺失/查询失败:请求不计费且不报错", () => { + beforeEach(() => { + cloudSyncRequests.splice(0, cloudSyncRequests.length); + vi.clearAllMocks(); + }); + + it("无价格:应跳过 DB cost 更新与限流 cost 追踪,并触发异步同步", async () => { + vi.mocked(getSystemSettings).mockResolvedValue(makeSystemSettings("redirected")); + vi.mocked(findLatestPriceByModel).mockResolvedValue(null); + + const session = createSession({ originalModel: "m1", redirectedModel: "m2" }); + const responseText = JSON.stringify({ + type: "message", + usage: { input_tokens: 2, output_tokens: 3 }, + }); + await finalizeRequestStats(session, responseText, 200, 5); + + expect(updateMessageRequestCost).not.toHaveBeenCalled(); + expect(RateLimitService.trackCost).not.toHaveBeenCalled(); + expect(findLatestPriceByModel).toHaveBeenCalled(); + expect(cloudSyncRequests).toEqual([{ reason: "missing-model" }]); + }); + + it("价格数据为空对象:应视为无价格并触发异步同步", async () => { + vi.mocked(getSystemSettings).mockResolvedValue(makeSystemSettings("redirected")); + vi.mocked(findLatestPriceByModel).mockImplementation(async (modelName: string) => { + if (modelName === "m2") { + return { + id: 1, + modelName: "m2", + priceData: {}, + source: "litellm", + createdAt: new Date(), + updatedAt: new Date(), + } as any; + } + return null; + }); + + const session = createSession({ originalModel: "m1", redirectedModel: "m2" }); + const responseText = JSON.stringify({ + type: "message", + usage: { input_tokens: 2, output_tokens: 3 }, + }); + await finalizeRequestStats(session, responseText, 200, 5); + + expect(updateMessageRequestCost).not.toHaveBeenCalled(); + expect(RateLimitService.trackCost).not.toHaveBeenCalled(); + expect(cloudSyncRequests).toEqual([{ reason: "missing-model" }]); + }); + + it("价格查询抛错:应跳过计费且不影响响应", async () => { + vi.mocked(getSystemSettings).mockResolvedValue(makeSystemSettings("original")); + vi.mocked(findLatestPriceByModel).mockImplementation(async () => { + throw new Error("db query failed"); + }); + + const session = createSession({ originalModel: "m1", redirectedModel: "m2" }); + const responseText = JSON.stringify({ + type: "message", + usage: { input_tokens: 2, output_tokens: 3 }, + }); + await finalizeRequestStats(session, responseText, 200, 5); + + expect(updateMessageRequestCost).not.toHaveBeenCalled(); + expect(RateLimitService.trackCost).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/settings/prices/price-list-zero-price-ui.test.tsx b/tests/unit/settings/prices/price-list-zero-price-ui.test.tsx new file mode 100644 index 000000000..cd56a8968 --- /dev/null +++ b/tests/unit/settings/prices/price-list-zero-price-ui.test.tsx @@ -0,0 +1,85 @@ +/** + * @vitest-environment happy-dom + */ + +import fs from "node:fs"; +import path from "node:path"; +import type { ReactNode } from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { NextIntlClientProvider } from "next-intl"; +import { describe, expect, test } from "vitest"; +import { PriceList } from "@/app/[locale]/settings/prices/_components/price-list"; +import type { ModelPrice } from "@/types/model-price"; + +function loadMessages() { + const base = path.join(process.cwd(), "messages/en"); + const read = (name: string) => JSON.parse(fs.readFileSync(path.join(base, name), "utf8")); + + return { + common: read("common.json"), + errors: read("errors.json"), + ui: read("ui.json"), + forms: read("forms.json"), + settings: read("settings.json"), + }; +} + +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(); + }, + }; +} + +describe("PriceList: formatPrice 应正确处理 0", () => { + test("input/output 为 0 时应显示 0 而非占位符", () => { + const messages = loadMessages(); + const now = new Date("2026-01-01T00:00:00.000Z"); + + const prices: ModelPrice[] = [ + { + id: 1, + modelName: "zero-model", + priceData: { + mode: "chat", + display_name: "Zero Model", + input_cost_per_token: 0, + output_cost_per_token: 0, + }, + source: "litellm", + createdAt: now, + updatedAt: now, + }, + ]; + + const { unmount } = render( + + + + ); + + expect(document.body.textContent).toContain("$0.0000/M"); + expect(document.body.textContent).not.toContain("$-/M"); + + unmount(); + }); +}); From 9901cffda7f11fb4fcebec47d7d8fc2e34ce30c6 Mon Sep 17 00:00:00 2001 From: YangQing-Lin <56943790+YangQing-Lin@users.noreply.github.com> Date: Sun, 11 Jan 2026 00:58:16 +0800 Subject: [PATCH 12/17] Fix/logs provider badge overflow (#581) * fix: prevent provider badge overflow in logs table * fix: make provider chain trigger shrinkable * fix: hide invalid cost multiplier badge * fix: avoid invalid multiplier in usage logs * fix: hide invalid multiplier in error details * test: add provider chain popover layout regression * test: cover invalid multiplier rendering branches * test: cover empty and undefined cost multiplier --- .../_components/error-details-dialog.test.tsx | 375 ++++++++++++++++++ .../logs/_components/error-details-dialog.tsx | 24 +- .../provider-chain-popover.test.tsx | 149 +++++++ .../_components/provider-chain-popover.tsx | 8 +- .../_components/usage-logs-table.test.tsx | 184 +++++++++ .../logs/_components/usage-logs-table.tsx | 187 ++++----- .../virtualized-logs-table.test.tsx | 280 +++++++++++++ .../_components/virtualized-logs-table.tsx | 37 +- 8 files changed, 1102 insertions(+), 142 deletions(-) create mode 100644 src/app/[locale]/dashboard/logs/_components/provider-chain-popover.test.tsx create mode 100644 src/app/[locale]/dashboard/logs/_components/usage-logs-table.test.tsx create mode 100644 src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.test.tsx diff --git a/src/app/[locale]/dashboard/logs/_components/error-details-dialog.test.tsx b/src/app/[locale]/dashboard/logs/_components/error-details-dialog.test.tsx index 823386ded..1f876fff7 100644 --- a/src/app/[locale]/dashboard/logs/_components/error-details-dialog.test.tsx +++ b/src/app/[locale]/dashboard/logs/_components/error-details-dialog.test.tsx @@ -1,5 +1,7 @@ import type { ReactNode } from "react"; import { renderToStaticMarkup } from "react-dom/server"; +import { createRoot } from "react-dom/client"; +import { act } from "react"; import { NextIntlClientProvider } from "next-intl"; import { Window } from "happy-dom"; import { describe, expect, test, vi } from "vitest"; @@ -55,6 +57,10 @@ vi.mock("@/components/ui/dialog", () => { }; }); +vi.mock("@/lib/utils/provider-chain-formatter", () => ({ + formatProviderTimeline: () => ({ timeline: "timeline", totalDuration: 123 }), +})); + import { ErrorDetailsDialog } from "./error-details-dialog"; const messages = { @@ -70,6 +76,28 @@ const messages = { processing: "Processing", success: "Success", error: "Error", + skipped: { + title: "Skipped", + reason: "Reason", + warmup: "Warmup", + desc: "Warmup skipped", + }, + blocked: { + title: "Blocked", + type: "Type", + sensitiveWord: "Sensitive word", + word: "Word", + matchType: "Match type", + matchTypeContains: "Contains", + matchTypeExact: "Exact", + matchTypeRegex: "Regex", + matchedText: "Matched text", + }, + modelRedirect: { + title: "Model redirect", + billingOriginal: "Billing original", + billingRedirected: "Billing redirected", + }, specialSettings: { title: "Special settings", }, @@ -87,6 +115,16 @@ const messages = { success: "No error (success)", default: "No error", }, + errorMessage: "Error message", + filteredProviders: "Filtered providers", + providerChain: { + title: "Provider chain", + totalDuration: "Total duration: {duration}", + }, + reasons: { + rateLimited: "Rate limited", + circuitOpen: "Circuit open", + }, }, billingDetails: { input: "Input", @@ -280,4 +318,341 @@ describe("error-details-dialog layout", () => { "md:grid-cols-2" ); }); + + test("uses gray status class for unexpected statusCode (e.g., 100)", () => { + const html = renderWithIntl( + + ); + + expect(html).toContain("bg-gray-100"); + }); + + test("covers 3xx and 4xx status badge classes", () => { + const html3xx = renderWithIntl( + + ); + expect(html3xx).toContain("bg-blue-100"); + + const html4xx = renderWithIntl( + + ); + expect(html4xx).toContain("bg-yellow-100"); + }); + + test("covers in-progress state when statusCode is null", () => { + const html = renderWithIntl( + + ); + + expect(html).toContain("In progress"); + expect(html).toContain("Processing"); + }); + + test("renders filtered providers and provider chain timeline when present", () => { + const html = renderWithIntl( + + ); + + expect(html).toContain("Filtered providers"); + expect(html).toContain("filtered-provider"); + expect(html).toContain("Provider chain"); + expect(html).toContain("timeline"); + expect(html).toContain("Total duration"); + }); + + test("formats JSON rate limit error message and filtered providers", () => { + const html = renderWithIntl( + + ); + + expect(html).toContain("Error message"); + expect(html).toContain("Rate limited"); + expect(html).toContain("p"); + expect(html).toContain("$1"); + }); + + test("formats non-rate-limit JSON error as pretty JSON", () => { + const html = renderWithIntl( + + ); + + expect(html).toContain("Error message"); + expect(html).toContain(""error""); + }); + + test("falls back to raw error message when it is not JSON", () => { + const html = renderWithIntl( + + ); + + expect(html).toContain("Error message"); + expect(html).toContain("not-json"); + }); + + test("renders warmup skipped and blocked sections when applicable", () => { + const html = renderWithIntl( + + ); + expect(html).toContain("Skipped"); + expect(html).toContain("Warmup"); + + const htmlBlocked = renderWithIntl( + + ); + expect(htmlBlocked).toContain("Blocked"); + expect(htmlBlocked).toContain("Sensitive word"); + expect(htmlBlocked).toContain("bad"); + expect(htmlBlocked).toContain("Contains"); + }); + + test("renders model redirect section when originalModel != currentModel", () => { + const html = renderWithIntl( + + ); + + expect(html).toContain("Model redirect"); + expect(html).toContain("m1"); + expect(html).toContain("m2"); + expect(html).toContain("Billing original"); + }); + + test("scrolls to model redirect section when scrollToRedirect is true", async () => { + vi.useFakeTimers(); + const container = document.createElement("div"); + document.body.appendChild(container); + + const scrollIntoViewMock = vi.fn(); + const originalScrollIntoView = Element.prototype.scrollIntoView; + Object.defineProperty(Element.prototype, "scrollIntoView", { + value: scrollIntoViewMock, + configurable: true, + }); + + const root = createRoot(container); + await act(async () => { + root.render( + + + + ); + }); + + await act(async () => { + vi.advanceTimersByTime(150); + }); + + expect(scrollIntoViewMock).toHaveBeenCalled(); + + await act(async () => { + root.unmount(); + }); + + Object.defineProperty(Element.prototype, "scrollIntoView", { + value: originalScrollIntoView, + configurable: true, + }); + vi.useRealTimers(); + container.remove(); + }); +}); + +describe("error-details-dialog multiplier", () => { + test("does not render multiplier row when costMultiplier is empty string", () => { + const html = renderWithIntl( + + ); + + expect(html).not.toContain("Multiplier"); + }); + + test("does not render multiplier row when costMultiplier is undefined", () => { + const html = renderWithIntl( + + ); + + expect(html).not.toContain("Multiplier"); + }); + + test("does not render multiplier row when costMultiplier is NaN", () => { + const html = renderWithIntl( + + ); + + expect(html).not.toContain("Multiplier"); + expect(html).not.toContain("NaN"); + }); + + test("does not render multiplier row when costMultiplier is Infinity", () => { + const html = renderWithIntl( + + ); + + expect(html).not.toContain("Multiplier"); + expect(html).not.toContain("Infinity"); + }); + + test("renders multiplier row when costMultiplier is finite and != 1", () => { + const html = renderWithIntl( + + ); + + expect(html).toContain("Multiplier"); + expect(html).toContain("0.20x"); + }); }); diff --git a/src/app/[locale]/dashboard/logs/_components/error-details-dialog.tsx b/src/app/[locale]/dashboard/logs/_components/error-details-dialog.tsx index fa113cf57..a2438256b 100644 --- a/src/app/[locale]/dashboard/logs/_components/error-details-dialog.tsx +++ b/src/app/[locale]/dashboard/logs/_components/error-details-dialog.tsx @@ -536,16 +536,20 @@ export function ErrorDetailsDialog({ )} - {costMultiplier && parseFloat(String(costMultiplier)) !== 1.0 && ( -
- - {t("logs.billingDetails.multiplier")}: - - - {parseFloat(String(costMultiplier)).toFixed(2)}x - -
- )} + {(() => { + if (costMultiplier === "" || costMultiplier == null) return null; + const multiplier = Number(costMultiplier); + if (!Number.isFinite(multiplier) || multiplier === 1) return null; + + return ( +
+ + {t("logs.billingDetails.multiplier")}: + + {multiplier.toFixed(2)}x +
+ ); + })()}
{t("logs.billingDetails.totalCost")}: diff --git a/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.test.tsx b/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.test.tsx new file mode 100644 index 000000000..dd1ad20e2 --- /dev/null +++ b/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.test.tsx @@ -0,0 +1,149 @@ +import type { ReactNode } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { NextIntlClientProvider } from "next-intl"; +import { Window } from "happy-dom"; +import { describe, expect, test, vi } from "vitest"; + +vi.mock("@/lib/utils/provider-chain-formatter", () => ({ + formatProviderDescription: () => "provider description", +})); + +vi.mock("@/components/ui/tooltip", () => { + type PropsWithChildren = { children?: ReactNode }; + + function TooltipProvider({ children }: PropsWithChildren) { + return
{children}
; + } + + function Tooltip({ children }: PropsWithChildren) { + return
{children}
; + } + + function TooltipTrigger({ children }: PropsWithChildren) { + return
{children}
; + } + + function TooltipContent({ children }: PropsWithChildren) { + return
{children}
; + } + + return { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent }; +}); + +vi.mock("@/components/ui/popover", () => { + type PropsWithChildren = { children?: ReactNode }; + + function Popover({ children }: PropsWithChildren) { + return
{children}
; + } + + function PopoverTrigger({ children }: PropsWithChildren) { + return
{children}
; + } + + function PopoverContent({ children }: PropsWithChildren) { + return
{children}
; + } + + return { Popover, PopoverTrigger, PopoverContent }; +}); + +vi.mock("@/components/ui/button", () => ({ + Button: ({ + children, + className, + ...props + }: React.ComponentProps<"button"> & { variant?: string }) => ( + + ), +})); + +vi.mock("@/components/ui/badge", () => ({ + Badge: ({ children, className }: React.ComponentProps<"span"> & { variant?: string }) => ( + + {children} + + ), +})); + +import { ProviderChainPopover } from "./provider-chain-popover"; + +const messages = { + dashboard: { + logs: { + table: { + times: "times", + }, + providerChain: { + decisionChain: "Decision chain", + }, + details: { + clickStatusCode: "Click status code", + }, + }, + }, + "provider-chain": {}, +}; + +function renderWithIntl(node: ReactNode) { + return renderToStaticMarkup( + +
{node}
+
+ ); +} + +function parseHtml(html: string) { + const window = new Window(); + window.document.body.innerHTML = html; + return window.document; +} + +describe("provider-chain-popover layout", () => { + test("requestCount<=1 branch keeps truncation container shrinkable", () => { + const html = renderWithIntl( + + ); + const document = parseHtml(html); + + const container = document.querySelector("#root > div"); + const containerClass = container?.getAttribute("class") ?? ""; + expect(containerClass).toContain("min-w-0"); + expect(containerClass).toContain("w-full"); + + const truncateNode = document.querySelector("#root span.truncate"); + expect(truncateNode).not.toBeNull(); + }); + + test("requestCount>1 branch uses w-full/min-w-0 button and flex-1 name container", () => { + const html = renderWithIntl( + + ); + const document = parseHtml(html); + + const button = document.querySelector("#root button"); + expect(button).not.toBeNull(); + const buttonClass = button?.getAttribute("class") ?? ""; + expect(buttonClass).toContain("w-full"); + expect(buttonClass).toContain("min-w-0"); + + const nameContainer = document.querySelector("#root button .flex-1.min-w-0"); + expect(nameContainer).not.toBeNull(); + + const countBadge = Array.from(document.querySelectorAll('#root [data-slot="badge"]')).find( + (node) => (node.getAttribute("class") ?? "").includes("ml-1") + ); + expect(countBadge).not.toBeUndefined(); + }); +}); diff --git a/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.tsx b/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.tsx index cf8a2fa1e..f3b0e20a6 100644 --- a/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.tsx +++ b/src/app/[locale]/dashboard/logs/_components/provider-chain-popover.tsx @@ -55,7 +55,7 @@ export function ProviderChainPopover({ // 如果只有一次请求,不显示 popover,只显示带 Tooltip 的名称 if (requestCount <= 1) { return ( -
+
@@ -78,11 +78,11 @@ export function ProviderChainPopover({ + ), +})); + +vi.mock("./error-details-dialog", () => ({ + ErrorDetailsDialog: () =>
, +})); + +import { UsageLogsTable } from "./usage-logs-table"; + +function makeLog(overrides: Partial): UsageLogRow { + return { + id: 1, + createdAt: new Date(), + sessionId: null, + requestSequence: null, + userName: "u", + keyName: "k", + providerName: "p", + model: "m", + originalModel: null, + endpoint: "/v1/messages", + statusCode: 200, + inputTokens: 1, + outputTokens: 1, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cacheCreation5mInputTokens: 0, + cacheCreation1hInputTokens: 0, + cacheTtlApplied: null, + totalTokens: 2, + costUsd: "0.01", + costMultiplier: null, + durationMs: 100, + ttfbMs: 50, + errorMessage: null, + providerChain: null, + blockedBy: null, + blockedReason: null, + userAgent: null, + messagesCount: null, + context1mApplied: null, + specialSettings: null, + ...overrides, + }; +} + +describe("usage-logs-table multiplier badge", () => { + test("does not render multiplier badge for null/undefined/empty/NaN/Infinity", () => { + for (const costMultiplier of [null, undefined, "", "NaN", "Infinity"] as const) { + const html = renderToStaticMarkup( + {}} + isPending={false} + /> + ); + + expect(html).not.toContain("×0.00"); + expect(html).not.toContain("×NaN"); + expect(html).not.toContain("×Infinity"); + } + }); + + test("renders multiplier badge when finite and != 1", () => { + const html = renderToStaticMarkup( + {}} + isPending={false} + /> + ); + + expect(html).toContain("×0.20"); + expect(html).toContain("0.20x"); + }); + + test("renders warmup skipped and blocked labels", () => { + const htmlWarmup = renderToStaticMarkup( + {}} + isPending={false} + /> + ); + expect(htmlWarmup).toContain("logs.table.skipped"); + + const htmlBlocked = renderToStaticMarkup( + {}} + isPending={false} + /> + ); + expect(htmlBlocked).toContain("logs.table.blocked"); + }); + + test("invokes model redirect and pagination callbacks", async () => { + const onPageChange = vi.fn(); + const container = document.createElement("div"); + document.body.appendChild(container); + + const root = createRoot(container); + await act(async () => { + root.render( + + ); + }); + + // Trigger model redirect click (covers onRedirectClick handler) + const redirectButton = container.querySelector('button[data-slot="model-redirect"]'); + expect(redirectButton).not.toBeNull(); + await act(async () => { + redirectButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + // Trigger pagination (covers onClick handlers) + const nextButton = Array.from(container.querySelectorAll("button")).find((b) => + (b.textContent ?? "").includes("logs.table.nextPage") + ); + expect(nextButton).not.toBeUndefined(); + await act(async () => { + nextButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(onPageChange).toHaveBeenCalledWith(2); + + await act(async () => { + root.unmount(); + }); + container.remove(); + }); +}); diff --git a/src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx b/src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx index d3db4acbd..b620cfa3d 100644 --- a/src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx +++ b/src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx @@ -93,6 +93,26 @@ export function UsageLogsTable({ const isWarmupSkipped = log.blockedBy === "warmup"; const isMutedRow = isNonBilling || isWarmupSkipped; + // 计算倍率(用于 Provider 列 Badge 和成本明细) + const successfulProvider = + log.providerChain && log.providerChain.length > 0 + ? [...log.providerChain] + .reverse() + .find( + (item) => + item.reason === "request_success" || item.reason === "retry_success" + ) + : null; + + const actualCostMultiplier = + successfulProvider?.costMultiplier ?? log.costMultiplier; + const multiplier = + actualCostMultiplier === "" || actualCostMultiplier == null + ? null + : Number(actualCostMultiplier); + const hasCostBadge = + multiplier != null && Number.isFinite(multiplier) && multiplier !== 1; + return (
- {(() => { - // 计算倍率,用于判断是否显示 Badge - const successfulProvider = - log.providerChain && log.providerChain.length > 0 - ? [...log.providerChain] - .reverse() - .find( - (item) => - item.reason === "request_success" || - item.reason === "retry_success" - ) - : null; - const actualCostMultiplier = - successfulProvider?.costMultiplier ?? log.costMultiplier; - const hasCostBadge = - !!actualCostMultiplier && - parseFloat(String(actualCostMultiplier)) !== 1.0; - - return ( - <> -
- 0 - ? log.providerChain[log.providerChain.length - 1].name - : null) || - log.providerName || - tChain("circuit.unknown") - } - hasCostBadge={hasCostBadge} - /> -
- {/* 摘要文字(第二行显示,左对齐) */} - {log.providerChain && - log.providerChain.length > 0 && - formatProviderSummary(log.providerChain, tChain) && ( -
- - - - - {formatProviderSummary(log.providerChain, tChain)} - - - -

- {formatProviderSummary(log.providerChain, tChain)} -

-
-
-
-
- )} - - ); - })()} +
+ 0 + ? log.providerChain[log.providerChain.length - 1].name + : null) || + log.providerName || + tChain("circuit.unknown") + } + hasCostBadge={hasCostBadge} + /> +
+ {/* 摘要文字(第二行显示,左对齐) */} + {log.providerChain && + log.providerChain.length > 0 && + formatProviderSummary(log.providerChain, tChain) && ( +
+ + + + + {formatProviderSummary(log.providerChain, tChain)} + + + +

+ {formatProviderSummary(log.providerChain, tChain)} +

+
+
+
+
+ )}
{/* 显示供应商倍率 Badge(不为 1.0 时) */} - {(() => { - // 从决策链中找到最后一个成功的供应商,使用它的倍率 - const successfulProvider = - log.providerChain && log.providerChain.length > 0 - ? [...log.providerChain] - .reverse() - .find( - (item) => - item.reason === "request_success" || - item.reason === "retry_success" - ) - : null; - - const actualCostMultiplier = - successfulProvider?.costMultiplier ?? log.costMultiplier; - - return actualCostMultiplier && - parseFloat(String(actualCostMultiplier)) !== 1.0 ? ( - 1.0 - ? "text-xs bg-orange-50 text-orange-700 border-orange-200 dark:bg-orange-950/30 dark:text-orange-300 dark:border-orange-800 shrink-0" - : "text-xs bg-green-50 text-green-700 border-green-200 dark:bg-green-950/30 dark:text-green-300 dark:border-green-800 shrink-0" - } - > - ×{parseFloat(String(actualCostMultiplier)).toFixed(2)} - - ) : null; - })()} + {hasCostBadge && multiplier != null ? ( + 1 + ? "text-xs bg-orange-50 text-orange-700 border-orange-200 dark:bg-orange-950/30 dark:text-orange-300 dark:border-orange-800 shrink-0" + : "text-xs bg-green-50 text-green-700 border-green-200 dark:bg-green-950/30 dark:text-green-300 dark:border-green-800 shrink-0" + } + > + ×{multiplier.toFixed(2)} + + ) : null}
)} @@ -391,27 +370,11 @@ export function UsageLogsTable({ {formatTokenAmount(log.cacheReadInputTokens)} tokens (0.1x)
)} - {(() => { - const successfulProvider = - log.providerChain && log.providerChain.length > 0 - ? [...log.providerChain] - .reverse() - .find( - (item) => - item.reason === "request_success" || - item.reason === "retry_success" - ) - : null; - const actualCostMultiplier = - successfulProvider?.costMultiplier ?? log.costMultiplier; - return actualCostMultiplier && - parseFloat(String(actualCostMultiplier)) !== 1.0 ? ( -
- {t("logs.billingDetails.multiplier")}:{" "} - {parseFloat(String(actualCostMultiplier)).toFixed(2)}x -
- ) : null; - })()} + {hasCostBadge && multiplier != null ? ( +
+ {t("logs.billingDetails.multiplier")}: {multiplier.toFixed(2)}x +
+ ) : null} diff --git a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.test.tsx b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.test.tsx new file mode 100644 index 000000000..342b3d971 --- /dev/null +++ b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.test.tsx @@ -0,0 +1,280 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import type { ReactNode } from "react"; +import { createRoot } from "react-dom/client"; +import { act } from "react"; +import { describe, expect, test, vi } from "vitest"; + +import type { UsageLogRow } from "@/repository/usage-logs"; + +let mockLogs: UsageLogRow[] = []; +let mockIsLoading = false; +let mockIsError = false; +let mockError: unknown = null; +let mockHasNextPage = false; +let mockIsFetchingNextPage = false; + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); + +vi.mock("@tanstack/react-query", () => ({ + useInfiniteQuery: () => ({ + data: { pages: [{ logs: mockLogs, nextCursor: null, hasMore: false }] }, + fetchNextPage: vi.fn(), + hasNextPage: mockHasNextPage, + isFetchingNextPage: mockIsFetchingNextPage, + isLoading: mockIsLoading, + isError: mockIsError, + error: mockError, + }), +})); + +vi.mock("@/hooks/use-virtualizer", () => ({ + useVirtualizer: () => ({ + getTotalSize: () => mockLogs.length * 52, + getVirtualItems: () => [ + ...mockLogs.map((_, index) => ({ + index, + start: index * 52, + size: 52, + })), + ...(mockHasNextPage + ? [ + { + index: mockLogs.length, + start: mockLogs.length * 52, + size: 52, + }, + ] + : []), + ], + }), +})); + +vi.mock("@/lib/utils/provider-chain-formatter", () => ({ + formatProviderSummary: () => "provider summary", +})); + +vi.mock("@/actions/usage-logs", () => ({ + getUsageLogsBatch: vi.fn(), +})); + +vi.mock("@/components/ui/tooltip", () => ({ + TooltipProvider: ({ children }: { children?: ReactNode }) =>
{children}
, + Tooltip: ({ children }: { children?: ReactNode }) =>
{children}
, + TooltipTrigger: ({ children }: { children?: ReactNode }) =>
{children}
, + TooltipContent: ({ children }: { children?: ReactNode }) =>
{children}
, +})); + +vi.mock("@/components/ui/button", () => ({ + Button: ({ children, className, ...props }: React.ComponentProps<"button">) => ( + + ), +})); + +vi.mock("@/components/ui/badge", () => ({ + Badge: ({ children, className }: React.ComponentProps<"span">) => ( + {children} + ), +})); + +vi.mock("@/components/ui/relative-time", () => ({ + RelativeTime: ({ fallback }: { fallback: string }) => {fallback}, +})); + +vi.mock("./model-display-with-redirect", () => ({ + ModelDisplayWithRedirect: ({ currentModel }: { currentModel: string | null }) => ( + {currentModel ?? "-"} + ), +})); + +vi.mock("./error-details-dialog", () => ({ + ErrorDetailsDialog: () =>
, +})); + +import { VirtualizedLogsTable } from "./virtualized-logs-table"; + +function makeLog(overrides: Partial): UsageLogRow { + return { + id: 1, + createdAt: new Date(), + sessionId: null, + requestSequence: null, + userName: "u", + keyName: "k", + providerName: "p", + model: "m", + originalModel: null, + endpoint: "/v1/messages", + statusCode: 200, + inputTokens: 1, + outputTokens: 1, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cacheCreation5mInputTokens: 0, + cacheCreation1hInputTokens: 0, + cacheTtlApplied: null, + totalTokens: 2, + costUsd: "0.01", + costMultiplier: null, + durationMs: 100, + ttfbMs: 50, + errorMessage: null, + providerChain: null, + blockedBy: null, + blockedReason: null, + userAgent: null, + messagesCount: null, + context1mApplied: null, + specialSettings: null, + ...overrides, + }; +} + +describe("virtualized-logs-table multiplier badge", () => { + test("renders loading/error/empty states", () => { + mockIsError = false; + mockError = null; + mockHasNextPage = false; + mockIsFetchingNextPage = false; + + mockIsLoading = true; + mockLogs = []; + expect( + renderToStaticMarkup() + ).toContain("logs.stats.loading"); + + mockIsLoading = false; + mockIsError = true; + mockError = new Error("boom"); + expect( + renderToStaticMarkup() + ).toContain("boom"); + + mockIsError = false; + mockError = null; + mockLogs = []; + expect( + renderToStaticMarkup() + ).toContain("logs.table.noData"); + }); + + test("does not render cost multiplier badge for null/undefined/empty/NaN/Infinity", () => { + mockIsLoading = false; + mockIsError = false; + mockError = null; + mockHasNextPage = false; + mockIsFetchingNextPage = false; + + for (const costMultiplier of [null, undefined, "", "NaN", "Infinity"] as const) { + mockLogs = [makeLog({ id: 1, costMultiplier })]; + const html = renderToStaticMarkup( + + ); + expect(html).not.toContain("xNaN"); + expect(html).not.toContain("xInfinity"); + expect(html).not.toContain("x0.00"); + } + }); + + test("renders cost multiplier badge when finite and != 1", () => { + mockIsLoading = false; + mockIsError = false; + mockError = null; + mockHasNextPage = false; + mockIsFetchingNextPage = false; + + mockLogs = [makeLog({ id: 1, costMultiplier: "0.2" })]; + const html = renderToStaticMarkup( + + ); + expect(html).toContain("x0.20"); + }); + + test("shows scroll-to-top button after scroll and triggers scrollTo", async () => { + mockIsLoading = false; + mockIsError = false; + mockError = null; + mockHasNextPage = false; + mockIsFetchingNextPage = false; + mockLogs = [makeLog({ id: 1, costMultiplier: null })]; + + const container = document.createElement("div"); + document.body.appendChild(container); + + const root = createRoot(container); + await act(async () => { + root.render(); + }); + + const scroller = container.querySelector( + "div.h-\\[600px\\].overflow-auto" + ) as HTMLDivElement | null; + expect(scroller).not.toBeNull(); + + if (scroller) { + // happy-dom may not implement scrollTo; stub for assertion + const scrollToMock = vi.fn(); + (scroller as unknown as { scrollTo: typeof scrollToMock }).scrollTo = scrollToMock; + await act(async () => { + scroller.scrollTop = 600; + scroller.dispatchEvent(new Event("scroll")); + }); + + expect(container.innerHTML).toContain("logs.table.scrollToTop"); + + const button = container.querySelector("button.fixed") as HTMLButtonElement | null; + expect(button).not.toBeNull(); + await act(async () => { + button?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(scrollToMock).toHaveBeenCalled(); + } + + await act(async () => { + root.unmount(); + }); + container.remove(); + }); + + test("renders blocked badge and loader row when applicable", () => { + mockIsLoading = false; + mockIsError = false; + mockError = null; + mockHasNextPage = true; + mockIsFetchingNextPage = false; + + mockLogs = [makeLog({ id: 1, blockedBy: "sensitive_word" })]; + const html = renderToStaticMarkup( + + ); + expect(html).toContain("logs.table.blocked"); + + // Loader row should render when hasNextPage=true + expect(html).toContain("animate-spin"); + }); + + test("renders provider summary and fetching state when enabled", () => { + mockIsLoading = false; + mockIsError = false; + mockError = null; + mockHasNextPage = true; + mockIsFetchingNextPage = true; + + mockLogs = [ + makeLog({ + id: 1, + costMultiplier: null, + providerChain: [{ id: 1, name: "p1", reason: "request_success", statusCode: 200 }], + }), + ]; + + const html = renderToStaticMarkup( + + ); + expect(html).toContain("provider summary"); + expect(html).toContain("logs.table.loadingMore"); + }); +}); diff --git a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx index 2638493fe..52f07e023 100644 --- a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx +++ b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx @@ -295,7 +295,7 @@ export function VirtualizedLogsTable({ ) : (
-
+
{(() => { // 计算倍率,用于判断是否显示 Badge const successfulProvider = @@ -310,34 +310,39 @@ export function VirtualizedLogsTable({ : null; const actualCostMultiplier = successfulProvider?.costMultiplier ?? log.costMultiplier; + const multiplier = Number(actualCostMultiplier); const hasCostBadge = - !!actualCostMultiplier && - parseFloat(String(actualCostMultiplier)) !== 1.0; + actualCostMultiplier !== "" && + actualCostMultiplier != null && + Number.isFinite(multiplier) && + multiplier !== 1; return ( <> - 0 - ? log.providerChain[log.providerChain.length - 1].name - : null) || - log.providerName || - tChain("circuit.unknown") - } - hasCostBadge={hasCostBadge} - /> +
+ 0 + ? log.providerChain[log.providerChain.length - 1].name + : null) || + log.providerName || + tChain("circuit.unknown") + } + hasCostBadge={hasCostBadge} + /> +
{/* Cost multiplier badge */} {hasCostBadge && ( 1.0 + multiplier > 1 ? "text-xs bg-orange-50 text-orange-700 border-orange-200 dark:bg-orange-950/30 dark:text-orange-300 dark:border-orange-800 shrink-0" : "text-xs bg-green-50 text-green-700 border-green-200 dark:bg-green-950/30 dark:text-green-300 dark:border-green-800 shrink-0" } > - x{parseFloat(String(actualCostMultiplier)).toFixed(2)} + x{multiplier.toFixed(2)} )} From 55dfd2120f783cdef7dd6abe64c83ce1f5124902 Mon Sep 17 00:00:00 2001 From: Ding <44717411+ding113@users.noreply.github.com> Date: Sun, 11 Jan 2026 00:58:40 +0800 Subject: [PATCH 13/17] =?UTF-8?q?feat:=20=E4=BB=B7=E6=A0=BC=E8=A1=A8=20UI?= =?UTF-8?q?=20=E6=94=AF=E6=8C=81=E8=87=AA=E5=AE=9A=E4=B9=89=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B/=E7=BC=93=E5=AD=98=E4=BB=B7=E6=A0=BC=20(#583)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: update pricing table UI * chore: format code (feat-prices-ui-custom-model-6ffe413) * fix: address pricing UI review feedback --- messages/en/settings.json | 30 +- messages/ja/settings.json | 30 +- messages/ru/settings.json | 30 +- messages/zh-CN/settings.json | 30 +- messages/zh-TW/settings.json | 30 +- src/actions/model-prices.ts | 37 ++ .../prices/_components/model-price-drawer.tsx | 620 ++++++++++++++++++ .../prices/_components/price-list.tsx | 225 +++++-- .../_components/upload-price-dialog.tsx | 50 +- src/app/[locale]/settings/prices/page.tsx | 4 +- src/app/api/prices/cloud-model-count/route.ts | 27 + src/lib/utils/cost-calculation.ts | 12 + src/lib/utils/price-data.ts | 1 + src/types/model-price.ts | 1 + ...calculation-input-cost-per-request.test.ts | 62 ++ .../price-data-input-cost-per-request.test.ts | 42 ++ .../prices/delete-model-dialog.test.tsx | 162 +++++ ...rice-drawer-prefill-and-submit-ui.test.tsx | 316 +++++++++ .../prices/price-list-interactions.test.tsx | 385 +++++++++++ .../price-list-ui-requirements.test.tsx | 272 ++++++++ ...ad-price-dialog-cloud-model-count.test.tsx | 108 +++ .../upload-price-dialog-upload-flow.test.tsx | 427 ++++++++++++ 22 files changed, 2821 insertions(+), 80 deletions(-) create mode 100644 src/app/[locale]/settings/prices/_components/model-price-drawer.tsx create mode 100644 src/app/api/prices/cloud-model-count/route.ts create mode 100644 tests/unit/lib/cost-calculation-input-cost-per-request.test.ts create mode 100644 tests/unit/lib/price-data-input-cost-per-request.test.ts create mode 100644 tests/unit/settings/prices/delete-model-dialog.test.tsx create mode 100644 tests/unit/settings/prices/model-price-drawer-prefill-and-submit-ui.test.tsx create mode 100644 tests/unit/settings/prices/price-list-interactions.test.tsx create mode 100644 tests/unit/settings/prices/price-list-ui-requirements.test.tsx create mode 100644 tests/unit/settings/prices/upload-price-dialog-cloud-model-count.test.tsx create mode 100644 tests/unit/settings/prices/upload-price-dialog-upload-flow.test.tsx diff --git a/messages/en/settings.json b/messages/en/settings.json index 6a75d4652..55c879dbd 100644 --- a/messages/en/settings.json +++ b/messages/en/settings.json @@ -616,8 +616,17 @@ "modelName": "Model Name", "provider": "Provider", "capabilities": "Capabilities", + "price": "Price", "inputPrice": "Input Price ($/M)", "outputPrice": "Output Price ($/M)", + "priceInput": "In", + "priceOutput": "Out", + "pricePerRequest": "Req", + "cacheReadPrice": "Cache Read ($/M)", + "cacheCreationPrice": "Cache Create ($/M)", + "cache5m": "5m", + "cache1h": "1h+", + "copyModelId": "Copy model ID", "updatedAt": "Updated At", "actions": "Actions", "typeChat": "Chat", @@ -660,6 +669,8 @@ "manualDownload": "You can also manually download", "latestPriceTable": "cloud price table", "andUploadViaButton": ", and upload via button above", + "cloudModelCountLoading": "Loading cloud model count...", + "cloudModelCountFailed": "Failed to load cloud model count", "supportedModels": "Currently supports {count} models", "results": { "title": "Update Results", @@ -679,15 +690,28 @@ "editModelDescription": "Edit the model price configuration", "deleteConfirm": "Are you sure you want to delete model {name}? This action cannot be undone.", "form": { - "modelName": "Model Name", + "modelName": "Model ID", "modelNamePlaceholder": "e.g., gpt-5.2-codex", - "modelNameRequired": "Model name is required", + "modelNameRequired": "Model ID is required", + "displayName": "Display Name (Optional)", + "displayNamePlaceholder": "e.g., GPT-5.2 Codex", "type": "Type", "provider": "Provider", "providerPlaceholder": "e.g., openai", + "requestPrice": "Per-call Price ($/request)", "inputPrice": "Input Price ($/M tokens)", "outputPrice": "Output Price ($/M tokens)", - "outputPriceImage": "Output Price ($/image)" + "outputPriceImage": "Output Price ($/image)", + "cacheReadPrice": "Cache Read Price ($/M tokens)", + "cacheCreationPrice5m": "Cache Creation Price (5m, $/M tokens)", + "cacheCreationPrice1h": "Cache Creation Price (1h+, $/M tokens)" + }, + "drawer": { + "prefillLabel": "Search existing models to prefill", + "prefillEmpty": "No matching models found", + "prefillFailed": "Search failed", + "promptCachingHint": "Enable if the model supports prompt caching", + "cachePricingTitle": "Cache Pricing" }, "actions": { "edit": "Edit", diff --git a/messages/ja/settings.json b/messages/ja/settings.json index ba96490e7..01d0e9cab 100644 --- a/messages/ja/settings.json +++ b/messages/ja/settings.json @@ -607,8 +607,17 @@ "modelName": "モデル名", "provider": "プロバイダー", "capabilities": "機能", + "price": "価格", "inputPrice": "入力価格 ($/M)", "outputPrice": "出力価格 ($/M)", + "priceInput": "入力", + "priceOutput": "出力", + "pricePerRequest": "回", + "cacheReadPrice": "キャッシュ読み取り ($/M)", + "cacheCreationPrice": "キャッシュ作成 ($/M)", + "cache5m": "5m", + "cache1h": "1h+", + "copyModelId": "モデルIDをコピー", "updatedAt": "更新日時", "actions": "操作", "typeChat": "チャット", @@ -651,6 +660,8 @@ "manualDownload": "手動でダウンロードすることもできます", "latestPriceTable": "クラウド価格表", "andUploadViaButton": "、上のボタンでアップロードしてください", + "cloudModelCountLoading": "クラウドモデル数を読み込み中...", + "cloudModelCountFailed": "クラウドモデル数の読み込みに失敗しました", "supportedModels": "現在{count}個のモデルをサポート", "results": { "title": "更新結果", @@ -670,15 +681,28 @@ "editModelDescription": "モデルの価格設定を編集します", "deleteConfirm": "モデル {name} を削除してもよろしいですか?この操作は元に戻せません。", "form": { - "modelName": "モデル名", + "modelName": "モデルID", "modelNamePlaceholder": "例: gpt-5.2-codex", - "modelNameRequired": "モデル名は必須です", + "modelNameRequired": "モデルIDは必須です", + "displayName": "表示名(任意)", + "displayNamePlaceholder": "例: GPT-5.2 Codex", "type": "タイプ", "provider": "プロバイダー", "providerPlaceholder": "例: openai", + "requestPrice": "呼び出し単価 ($/request)", "inputPrice": "入力価格 ($/M tokens)", "outputPrice": "出力価格 ($/M tokens)", - "outputPriceImage": "出力価格 ($/image)" + "outputPriceImage": "出力価格 ($/image)", + "cacheReadPrice": "キャッシュ読み取り価格 ($/M tokens)", + "cacheCreationPrice5m": "キャッシュ作成価格(5m,$/M tokens)", + "cacheCreationPrice1h": "キャッシュ作成価格(1h+,$/M tokens)" + }, + "drawer": { + "prefillLabel": "既存モデルを検索してプリフィル", + "prefillEmpty": "一致するモデルが見つかりません", + "prefillFailed": "検索に失敗しました", + "promptCachingHint": "モデルがキャッシュに対応している場合のみ有効化し、下のキャッシュ価格を設定してください", + "cachePricingTitle": "キャッシュ価格" }, "actions": { "edit": "編集", diff --git a/messages/ru/settings.json b/messages/ru/settings.json index c610de547..97daad508 100644 --- a/messages/ru/settings.json +++ b/messages/ru/settings.json @@ -607,8 +607,17 @@ "modelName": "Название модели", "provider": "Поставщик", "capabilities": "Возможности", + "price": "Цена", "inputPrice": "Цена ввода ($/M)", "outputPrice": "Цена вывода ($/M)", + "priceInput": "Ввод", + "priceOutput": "Вывод", + "pricePerRequest": "Запрос", + "cacheReadPrice": "Чтение кэша ($/M)", + "cacheCreationPrice": "Создание кэша ($/M)", + "cache5m": "5m", + "cache1h": "1h+", + "copyModelId": "Скопировать ID модели", "updatedAt": "Обновлено", "actions": "Действия", "typeChat": "Чат", @@ -651,6 +660,8 @@ "manualDownload": "Вы также можете скачать вручную", "latestPriceTable": "облачный прайс-лист", "andUploadViaButton": ", и загрузить через кнопку выше", + "cloudModelCountLoading": "Загрузка количества моделей из облака...", + "cloudModelCountFailed": "Не удалось загрузить количество моделей из облака", "supportedModels": "Поддерживается {count} моделей", "results": { "title": "Результаты обновления", @@ -670,15 +681,28 @@ "editModelDescription": "Редактировать цену модели", "deleteConfirm": "Удалить модель {name}? Это действие необратимо.", "form": { - "modelName": "Название модели", + "modelName": "ID модели", "modelNamePlaceholder": "например: gpt-5.2-codex", - "modelNameRequired": "Название модели обязательно", + "modelNameRequired": "ID модели обязателен", + "displayName": "Отображаемое имя (необязательно)", + "displayNamePlaceholder": "например: GPT-5.2 Codex", "type": "Тип", "provider": "Поставщик", "providerPlaceholder": "например: openai", + "requestPrice": "Цена за вызов ($/request)", "inputPrice": "Цена ввода ($/M токенов)", "outputPrice": "Цена вывода ($/M токенов)", - "outputPriceImage": "Цена вывода ($/изображение)" + "outputPriceImage": "Цена вывода ($/изображение)", + "cacheReadPrice": "Цена чтения кэша ($/M токенов)", + "cacheCreationPrice5m": "Цена создания кэша (5m, $/M токенов)", + "cacheCreationPrice1h": "Цена создания кэша (1h+, $/M токенов)" + }, + "drawer": { + "prefillLabel": "Поиск существующих моделей для автозаполнения", + "prefillEmpty": "Модели не найдены", + "prefillFailed": "Ошибка поиска", + "promptCachingHint": "Включайте только если модель поддерживает кэширование, и задайте цены кэша ниже", + "cachePricingTitle": "Цены кэша" }, "actions": { "edit": "Редактировать", diff --git a/messages/zh-CN/settings.json b/messages/zh-CN/settings.json index 99381c57c..151e6cb7e 100644 --- a/messages/zh-CN/settings.json +++ b/messages/zh-CN/settings.json @@ -1335,8 +1335,17 @@ "modelName": "模型名称", "provider": "提供商", "capabilities": "能力", + "price": "价格", "inputPrice": "输入价格 ($/M)", "outputPrice": "输出价格 ($/M)", + "priceInput": "输入", + "priceOutput": "输出", + "pricePerRequest": "按次", + "cacheReadPrice": "缓存读取 ($/M)", + "cacheCreationPrice": "缓存创建 ($/M)", + "cache5m": "5m", + "cache1h": "1h+", + "copyModelId": "复制模型 ID", "updatedAt": "更新时间", "actions": "操作", "typeChat": "对话", @@ -1379,6 +1388,8 @@ "manualDownload": "你也可以手动下载", "latestPriceTable": "云端价格表", "andUploadViaButton": ",并通过上方按钮上传", + "cloudModelCountLoading": "云端模型数量加载中...", + "cloudModelCountFailed": "云端模型数量加载失败", "supportedModels": "当前支持 {count} 个模型", "results": { "title": "更新结果", @@ -1398,15 +1409,28 @@ "editModelDescription": "编辑模型的价格配置", "deleteConfirm": "确定要删除模型 {name} 吗?此操作不可撤销。", "form": { - "modelName": "模型名称", + "modelName": "模型 ID", "modelNamePlaceholder": "例如: gpt-5.2-codex", - "modelNameRequired": "模型名称不能为空", + "modelNameRequired": "模型 ID 不能为空", + "displayName": "展示名称(可选)", + "displayNamePlaceholder": "例如: GPT-5.2 Codex", "type": "类型", "provider": "供应商", "providerPlaceholder": "例如: openai", + "requestPrice": "按次调用价格 ($/request)", "inputPrice": "输入价格 ($/M tokens)", "outputPrice": "输出价格 ($/M tokens)", - "outputPriceImage": "输出价格 ($/image)" + "outputPriceImage": "输出价格 ($/image)", + "cacheReadPrice": "缓存读取价格 ($/M tokens)", + "cacheCreationPrice5m": "缓存创建价格(5m,$/M tokens)", + "cacheCreationPrice1h": "缓存创建价格(1h+,$/M tokens)" + }, + "drawer": { + "prefillLabel": "搜索现有模型并预填充", + "prefillEmpty": "未找到匹配的模型", + "prefillFailed": "搜索失败", + "promptCachingHint": "仅当模型支持缓存时开启,并配置下方缓存价格", + "cachePricingTitle": "缓存价格" }, "actions": { "edit": "编辑", diff --git a/messages/zh-TW/settings.json b/messages/zh-TW/settings.json index 078dc8d17..7688f9d7b 100644 --- a/messages/zh-TW/settings.json +++ b/messages/zh-TW/settings.json @@ -607,8 +607,17 @@ "modelName": "模型名稱", "provider": "提供商", "capabilities": "能力", + "price": "價格", "inputPrice": "輸入價格 ($/M)", "outputPrice": "輸出價格 ($/M)", + "priceInput": "輸入", + "priceOutput": "輸出", + "pricePerRequest": "按次", + "cacheReadPrice": "快取讀取 ($/M)", + "cacheCreationPrice": "快取建立 ($/M)", + "cache5m": "5m", + "cache1h": "1h+", + "copyModelId": "複製模型 ID", "updatedAt": "更新時間", "actions": "操作", "typeChat": "對話", @@ -651,6 +660,8 @@ "manualDownload": "你也可以手動下載", "latestPriceTable": "雲端價格表", "andUploadViaButton": ",並透過上方按鈕上傳", + "cloudModelCountLoading": "雲端模型數量載入中...", + "cloudModelCountFailed": "雲端模型數量載入失敗", "supportedModels": "目前支援 {count} 個模型", "results": { "title": "更新結果", @@ -670,15 +681,28 @@ "editModelDescription": "編輯模型的價格設定", "deleteConfirm": "確定要刪除模型 {name} 嗎?此操作無法復原。", "form": { - "modelName": "模型名稱", + "modelName": "模型 ID", "modelNamePlaceholder": "例如: gpt-5.2-codex", - "modelNameRequired": "模型名稱為必填", + "modelNameRequired": "模型 ID 為必填", + "displayName": "顯示名稱(選填)", + "displayNamePlaceholder": "例如: GPT-5.2 Codex", "type": "類型", "provider": "提供商", "providerPlaceholder": "例如: openai", + "requestPrice": "按次呼叫價格 ($/request)", "inputPrice": "輸入價格 ($/M tokens)", "outputPrice": "輸出價格 ($/M tokens)", - "outputPriceImage": "輸出價格 ($/張圖)" + "outputPriceImage": "輸出價格 ($/張圖)", + "cacheReadPrice": "快取讀取價格 ($/M tokens)", + "cacheCreationPrice5m": "快取建立價格(5m,$/M tokens)", + "cacheCreationPrice1h": "快取建立價格(1h+,$/M tokens)" + }, + "drawer": { + "prefillLabel": "搜尋現有模型並預填充", + "prefillEmpty": "未找到匹配的模型", + "prefillFailed": "搜尋失敗", + "promptCachingHint": "僅當模型支援快取時開啟,並配置下方快取價格", + "cachePricingTitle": "快取價格" }, "actions": { "edit": "編輯", diff --git a/src/actions/model-prices.ts b/src/actions/model-prices.ts index 85cf2d697..5ba943946 100644 --- a/src/actions/model-prices.ts +++ b/src/actions/model-prices.ts @@ -452,11 +452,17 @@ export async function syncLiteLLMPrices( */ export interface SingleModelPriceInput { modelName: string; + displayName?: string; mode: "chat" | "image_generation" | "completion"; litellmProvider?: string; + supportsPromptCaching?: boolean; inputCostPerToken?: number; outputCostPerToken?: number; outputCostPerImage?: number; + inputCostPerRequest?: number; + cacheReadInputTokenCost?: number; + cacheCreationInputTokenCost?: number; + cacheCreationInputTokenCostAbove1hr?: number; } /** @@ -496,14 +502,45 @@ export async function upsertSingleModelPrice( ) { return { ok: false, error: "图片价格必须为非负数" }; } + if ( + input.inputCostPerRequest !== undefined && + (input.inputCostPerRequest < 0 || !Number.isFinite(input.inputCostPerRequest)) + ) { + return { ok: false, error: "按次调用价格必须为非负数" }; + } + if ( + input.cacheReadInputTokenCost !== undefined && + (input.cacheReadInputTokenCost < 0 || !Number.isFinite(input.cacheReadInputTokenCost)) + ) { + return { ok: false, error: "缓存读取价格必须为非负数" }; + } + if ( + input.cacheCreationInputTokenCost !== undefined && + (input.cacheCreationInputTokenCost < 0 || !Number.isFinite(input.cacheCreationInputTokenCost)) + ) { + return { ok: false, error: "缓存创建价格必须为非负数" }; + } + if ( + input.cacheCreationInputTokenCostAbove1hr !== undefined && + (input.cacheCreationInputTokenCostAbove1hr < 0 || + !Number.isFinite(input.cacheCreationInputTokenCostAbove1hr)) + ) { + return { ok: false, error: "缓存创建(1h)价格必须为非负数" }; + } // 构建价格数据 const priceData: ModelPriceData = { mode: input.mode, + display_name: input.displayName?.trim() || undefined, litellm_provider: input.litellmProvider || undefined, + supports_prompt_caching: input.supportsPromptCaching, input_cost_per_token: input.inputCostPerToken, output_cost_per_token: input.outputCostPerToken, output_cost_per_image: input.outputCostPerImage, + input_cost_per_request: input.inputCostPerRequest, + cache_read_input_token_cost: input.cacheReadInputTokenCost, + cache_creation_input_token_cost: input.cacheCreationInputTokenCost, + cache_creation_input_token_cost_above_1hr: input.cacheCreationInputTokenCostAbove1hr, }; // 执行更新 diff --git a/src/app/[locale]/settings/prices/_components/model-price-drawer.tsx b/src/app/[locale]/settings/prices/_components/model-price-drawer.tsx new file mode 100644 index 000000000..67129ef8d --- /dev/null +++ b/src/app/[locale]/settings/prices/_components/model-price-drawer.tsx @@ -0,0 +1,620 @@ +"use client"; + +import { Loader2, Pencil, Plus, Search } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useCallback, useEffect, useState } from "react"; +import { toast } from "sonner"; +import { upsertSingleModelPrice } from "@/actions/model-prices"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@/components/ui/sheet"; +import { Switch } from "@/components/ui/switch"; +import { useDebounce } from "@/lib/hooks/use-debounce"; +import type { ModelPrice } from "@/types/model-price"; + +interface ModelPriceDrawerProps { + mode: "create" | "edit"; + initialData?: ModelPrice; + trigger?: React.ReactNode; + onSuccess?: () => void; + defaultOpen?: boolean; +} + +type ModelMode = "chat" | "image_generation" | "completion"; + +type PrefillStatus = "idle" | "loading" | "loaded" | "error"; + +function parsePricePerMillionToPerToken(value: string): number | undefined { + const trimmed = value.trim(); + if (!trimmed) return undefined; + const parsed = Number.parseFloat(trimmed); + if (!Number.isFinite(parsed) || parsed < 0) return undefined; + return parsed / 1000000; +} + +function parsePrice(value: string): number | undefined { + const trimmed = value.trim(); + if (!trimmed) return undefined; + const parsed = Number.parseFloat(trimmed); + if (!Number.isFinite(parsed) || parsed < 0) return undefined; + return parsed; +} + +function formatPerTokenPriceToPerMillion(value?: number): string { + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { + return ""; + } + return (value * 1000000).toString(); +} + +/** + * 模型价格添加/编辑抽屉(右侧) + */ +export function ModelPriceDrawer({ + mode, + initialData, + trigger, + onSuccess, + defaultOpen = false, +}: ModelPriceDrawerProps) { + const t = useTranslations("settings.prices"); + const tCommon = useTranslations("settings.common"); + + const [open, setOpen] = useState(defaultOpen); + const [loading, setLoading] = useState(false); + + // 表单状态 + const [modelName, setModelName] = useState(""); + const [displayName, setDisplayName] = useState(""); + const [modelMode, setModelMode] = useState("chat"); + const [provider, setProvider] = useState(""); + const [supportsPromptCaching, setSupportsPromptCaching] = useState(false); + const [inputPrice, setInputPrice] = useState(""); + const [outputPrice, setOutputPrice] = useState(""); + const [inputPricePerRequest, setInputPricePerRequest] = useState(""); + const [cacheReadPrice, setCacheReadPrice] = useState(""); + const [cacheCreation5mPrice, setCacheCreation5mPrice] = useState(""); + const [cacheCreation1hPrice, setCacheCreation1hPrice] = useState(""); + + // 预填充搜索(仅 create 模式显示) + const [prefillQuery, setPrefillQuery] = useState(""); + const [prefillStatus, setPrefillStatus] = useState("idle"); + const [prefillResults, setPrefillResults] = useState([]); + const debouncedPrefillQuery = useDebounce(prefillQuery, 300); + + const resetForm = useCallback(() => { + setModelName(""); + setDisplayName(""); + setModelMode("chat"); + setProvider(""); + setSupportsPromptCaching(false); + setInputPrice(""); + setOutputPrice(""); + setInputPricePerRequest(""); + setCacheReadPrice(""); + setCacheCreation5mPrice(""); + setCacheCreation1hPrice(""); + setPrefillQuery(""); + setPrefillStatus("idle"); + setPrefillResults([]); + }, []); + + const applyPrefill = useCallback((selected: ModelPrice) => { + setModelName(selected.modelName); + setDisplayName(selected.priceData.display_name?.trim() || ""); + setModelMode((selected.priceData.mode as ModelMode) || "chat"); + setProvider(selected.priceData.litellm_provider?.trim() || ""); + setSupportsPromptCaching(selected.priceData.supports_prompt_caching === true); + + if (selected.priceData.mode === "image_generation") { + setInputPrice(""); + setOutputPrice( + typeof selected.priceData.output_cost_per_image === "number" + ? selected.priceData.output_cost_per_image.toString() + : "" + ); + } else { + setInputPrice(formatPerTokenPriceToPerMillion(selected.priceData.input_cost_per_token)); + setOutputPrice(formatPerTokenPriceToPerMillion(selected.priceData.output_cost_per_token)); + } + + setInputPricePerRequest( + typeof selected.priceData.input_cost_per_request === "number" + ? selected.priceData.input_cost_per_request.toString() + : "" + ); + setCacheReadPrice( + formatPerTokenPriceToPerMillion(selected.priceData.cache_read_input_token_cost) + ); + setCacheCreation5mPrice( + formatPerTokenPriceToPerMillion(selected.priceData.cache_creation_input_token_cost) + ); + setCacheCreation1hPrice( + formatPerTokenPriceToPerMillion(selected.priceData.cache_creation_input_token_cost_above_1hr) + ); + }, []); + + // 当抽屉打开或初始数据变化时,重置表单 + useEffect(() => { + if (!open) { + return; + } + + if (mode === "edit" && initialData) { + applyPrefill(initialData); + return; + } + + resetForm(); + }, [open, mode, initialData, applyPrefill, resetForm]); + + useEffect(() => { + if (!open || mode !== "create") { + return; + } + + if (!prefillQuery.trim()) { + setPrefillResults([]); + setPrefillStatus("idle"); + } + }, [mode, open, prefillQuery]); + + useEffect(() => { + if (!open || mode !== "create") { + return; + } + + const query = debouncedPrefillQuery.trim(); + if (!query) { + return; + } + + let cancelled = false; + const fetchPrefillResults = async () => { + setPrefillStatus("loading"); + setPrefillResults([]); + try { + const params = new URLSearchParams(); + params.set("page", "1"); + params.set("pageSize", "10"); + params.set("search", query); + const response = await fetch(`/api/prices?${params.toString()}`, { cache: "no-store" }); + const payload = await response.json(); + if (!payload?.ok) { + throw new Error(payload?.error || "unknown error"); + } + + const data: ModelPrice[] = payload.data?.data ?? []; + if (!cancelled) { + setPrefillResults(data); + setPrefillStatus("loaded"); + } + } catch (error) { + console.error("搜索模型失败:", error); + if (!cancelled) { + setPrefillResults([]); + setPrefillStatus("error"); + } + } + }; + + fetchPrefillResults(); + + return () => { + cancelled = true; + }; + }, [mode, open, debouncedPrefillQuery]); + + const handleSubmit = async () => { + if (!modelName.trim()) { + toast.error(t("form.modelNameRequired")); + return; + } + + setLoading(true); + + try { + const inputCostPerToken = + modelMode !== "image_generation" ? parsePricePerMillionToPerToken(inputPrice) : undefined; + const outputCostPerToken = + modelMode !== "image_generation" ? parsePricePerMillionToPerToken(outputPrice) : undefined; + const outputCostPerImage = + modelMode === "image_generation" ? parsePrice(outputPrice) : undefined; + + const inputCostPerRequest = parsePrice(inputPricePerRequest); + + const cacheReadCostPerToken = supportsPromptCaching + ? parsePricePerMillionToPerToken(cacheReadPrice) + : undefined; + const cacheCreation5mCostPerToken = supportsPromptCaching + ? parsePricePerMillionToPerToken(cacheCreation5mPrice) + : undefined; + const cacheCreation1hCostPerToken = supportsPromptCaching + ? parsePricePerMillionToPerToken(cacheCreation1hPrice) + : undefined; + + const result = await upsertSingleModelPrice({ + modelName: modelName.trim(), + displayName: displayName.trim() || undefined, + mode: modelMode, + litellmProvider: provider.trim() || undefined, + supportsPromptCaching, + inputCostPerToken, + outputCostPerToken, + outputCostPerImage, + inputCostPerRequest, + cacheReadInputTokenCost: cacheReadCostPerToken, + cacheCreationInputTokenCost: cacheCreation5mCostPerToken, + cacheCreationInputTokenCostAbove1hr: cacheCreation1hCostPerToken, + }); + + if (!result.ok) { + toast.error(result.error); + return; + } + + toast.success(mode === "create" ? t("toast.createSuccess") : t("toast.updateSuccess")); + setOpen(false); + onSuccess?.(); + window.dispatchEvent(new Event("price-data-updated")); + } catch (error) { + console.error("保存失败:", error); + toast.error(t("toast.saveFailed")); + } finally { + setLoading(false); + } + }; + + const defaultTrigger = + mode === "create" ? ( + + ) : ( + + ); + + const isPrefillVisible = mode === "create"; + + return ( + + {trigger || defaultTrigger} + + + {mode === "create" ? t("addModel") : t("editModel")} + + {mode === "create" ? t("addModelDescription") : t("editModelDescription")} + + + +
+ {isPrefillVisible && ( +
+ +
+ + setPrefillQuery(event.target.value)} + placeholder={t("searchPlaceholder")} + className="pl-9" + disabled={loading} + /> +
+ + {prefillStatus !== "idle" && ( + + + + {prefillStatus === "loading" ? ( +
+ + {tCommon("loading")} +
+ ) : ( + + {prefillStatus === "error" + ? t("drawer.prefillFailed") + : t("drawer.prefillEmpty")} + + )} +
+ + {prefillResults.map((item) => { + const name = item.priceData.display_name?.trim() || item.modelName; + const providerName = item.priceData.litellm_provider?.trim() || ""; + return ( + applyPrefill(item)} + disabled={loading} + className="cursor-pointer" + > +
+
+
{name}
+
+ {item.modelName} +
+
+
+ {providerName ? ( + + {providerName} + + ) : null} + {item.source === "manual" ? ( + {t("badges.local")} + ) : null} +
+
+
+ ); + })} +
+
+
+ )} +
+ )} + +
+
+ + setModelName(e.target.value)} + placeholder={t("form.modelNamePlaceholder")} + disabled={mode === "edit" || loading} + /> +
+ +
+ + setDisplayName(e.target.value)} + placeholder={t("form.displayNamePlaceholder")} + disabled={loading} + /> +
+ +
+ + +
+ +
+ + setProvider(e.target.value)} + placeholder={t("form.providerPlaceholder")} + disabled={loading} + /> +
+ +
+ +
+ + $ + + setInputPricePerRequest(e.target.value)} + placeholder="0.00" + className="pl-7 pr-12" + disabled={loading} + /> + + /req + +
+
+ + {modelMode !== "image_generation" && ( +
+ +
+ + $ + + setInputPrice(e.target.value)} + placeholder="0.00" + className="pl-7 pr-12" + disabled={loading} + /> + + /M + +
+
+ )} + +
+ +
+ + $ + + setOutputPrice(e.target.value)} + placeholder="0.00" + className="pl-7 pr-16" + disabled={loading} + /> + + {modelMode === "image_generation" ? "/img" : "/M"} + +
+
+ +
+
+
{t("capabilities.promptCaching")}
+
{t("drawer.promptCachingHint")}
+
+ +
+ +
+
{t("drawer.cachePricingTitle")}
+ +
+ +
+ + $ + + setCacheReadPrice(e.target.value)} + placeholder="0.00" + className="pl-7 pr-12" + disabled={loading || !supportsPromptCaching} + /> + + /M + +
+
+ +
+ +
+ + $ + + setCacheCreation5mPrice(e.target.value)} + placeholder="0.00" + className="pl-7 pr-12" + disabled={loading || !supportsPromptCaching} + /> + + /M + +
+
+ +
+ +
+ + $ + + setCacheCreation1hPrice(e.target.value)} + placeholder="0.00" + className="pl-7 pr-12" + disabled={loading || !supportsPromptCaching} + /> + + /M + +
+
+
+
+
+ + +
+ + +
+
+
+
+ ); +} diff --git a/src/app/[locale]/settings/prices/_components/price-list.tsx b/src/app/[locale]/settings/prices/_components/price-list.tsx index 1f2ea843d..d3c081360 100644 --- a/src/app/[locale]/settings/prices/_components/price-list.tsx +++ b/src/app/[locale]/settings/prices/_components/price-list.tsx @@ -21,6 +21,7 @@ import { } from "lucide-react"; import { useLocale, useTranslations } from "next-intl"; import { useCallback, useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -47,9 +48,10 @@ import { } from "@/components/ui/table"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useDebounce } from "@/lib/hooks/use-debounce"; +import { copyToClipboard } from "@/lib/utils/clipboard"; import type { ModelPrice, ModelPriceSource } from "@/types/model-price"; import { DeleteModelDialog } from "./delete-model-dialog"; -import { ModelPriceDialog } from "./model-price-dialog"; +import { ModelPriceDrawer } from "./model-price-drawer"; interface PriceListProps { initialPrices: ModelPrice[]; @@ -74,6 +76,7 @@ export function PriceList({ initialLitellmProviderFilter, }: PriceListProps) { const t = useTranslations("settings.prices"); + const tCommon = useTranslations("common"); const locale = useLocale(); const [searchTerm, setSearchTerm] = useState(initialSearchTerm); const [sourceFilter, setSourceFilter] = useState(initialSourceFilter); @@ -87,6 +90,7 @@ export function PriceList({ // 使用防抖,避免频繁请求 const debouncedSearchTerm = useDebounce(searchTerm, 500); const lastDebouncedSearchTerm = useRef(debouncedSearchTerm); + const pendingRefreshPage = useRef(null); // 计算总页数 const totalPages = Math.ceil(total / pageSize); @@ -179,6 +183,13 @@ export function PriceList({ // 监听价格数据变化事件(由其他组件触发) useEffect(() => { const handlePriceUpdate = () => { + const forcedPage = pendingRefreshPage.current; + if (typeof forcedPage === "number") { + pendingRefreshPage.current = null; + fetchPrices(forcedPage, pageSize, debouncedSearchTerm, sourceFilter, litellmProviderFilter); + return; + } + fetchPrices(page, pageSize, debouncedSearchTerm, sourceFilter, litellmProviderFilter); }; @@ -244,21 +255,48 @@ export function PriceList({ }; /** - * 获取模型类型标签 + * 格式化标量价格(用于 /img、/req 等) */ - const getModeBadge = (mode?: string) => { - switch (mode) { - case "chat": - return null; - case "image_generation": - return {t("table.typeImage")}; - case "completion": - return {t("table.typeCompletion")}; - default: - return {t("table.typeUnknown")}; + const formatScalarPrice = (value?: number): string => { + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { + return "-"; } + if (value < 0.01) return value.toFixed(4); + if (value < 1) return value.toFixed(3); + if (value < 100) return value.toFixed(2); + return value.toFixed(0); }; + const formatPerMillionTokenPriceLabel = (value?: number): string => { + const formatted = formatPrice(value); + if (formatted === "-") return "-"; + return `$${formatted}/M`; + }; + + const formatPerImagePriceLabel = (value?: number): string => { + const formatted = formatScalarPrice(value); + if (formatted === "-") return "-"; + return `$${formatted}/img`; + }; + + const formatPerRequestPriceLabel = (value?: number): string => { + const formatted = formatScalarPrice(value); + if (formatted === "-") return "-"; + return `$${formatted}/req`; + }; + + const handleCopyModelId = useCallback( + async (modelId: string) => { + const ok = await copyToClipboard(modelId); + if (ok) { + toast.success(tCommon("copySuccess")); + return; + } + toast.error(tCommon("copyFailed")); + }, + [tCommon] + ); + const capabilityItems: Array<{ key: | "supports_assistant_prefill" @@ -410,10 +448,16 @@ export function PriceList({ {t("table.modelName")} - {t("table.provider")} {t("table.capabilities")} - {t("table.inputPrice")} - {t("table.outputPrice")} + + {t("table.price")} + + + {t("table.cacheReadPrice")} + + + {t("table.cacheCreationPrice")} + {t("table.updatedAt")} {t("table.actions")} @@ -436,24 +480,30 @@ export function PriceList({ {price.priceData.display_name?.trim() || price.modelName} + {price.priceData.litellm_provider ? ( + + {price.priceData.litellm_provider} + + ) : null} {price.source === "manual" && ( {t("badges.local")} )} - {price.priceData.mode ? getModeBadge(price.priceData.mode) : null}
- {price.priceData.display_name?.trim() && - price.priceData.display_name.trim() !== price.modelName ? ( -
- {price.modelName} -
- ) : null} - - - {price.priceData.litellm_provider ? ( - {price.priceData.litellm_provider} - ) : ( - - - )} +
+ + + + + {t("table.copyModelId")} + +
@@ -485,23 +535,77 @@ export function PriceList({
- {price.priceData.mode === "image_generation" ? ( - "-" - ) : ( - - ${formatPrice(price.priceData.input_cost_per_token)}/M - - )} +
+
+ + {t("table.priceInput")} + + + {price.priceData.mode === "image_generation" + ? "-" + : formatPerMillionTokenPriceLabel(price.priceData.input_cost_per_token)} + +
+
+ + {t("table.priceOutput")} + + + {price.priceData.mode === "image_generation" + ? formatPerImagePriceLabel(price.priceData.output_cost_per_image) + : formatPerMillionTokenPriceLabel( + price.priceData.output_cost_per_token + )} + +
+ {formatPerRequestPriceLabel(price.priceData.input_cost_per_request) !== + "-" ? ( +
+ + {t("table.pricePerRequest")} + + + {formatPerRequestPriceLabel(price.priceData.input_cost_per_request)} + +
+ ) : null} +
- {price.priceData.mode === "image_generation" ? ( - - ${formatPrice(price.priceData.output_cost_per_image)}/img - + + {price.priceData.supports_prompt_caching === true + ? formatPerMillionTokenPriceLabel( + price.priceData.cache_read_input_token_cost + ) + : "-"} + + + + {price.priceData.supports_prompt_caching === true ? ( +
+
+ + {t("table.cache5m")} + + + {formatPerMillionTokenPriceLabel( + price.priceData.cache_creation_input_token_cost + )} + +
+
+ + {t("table.cache1h")} + + + {formatPerMillionTokenPriceLabel( + price.priceData.cache_creation_input_token_cost_above_1hr + )} + +
+
) : ( - - ${formatPrice(price.priceData.output_cost_per_token)}/M - + - )}
@@ -520,18 +624,9 @@ export function PriceList({ - - fetchPrices( - page, - pageSize, - debouncedSearchTerm, - sourceFilter, - litellmProviderFilter - ) - } trigger={ e.preventDefault()}> @@ -541,15 +636,21 @@ export function PriceList({ /> - fetchPrices( - page, - pageSize, - debouncedSearchTerm, - sourceFilter, - litellmProviderFilter - ) - } + onSuccess={() => { + const willBeEmpty = filteredPrices.length <= 1 && page > 1; + const targetPage = willBeEmpty ? page - 1 : page; + if (targetPage !== page) { + pendingRefreshPage.current = targetPage; + setPage(targetPage); + updateURL( + debouncedSearchTerm, + targetPage, + pageSize, + sourceFilter, + litellmProviderFilter + ); + } + }} trigger={ e.preventDefault()} diff --git a/src/app/[locale]/settings/prices/_components/upload-price-dialog.tsx b/src/app/[locale]/settings/prices/_components/upload-price-dialog.tsx index 88d9501c4..ce29f2e42 100644 --- a/src/app/[locale]/settings/prices/_components/upload-price-dialog.tsx +++ b/src/app/[locale]/settings/prices/_components/upload-price-dialog.tsx @@ -63,6 +63,10 @@ export function UploadPriceDialog({ const [open, setOpen] = useState(defaultOpen); const [uploading, setUploading] = useState(false); const [result, setResult] = useState(null); + const [cloudModelCount, setCloudModelCount] = useState(null); + const [cloudModelCountStatus, setCloudModelCountStatus] = useState< + "idle" | "loading" | "loaded" | "error" + >("idle"); const handleOpenChange = (nextOpen: boolean) => { if (!nextOpen && uploading) { @@ -76,6 +80,41 @@ export function UploadPriceDialog({ setOpen(nextOpen); }; + useEffect(() => { + if (!open) { + return; + } + + let cancelled = false; + const fetchCloudModelCount = async () => { + setCloudModelCountStatus("loading"); + try { + const response = await fetch("/api/prices/cloud-model-count", { cache: "no-store" }); + const payload = await response.json(); + if (!payload?.ok) { + throw new Error(payload?.error || "unknown error"); + } + + if (!cancelled) { + setCloudModelCount(Number(payload.data?.count) || 0); + setCloudModelCountStatus("loaded"); + } + } catch (error) { + console.error("获取云端模型数量失败:", error); + if (!cancelled) { + setCloudModelCount(null); + setCloudModelCountStatus("error"); + } + } + }; + + fetchCloudModelCount(); + + return () => { + cancelled = true; + }; + }, [open]); + /** * 处理文件选择 */ @@ -218,7 +257,16 @@ export function UploadPriceDialog({ {" "} {t("dialog.andUploadViaButton")}

-

• {t("dialog.supportedModels", { count: "Claude + OpenAI" })}

+

+ •{" "} + {cloudModelCountStatus === "loading" + ? t("dialog.cloudModelCountLoading") + : cloudModelCountStatus === "loaded" + ? t("dialog.supportedModels", { count: cloudModelCount ?? 0 }) + : cloudModelCountStatus === "error" + ? t("dialog.cloudModelCountFailed") + : t("dialog.supportedModels", { count: "-" })} +

) : ( diff --git a/src/app/[locale]/settings/prices/page.tsx b/src/app/[locale]/settings/prices/page.tsx index 0f14528ca..9e82beee6 100644 --- a/src/app/[locale]/settings/prices/page.tsx +++ b/src/app/[locale]/settings/prices/page.tsx @@ -3,7 +3,7 @@ import { Suspense } from "react"; import { getModelPrices, getModelPricesPaginated } from "@/actions/model-prices"; import { Section } from "@/components/section"; import { SettingsPageHeader } from "../_components/settings-page-header"; -import { ModelPriceDialog } from "./_components/model-price-dialog"; +import { ModelPriceDrawer } from "./_components/model-price-drawer"; import { PriceList } from "./_components/price-list"; import { PricesSkeleton } from "./_components/prices-skeleton"; import { SyncLiteLLMButton } from "./_components/sync-litellm-button"; @@ -86,7 +86,7 @@ async function SettingsPricesContent({ searchParams }: SettingsPricesPageProps) description={t("prices.section.description")} actions={
- +
diff --git a/src/app/api/prices/cloud-model-count/route.ts b/src/app/api/prices/cloud-model-count/route.ts new file mode 100644 index 000000000..d2a5c1f3d --- /dev/null +++ b/src/app/api/prices/cloud-model-count/route.ts @@ -0,0 +1,27 @@ +import { NextResponse } from "next/server"; +import { getSession } from "@/lib/auth"; +import { + fetchCloudPriceTableToml, + parseCloudPriceTableToml, +} from "@/lib/price-sync/cloud-price-table"; + +export async function GET() { + // 权限检查:只有管理员可以访问 + const session = await getSession(); + if (!session || session.user.role !== "admin") { + return NextResponse.json({ ok: false, error: "无权限访问此资源" }, { status: 403 }); + } + + const tomlResult = await fetchCloudPriceTableToml(); + if (!tomlResult.ok) { + return NextResponse.json({ ok: false, error: tomlResult.error }, { status: 502 }); + } + + const parseResult = parseCloudPriceTableToml(tomlResult.data); + if (!parseResult.ok) { + return NextResponse.json({ ok: false, error: parseResult.error }, { status: 500 }); + } + + const count = Object.keys(parseResult.data.models).length; + return NextResponse.json({ ok: true, data: { count } }); +} diff --git a/src/lib/utils/cost-calculation.ts b/src/lib/utils/cost-calculation.ts index a6b70ccec..a38b702de 100644 --- a/src/lib/utils/cost-calculation.ts +++ b/src/lib/utils/cost-calculation.ts @@ -113,6 +113,18 @@ export function calculateRequestCost( const inputCostPerToken = priceData.input_cost_per_token; const outputCostPerToken = priceData.output_cost_per_token; + const inputCostPerRequest = priceData.input_cost_per_request; + + if ( + typeof inputCostPerRequest === "number" && + Number.isFinite(inputCostPerRequest) && + inputCostPerRequest >= 0 + ) { + const requestCost = toDecimal(inputCostPerRequest); + if (requestCost) { + segments.push(requestCost); + } + } const cacheCreation5mCost = priceData.cache_creation_input_token_cost ?? diff --git a/src/lib/utils/price-data.ts b/src/lib/utils/price-data.ts index d8770fae0..efadf36a3 100644 --- a/src/lib/utils/price-data.ts +++ b/src/lib/utils/price-data.ts @@ -8,6 +8,7 @@ export function hasValidPriceData(priceData: ModelPriceData): boolean { const numericCosts = [ priceData.input_cost_per_token, priceData.output_cost_per_token, + priceData.input_cost_per_request, priceData.cache_creation_input_token_cost, priceData.cache_creation_input_token_cost_above_1hr, priceData.cache_read_input_token_cost, diff --git a/src/types/model-price.ts b/src/types/model-price.ts index 75ae4e81b..ab2b62797 100644 --- a/src/types/model-price.ts +++ b/src/types/model-price.ts @@ -5,6 +5,7 @@ export interface ModelPriceData { // 基础价格信息 input_cost_per_token?: number; output_cost_per_token?: number; + input_cost_per_request?: number; // 按次调用固定费用(与 token 费用叠加) // 缓存相关价格 cache_creation_input_token_cost?: number; diff --git a/tests/unit/lib/cost-calculation-input-cost-per-request.test.ts b/tests/unit/lib/cost-calculation-input-cost-per-request.test.ts new file mode 100644 index 000000000..fd2fc20a5 --- /dev/null +++ b/tests/unit/lib/cost-calculation-input-cost-per-request.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "vitest"; +import { calculateRequestCost } from "@/lib/utils/cost-calculation"; + +describe("calculateRequestCost: input_cost_per_request", () => { + test("仅配置按次调用价格时,应计入单次成本", () => { + const cost = calculateRequestCost( + {}, + { + input_cost_per_request: 0.005, + } + ); + + expect(cost.toString()).toBe("0.005"); + }); + + test("按次调用价格应与 token 计费叠加", () => { + const cost = calculateRequestCost( + { input_tokens: 1000, output_tokens: 2000 }, + { + input_cost_per_token: 0.000001, + output_cost_per_token: 0.000002, + input_cost_per_request: 0.005, + } + ); + + expect(cost.toString()).toBe("0.01"); + }); + + test("倍率应同时作用于按次费用与 token 费用", () => { + const cost = calculateRequestCost( + { input_tokens: 1000, output_tokens: 2000 }, + { + input_cost_per_token: 0.000001, + output_cost_per_token: 0.000002, + input_cost_per_request: 0.005, + }, + 2 + ); + + expect(cost.toString()).toBe("0.02"); + }); + + test("无效的按次费用(负数/非有限)应被忽略", () => { + const negativeCost = calculateRequestCost( + {}, + { + input_cost_per_request: -0.005, + } + ); + + expect(negativeCost.toString()).toBe("0"); + + const nanCost = calculateRequestCost( + {}, + { + input_cost_per_request: Number.NaN, + } + ); + + expect(nanCost.toString()).toBe("0"); + }); +}); diff --git a/tests/unit/lib/price-data-input-cost-per-request.test.ts b/tests/unit/lib/price-data-input-cost-per-request.test.ts new file mode 100644 index 000000000..d0d70cddb --- /dev/null +++ b/tests/unit/lib/price-data-input-cost-per-request.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from "vitest"; +import { hasValidPriceData } from "@/lib/utils/price-data"; + +describe("hasValidPriceData: input_cost_per_request", () => { + test("仅配置按次调用价格时,应视为有效价格数据", () => { + expect( + hasValidPriceData({ + input_cost_per_request: 0.005, + }) + ).toBe(true); + }); + + test("按次调用价格为 0 时,应视为有效价格数据", () => { + expect( + hasValidPriceData({ + input_cost_per_request: 0, + }) + ).toBe(true); + }); + + test("按次调用价格为负数时,不应视为有效价格数据", () => { + expect( + hasValidPriceData({ + input_cost_per_request: -0.005, + }) + ).toBe(false); + }); + + test("按次调用价格为 NaN/Infinity 时,不应视为有效价格数据", () => { + expect( + hasValidPriceData({ + input_cost_per_request: Number.NaN, + }) + ).toBe(false); + + expect( + hasValidPriceData({ + input_cost_per_request: Number.POSITIVE_INFINITY, + }) + ).toBe(false); + }); +}); diff --git a/tests/unit/settings/prices/delete-model-dialog.test.tsx b/tests/unit/settings/prices/delete-model-dialog.test.tsx new file mode 100644 index 000000000..85240c013 --- /dev/null +++ b/tests/unit/settings/prices/delete-model-dialog.test.tsx @@ -0,0 +1,162 @@ +/** + * @vitest-environment happy-dom + */ + +import fs from "node:fs"; +import path from "node:path"; +import type { ReactNode } from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { NextIntlClientProvider } from "next-intl"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { DeleteModelDialog } from "@/app/[locale]/settings/prices/_components/delete-model-dialog"; + +const modelPricesActionMocks = vi.hoisted(() => ({ + deleteSingleModelPrice: vi.fn(async () => ({ ok: true, data: null })), +})); +vi.mock("@/actions/model-prices", () => modelPricesActionMocks); + +const sonnerMocks = vi.hoisted(() => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})); +vi.mock("sonner", () => sonnerMocks); + +function loadMessages() { + const base = path.join(process.cwd(), "messages/en"); + const read = (name: string) => JSON.parse(fs.readFileSync(path.join(base, name), "utf8")); + + return { + common: read("common.json"), + errors: read("errors.json"), + ui: read("ui.json"), + forms: read("forms.json"), + settings: read("settings.json"), + }; +} + +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 flushPromises() { + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +describe("DeleteModelDialog: 删除流程", () => { + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = ""; + }); + + test("删除成功:应调用 action、提示成功、触发回调与事件", async () => { + const messages = loadMessages(); + const onSuccess = vi.fn(); + const priceUpdatedListener = vi.fn(); + window.addEventListener("price-data-updated", priceUpdatedListener); + + const { unmount } = render( + + + + ); + + const trigger = Array.from(document.querySelectorAll("button")).find( + (el) => el.textContent?.trim() === "Delete" + ); + expect(trigger).toBeTruthy(); + + await act(async () => { + trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await flushPromises(); + }); + + const dialog = document.querySelector( + '[data-slot="alert-dialog-content"]' + ) as HTMLElement | null; + expect(dialog).toBeTruthy(); + + const confirmButton = Array.from(dialog!.querySelectorAll("button")).find( + (el) => el.textContent?.trim() === "Delete" + ); + expect(confirmButton).toBeTruthy(); + + await act(async () => { + confirmButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await flushPromises(); + await flushPromises(); + }); + + expect(modelPricesActionMocks.deleteSingleModelPrice).toHaveBeenCalledWith("model-to-delete"); + expect(sonnerMocks.toast.success).toHaveBeenCalled(); + expect(sonnerMocks.toast.error).not.toHaveBeenCalled(); + expect(onSuccess).toHaveBeenCalled(); + expect(priceUpdatedListener).toHaveBeenCalled(); + + window.removeEventListener("price-data-updated", priceUpdatedListener); + unmount(); + }); + + test("删除失败:应提示错误且不触发成功回调", async () => { + modelPricesActionMocks.deleteSingleModelPrice.mockResolvedValueOnce({ + ok: false, + error: "bad", + }); + + const messages = loadMessages(); + const onSuccess = vi.fn(); + + const { unmount } = render( + + + + ); + + const trigger = Array.from(document.querySelectorAll("button")).find( + (el) => el.textContent?.trim() === "Delete" + ); + expect(trigger).toBeTruthy(); + + await act(async () => { + trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await flushPromises(); + }); + + const dialog = document.querySelector( + '[data-slot="alert-dialog-content"]' + ) as HTMLElement | null; + expect(dialog).toBeTruthy(); + + const confirmButton = Array.from(dialog!.querySelectorAll("button")).find( + (el) => el.textContent?.trim() === "Delete" + ); + expect(confirmButton).toBeTruthy(); + + await act(async () => { + confirmButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await flushPromises(); + await flushPromises(); + }); + + expect(modelPricesActionMocks.deleteSingleModelPrice).toHaveBeenCalledWith("model-to-delete"); + expect(sonnerMocks.toast.error).toHaveBeenCalledWith("bad"); + expect(onSuccess).not.toHaveBeenCalled(); + + unmount(); + }); +}); diff --git a/tests/unit/settings/prices/model-price-drawer-prefill-and-submit-ui.test.tsx b/tests/unit/settings/prices/model-price-drawer-prefill-and-submit-ui.test.tsx new file mode 100644 index 000000000..c9fef94c8 --- /dev/null +++ b/tests/unit/settings/prices/model-price-drawer-prefill-and-submit-ui.test.tsx @@ -0,0 +1,316 @@ +/** + * @vitest-environment happy-dom + */ + +import fs from "node:fs"; +import path from "node:path"; +import type { ReactNode } from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { NextIntlClientProvider } from "next-intl"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { ModelPriceDrawer } from "@/app/[locale]/settings/prices/_components/model-price-drawer"; +import type { ModelPrice } from "@/types/model-price"; + +const modelPricesActionMocks = vi.hoisted(() => ({ + upsertSingleModelPrice: vi.fn(async () => ({ ok: true, data: null })), +})); +vi.mock("@/actions/model-prices", () => modelPricesActionMocks); + +const sonnerMocks = vi.hoisted(() => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})); +vi.mock("sonner", () => sonnerMocks); + +function loadMessages() { + const base = path.join(process.cwd(), "messages/en"); + const read = (name: string) => JSON.parse(fs.readFileSync(path.join(base, name), "utf8")); + + return { + common: read("common.json"), + errors: read("errors.json"), + ui: read("ui.json"), + forms: read("forms.json"), + settings: read("settings.json"), + }; +} + +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 flushPromises() { + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +async function flushMicrotasks() { + await Promise.resolve(); + await Promise.resolve(); +} + +function setReactInputValue(input: HTMLInputElement, value: string) { + const prototype = Object.getPrototypeOf(input) as HTMLInputElement; + const descriptor = Object.getOwnPropertyDescriptor(prototype, "value"); + descriptor?.set?.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); + input.dispatchEvent(new Event("change", { bubbles: true })); +} + +describe("ModelPriceDrawer: 预填充与提交", () => { + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = ""; + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + test("create 模式应支持搜索现有模型并预填充字段", async () => { + vi.useFakeTimers(); + const messages = loadMessages(); + const originalFetch = globalThis.fetch; + + const now = new Date("2026-01-01T00:00:00.000Z"); + const prefillModel: ModelPrice = { + id: 100, + modelName: "openai/gpt-test", + priceData: { + mode: "chat", + display_name: "GPT Test", + litellm_provider: "openai", + supports_prompt_caching: true, + input_cost_per_token: 0.000001, + output_cost_per_token: 0.000002, + input_cost_per_request: 0.005, + cache_read_input_token_cost: 0.0000001, + cache_creation_input_token_cost: 0.00000125, + cache_creation_input_token_cost_above_1hr: 0.000002, + }, + source: "litellm", + createdAt: now, + updatedAt: now, + }; + + const fetchMock = vi.fn().mockResolvedValue({ + json: async () => ({ + ok: true, + data: { data: [prefillModel], total: 1, page: 1, pageSize: 10, totalPages: 1 }, + }), + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + globalThis.fetch = fetchMock as any; + + const { unmount } = render( + + + + ); + + const searchInput = document.getElementById("prefill-search") as HTMLInputElement | null; + expect(searchInput).toBeTruthy(); + + await act(async () => { + setReactInputValue(searchInput!, "gpt"); + }); + + await act(async () => { + vi.advanceTimersByTime(350); + }); + + await act(async () => { + await flushMicrotasks(); + }); + + expect(fetchMock).toHaveBeenCalled(); + expect(document.body.textContent).toContain("openai/gpt-test"); + + const resultItem = Array.from(document.querySelectorAll('[data-slot="command-item"]')).find( + (el) => el.textContent?.includes("openai/gpt-test") + ); + expect(resultItem).toBeTruthy(); + + await act(async () => { + resultItem?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await flushMicrotasks(); + }); + + const modelIdInput = document.getElementById("modelName") as HTMLInputElement | null; + const displayNameInput = document.getElementById("displayName") as HTMLInputElement | null; + const providerInput = document.getElementById("provider") as HTMLInputElement | null; + + expect(modelIdInput?.value).toBe("openai/gpt-test"); + expect(displayNameInput?.value).toBe("GPT Test"); + expect(providerInput?.value).toBe("openai"); + + globalThis.fetch = originalFetch; + unmount(); + }); + + test("预填充搜索应防抖:快速输入只触发一次请求", async () => { + vi.useFakeTimers(); + const messages = loadMessages(); + const originalFetch = globalThis.fetch; + + const fetchMock = vi.fn().mockResolvedValue({ + json: async () => ({ + ok: true, + data: { data: [], total: 0, page: 1, pageSize: 10, totalPages: 0 }, + }), + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + globalThis.fetch = fetchMock as any; + + const { unmount } = render( + + + + ); + + const searchInput = document.getElementById("prefill-search") as HTMLInputElement | null; + expect(searchInput).toBeTruthy(); + + await act(async () => { + setReactInputValue(searchInput!, "g"); + setReactInputValue(searchInput!, "gp"); + setReactInputValue(searchInput!, "gpt"); + }); + + await act(async () => { + vi.advanceTimersByTime(350); + }); + + await act(async () => { + await flushMicrotasks(); + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const url = fetchMock.mock.calls[0][0] as string; + expect(url).toContain("search=gpt"); + + globalThis.fetch = originalFetch; + unmount(); + }); + + test("提交时应正确换算 $/M 到每 token,并透传按次与缓存价格字段", async () => { + const messages = loadMessages(); + const { unmount } = render( + + + + ); + + await act(async () => { + await flushPromises(); + }); + + const modelIdInput = document.getElementById("modelName") as HTMLInputElement | null; + const displayNameInput = document.getElementById("displayName") as HTMLInputElement | null; + const providerInput = document.getElementById("provider") as HTMLInputElement | null; + const requestPriceInput = document.getElementById( + "inputPricePerRequest" + ) as HTMLInputElement | null; + const inputPrice = document.getElementById("inputPrice") as HTMLInputElement | null; + const outputPrice = document.getElementById("outputPrice") as HTMLInputElement | null; + + expect(modelIdInput).toBeTruthy(); + expect(displayNameInput).toBeTruthy(); + expect(providerInput).toBeTruthy(); + expect(requestPriceInput).toBeTruthy(); + expect(inputPrice).toBeTruthy(); + expect(outputPrice).toBeTruthy(); + + await act(async () => { + setReactInputValue(modelIdInput!, "custom/model-1"); + setReactInputValue(displayNameInput!, "Custom Model 1"); + setReactInputValue(providerInput!, "openai"); + + setReactInputValue(requestPriceInput!, "0.005"); + + setReactInputValue(inputPrice!, "2"); + + setReactInputValue(outputPrice!, "4"); + }); + + const cachingSwitch = document.querySelector( + 'button[aria-label="Prompt caching"]' + ) as HTMLButtonElement | null; + expect(cachingSwitch).toBeTruthy(); + + await act(async () => { + cachingSwitch?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await flushPromises(); + }); + + const cacheRead = document.getElementById("cacheReadPrice") as HTMLInputElement | null; + const cacheCreate5m = document.getElementById( + "cacheCreation5mPrice" + ) as HTMLInputElement | null; + const cacheCreate1h = document.getElementById( + "cacheCreation1hPrice" + ) as HTMLInputElement | null; + + expect(cacheRead).toBeTruthy(); + expect(cacheCreate5m).toBeTruthy(); + expect(cacheCreate1h).toBeTruthy(); + + await act(async () => { + setReactInputValue(cacheRead!, "0.2"); + setReactInputValue(cacheCreate5m!, "2.5"); + setReactInputValue(cacheCreate1h!, "4"); + }); + + const submit = Array.from(document.querySelectorAll("button")).find( + (el) => el.textContent?.trim() === "Confirm" + ); + expect(submit).toBeTruthy(); + + await act(async () => { + await flushPromises(); + submit?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await flushPromises(); + await flushPromises(); + }); + + expect(modelPricesActionMocks.upsertSingleModelPrice).toHaveBeenCalled(); + const payload = modelPricesActionMocks.upsertSingleModelPrice.mock.calls[0][0] as Record< + string, + unknown + >; + + expect(payload.modelName).toBe("custom/model-1"); + expect(payload.displayName).toBe("Custom Model 1"); + expect(payload.litellmProvider).toBe("openai"); + expect(payload.mode).toBe("chat"); + + // $/M -> 每 token + expect(payload.inputCostPerToken).toBeCloseTo(0.000002); + expect(payload.outputCostPerToken).toBeCloseTo(0.000004); + + // 按次价格不换算 + expect(payload.inputCostPerRequest).toBeCloseTo(0.005); + + // 缓存价格 $/M -> 每 token + expect(payload.cacheReadInputTokenCost).toBeCloseTo(0.0000002); + expect(payload.cacheCreationInputTokenCost).toBeCloseTo(0.0000025); + expect(payload.cacheCreationInputTokenCostAbove1hr).toBeCloseTo(0.000004); + + unmount(); + }); +}); diff --git a/tests/unit/settings/prices/price-list-interactions.test.tsx b/tests/unit/settings/prices/price-list-interactions.test.tsx new file mode 100644 index 000000000..aee21e164 --- /dev/null +++ b/tests/unit/settings/prices/price-list-interactions.test.tsx @@ -0,0 +1,385 @@ +/** + * @vitest-environment happy-dom + */ + +import fs from "node:fs"; +import path from "node:path"; +import type { ReactNode } from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { NextIntlClientProvider } from "next-intl"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { PriceList } from "@/app/[locale]/settings/prices/_components/price-list"; +import type { ModelPrice } from "@/types/model-price"; + +const clipboardMocks = vi.hoisted(() => ({ + copyToClipboard: vi.fn(async () => true), + isClipboardSupported: vi.fn(() => true), +})); +vi.mock("@/lib/utils/clipboard", () => clipboardMocks); + +const sonnerMocks = vi.hoisted(() => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})); +vi.mock("sonner", () => sonnerMocks); + +function loadMessages() { + const base = path.join(process.cwd(), "messages/en"); + const read = (name: string) => JSON.parse(fs.readFileSync(path.join(base, name), "utf8")); + + return { + common: read("common.json"), + errors: read("errors.json"), + ui: read("ui.json"), + forms: read("forms.json"), + settings: read("settings.json"), + }; +} + +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 flushPromises() { + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +function setReactInputValue(input: HTMLInputElement, value: string) { + const prototype = Object.getPrototypeOf(input) as HTMLInputElement; + const descriptor = Object.getOwnPropertyDescriptor(prototype, "value"); + descriptor?.set?.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); + input.dispatchEvent(new Event("change", { bubbles: true })); +} + +describe("PriceList: 交互与数据刷新", () => { + const messages = loadMessages(); + const now = new Date("2026-01-01T00:00:00.000Z"); + + const baseModel: ModelPrice = { + id: 1, + modelName: "base-model", + priceData: { + mode: "chat", + display_name: "Base Model", + litellm_provider: "openai", + input_cost_per_token: 0.000001, + output_cost_per_token: 0.000002, + supports_prompt_caching: false, + }, + source: "litellm", + createdAt: now, + updatedAt: now, + }; + + const originalFetch = globalThis.fetch; + + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = ""; + window.history.replaceState({}, "", "http://localhost:3000/settings/prices"); + }); + + afterEach(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + globalThis.fetch = originalFetch as any; + vi.useRealTimers(); + }); + + test("点击筛选按钮应触发拉取,并携带对应 query 参数", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + json: async () => ({ + ok: true, + data: { data: [baseModel], total: 1, page: 1, pageSize: 50 }, + }), + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + globalThis.fetch = fetchMock as any; + + const { unmount } = render( + + + + ); + + const openaiFilter = Array.from(document.querySelectorAll("button")).find((el) => + (el.textContent || "").includes("OpenAI") + ); + expect(openaiFilter).toBeTruthy(); + + await act(async () => { + openaiFilter?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await flushPromises(); + await flushPromises(); + }); + + expect(fetchMock).toHaveBeenCalled(); + const firstUrl = fetchMock.mock.calls[0][0] as string; + expect(firstUrl).toContain("litellmProvider=openai"); + + await act(async () => { + openaiFilter?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await flushPromises(); + await flushPromises(); + }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const secondUrl = fetchMock.mock.calls[1][0] as string; + expect(secondUrl).not.toContain("litellmProvider=openai"); + + unmount(); + }); + + test("点击 All 应清空筛选并触发拉取", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + json: async () => ({ + ok: true, + data: { data: [baseModel], total: 1, page: 1, pageSize: 20 }, + }), + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + globalThis.fetch = fetchMock as any; + + const { unmount } = render( + + + + ); + + const allFilter = Array.from(document.querySelectorAll("button")).find((el) => + (el.textContent || "").trim().toLowerCase().startsWith("all") + ); + expect(allFilter).toBeTruthy(); + + await act(async () => { + allFilter?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await flushPromises(); + await flushPromises(); + }); + + expect(fetchMock).toHaveBeenCalled(); + const url = fetchMock.mock.calls[0][0] as string; + expect(url).not.toContain("litellmProvider=openai"); + + unmount(); + }); + + test("分页:点击 Next 应请求下一页并更新页面数据", async () => { + const page2Model: ModelPrice = { ...baseModel, id: 2, modelName: "page-2-model" }; + const page2 = { + ok: true, + data: { data: [page2Model], total: 60, page: 2, pageSize: 50 }, + }; + + const fetchMock = vi.fn().mockResolvedValue({ json: async () => page2 }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + globalThis.fetch = fetchMock as any; + + const { unmount } = render( + + + + ); + + const nextButton = Array.from(document.querySelectorAll("button")).find((el) => + el.textContent?.includes("Next") + ) as HTMLButtonElement | undefined; + expect(nextButton).toBeTruthy(); + expect(nextButton?.disabled).toBe(false); + + await act(async () => { + nextButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await flushPromises(); + await flushPromises(); + }); + + expect(fetchMock).toHaveBeenCalled(); + const url = fetchMock.mock.calls[0][0] as string; + expect(url).toContain("page=2"); + + expect(document.body.textContent).toContain("page-2-model"); + + unmount(); + }); + + test("页面大小:切换 pageSize 应重新计算页码并重新请求", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + json: async () => ({ + ok: true, + data: { data: [baseModel], total: 60, page: 2, pageSize: 50 }, + }), + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + globalThis.fetch = fetchMock as any; + + const { unmount } = render( + + + + ); + + const trigger = document.querySelector( + "[data-slot='select-trigger']" + ) as HTMLButtonElement | null; + expect(trigger).toBeTruthy(); + + await act(async () => { + trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await flushPromises(); + await flushPromises(); + }); + + const option = Array.from(document.querySelectorAll("[data-slot='select-item']")).find( + (el) => (el.textContent || "").trim() === "50" + ); + expect(option).toBeTruthy(); + + await act(async () => { + option?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await flushPromises(); + await flushPromises(); + }); + + expect(fetchMock).toHaveBeenCalled(); + const url = fetchMock.mock.calls[0][0] as string; + expect(url).toContain("pageSize=50"); + expect(url).toContain("page=2"); + + unmount(); + }); + + test("搜索:输入后防抖触发请求,且应重置到第一页", async () => { + vi.useFakeTimers(); + + const fetchMock = vi.fn().mockResolvedValue({ + json: async () => ({ + ok: true, + data: { data: [baseModel], total: 1, page: 1, pageSize: 50 }, + }), + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + globalThis.fetch = fetchMock as any; + + const { unmount } = render( + + + + ); + + const searchInput = Array.from(document.querySelectorAll("input")).find( + (el) => (el as HTMLInputElement).placeholder === "Search model name..." + ) as HTMLInputElement | undefined; + expect(searchInput).toBeTruthy(); + + await act(async () => { + setReactInputValue(searchInput!, "gpt"); + }); + + await act(async () => { + vi.advanceTimersByTime(600); + }); + + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(fetchMock).toHaveBeenCalled(); + const url = fetchMock.mock.calls[0][0] as string; + expect(url).toContain("search=gpt"); + expect(url).toContain("page=1"); + + unmount(); + }); + + test("price-data-updated 事件应触发刷新请求", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + json: async () => ({ + ok: true, + data: { data: [baseModel], total: 1, page: 1, pageSize: 50 }, + }), + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + globalThis.fetch = fetchMock as any; + + const { unmount } = render( + + + + ); + + await act(async () => { + window.dispatchEvent(new Event("price-data-updated")); + await flushPromises(); + await flushPromises(); + }); + + expect(fetchMock).toHaveBeenCalled(); + + unmount(); + }); +}); diff --git a/tests/unit/settings/prices/price-list-ui-requirements.test.tsx b/tests/unit/settings/prices/price-list-ui-requirements.test.tsx new file mode 100644 index 000000000..b8da12425 --- /dev/null +++ b/tests/unit/settings/prices/price-list-ui-requirements.test.tsx @@ -0,0 +1,272 @@ +/** + * @vitest-environment happy-dom + */ + +import fs from "node:fs"; +import path from "node:path"; +import type { ReactNode } from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { NextIntlClientProvider } from "next-intl"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { PriceList } from "@/app/[locale]/settings/prices/_components/price-list"; +import type { ModelPrice } from "@/types/model-price"; + +const clipboardMocks = vi.hoisted(() => ({ + copyToClipboard: vi.fn(async () => true), + isClipboardSupported: vi.fn(() => true), +})); +vi.mock("@/lib/utils/clipboard", () => clipboardMocks); + +const sonnerMocks = vi.hoisted(() => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})); +vi.mock("sonner", () => sonnerMocks); + +function loadMessages() { + const base = path.join(process.cwd(), "messages/en"); + const read = (name: string) => JSON.parse(fs.readFileSync(path.join(base, name), "utf8")); + + return { + common: read("common.json"), + errors: read("errors.json"), + ui: read("ui.json"), + forms: read("forms.json"), + settings: read("settings.json"), + }; +} + +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(); + }, + }; +} + +describe("PriceList: UI 需求覆盖", () => { + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = ""; + }); + + test("应移除提供商列,并新增合并价格列与缓存列", () => { + const messages = loadMessages(); + const now = new Date("2026-01-01T00:00:00.000Z"); + + const prices: ModelPrice[] = [ + { + id: 1, + modelName: "model-id-001", + priceData: { + mode: "completion", + display_name: "Model Display", + litellm_provider: "openai", + input_cost_per_token: 0.000001, + output_cost_per_token: 0.000002, + supports_prompt_caching: true, + cache_read_input_token_cost: 0.0000001, + cache_creation_input_token_cost: 0.00000125, + cache_creation_input_token_cost_above_1hr: 0.000002, + }, + source: "litellm", + createdAt: now, + updatedAt: now, + }, + ]; + + const { unmount } = render( + + + + ); + + const headers = Array.from(document.querySelectorAll("thead th")).map((el) => + (el.textContent || "").trim() + ); + + expect(headers).toContain("Model Name"); + expect(headers).toContain("Capabilities"); + expect(headers).toContain("Price"); + expect(headers).toContain("Cache Read ($/M)"); + expect(headers).toContain("Cache Create ($/M)"); + expect(headers).toContain("Updated At"); + expect(headers).toContain("Actions"); + + expect(headers).not.toContain("Provider"); + expect(headers).not.toContain("Input Price ($/M)"); + expect(headers).not.toContain("Output Price ($/M)"); + + unmount(); + }); + + test("提供商应作为 badge 显示在模型名称列,且不再显示模型类型 badge", () => { + const messages = loadMessages(); + const now = new Date("2026-01-01T00:00:00.000Z"); + + const prices: ModelPrice[] = [ + { + id: 2, + modelName: "gpt-custom-1", + priceData: { + mode: "completion", + display_name: "My GPT", + litellm_provider: "openai", + input_cost_per_token: 0.000001, + output_cost_per_token: 0.000002, + }, + source: "litellm", + createdAt: now, + updatedAt: now, + }, + ]; + + const { unmount } = render( + + + + ); + + const badges = Array.from(document.querySelectorAll('[data-slot="badge"]')).map((el) => + (el.textContent || "").trim() + ); + expect(badges).toContain("openai"); + + expect(badges).not.toContain("Completion"); + expect(badges).not.toContain("Image"); + expect(badges).not.toContain("Unknown"); + + unmount(); + }); + + test("模型 ID 点击应复制到剪贴板并提示成功", async () => { + const messages = loadMessages(); + const now = new Date("2026-01-01T00:00:00.000Z"); + + const prices: ModelPrice[] = [ + { + id: 3, + modelName: "model-id-to-copy", + priceData: { + mode: "chat", + display_name: "Display Name", + litellm_provider: "openai", + input_cost_per_token: 0.000001, + output_cost_per_token: 0.000002, + }, + source: "litellm", + createdAt: now, + updatedAt: now, + }, + ]; + + const { unmount } = render( + + + + ); + + const copyButton = document.querySelector( + 'button[aria-label="Copy model ID"]' + ) as HTMLButtonElement | null; + expect(copyButton).toBeTruthy(); + + await act(async () => { + copyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(clipboardMocks.copyToClipboard).toHaveBeenCalledWith("model-id-to-copy"); + expect(sonnerMocks.toast.success).toHaveBeenCalled(); + expect(sonnerMocks.toast.error).not.toHaveBeenCalled(); + + unmount(); + }); + + test("复制失败时应提示错误", async () => { + clipboardMocks.copyToClipboard.mockResolvedValueOnce(false); + + const messages = loadMessages(); + const now = new Date("2026-01-01T00:00:00.000Z"); + + const prices: ModelPrice[] = [ + { + id: 4, + modelName: "model-id-copy-fail", + priceData: { + mode: "chat", + display_name: "Display Name", + litellm_provider: "openai", + input_cost_per_token: 0.000001, + output_cost_per_token: 0.000002, + }, + source: "litellm", + createdAt: now, + updatedAt: now, + }, + ]; + + const { unmount } = render( + + + + ); + + const copyButton = document.querySelector( + 'button[aria-label="Copy model ID"]' + ) as HTMLButtonElement | null; + expect(copyButton).toBeTruthy(); + + await act(async () => { + copyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(clipboardMocks.copyToClipboard).toHaveBeenCalledWith("model-id-copy-fail"); + expect(sonnerMocks.toast.error).toHaveBeenCalled(); + + unmount(); + }); +}); diff --git a/tests/unit/settings/prices/upload-price-dialog-cloud-model-count.test.tsx b/tests/unit/settings/prices/upload-price-dialog-cloud-model-count.test.tsx new file mode 100644 index 000000000..3ed727fe7 --- /dev/null +++ b/tests/unit/settings/prices/upload-price-dialog-cloud-model-count.test.tsx @@ -0,0 +1,108 @@ +/** + * @vitest-environment happy-dom + */ + +import fs from "node:fs"; +import path from "node:path"; +import type { ReactNode } from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { NextIntlClientProvider } from "next-intl"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { UploadPriceDialog } from "@/app/[locale]/settings/prices/_components/upload-price-dialog"; + +// 测试环境不加载 next/navigation 的真实实现(避免 Next.js 运行时依赖) +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), refresh: vi.fn() }), +})); + +const sonnerMocks = vi.hoisted(() => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})); +vi.mock("sonner", () => sonnerMocks); + +function loadMessages() { + const base = path.join(process.cwd(), "messages/en"); + const read = (name: string) => JSON.parse(fs.readFileSync(path.join(base, name), "utf8")); + + return { + common: read("common.json"), + errors: read("errors.json"), + ui: read("ui.json"), + forms: read("forms.json"), + settings: read("settings.json"), + }; +} + +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 flushPromises() { + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +describe("UploadPriceDialog: 云端模型数量应异步显示", () => { + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = ""; + }); + + test("打开弹窗后应先显示加载态,随后展示云端模型数量", async () => { + const messages = loadMessages(); + const originalFetch = globalThis.fetch; + + let resolveFetch: ((value: unknown) => void) | null = null; + const fetchMock = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }) + ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + globalThis.fetch = fetchMock as any; + + const { unmount } = render( + + + + ); + + expect(document.body.textContent).toContain("System has built-in price table"); + expect(document.body.textContent).toContain( + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + (messages as any).settings.prices.dialog.cloudModelCountLoading + ); + + resolveFetch?.({ + json: async () => ({ ok: true, data: { count: 123 } }), + }); + + await act(async () => { + await flushPromises(); + await flushPromises(); + }); + + expect(fetchMock).toHaveBeenCalled(); + expect(document.body.textContent).toContain("Currently supports 123 models"); + + globalThis.fetch = originalFetch; + unmount(); + }); +}); diff --git a/tests/unit/settings/prices/upload-price-dialog-upload-flow.test.tsx b/tests/unit/settings/prices/upload-price-dialog-upload-flow.test.tsx new file mode 100644 index 000000000..13be52972 --- /dev/null +++ b/tests/unit/settings/prices/upload-price-dialog-upload-flow.test.tsx @@ -0,0 +1,427 @@ +/** + * @vitest-environment happy-dom + */ + +import fs from "node:fs"; +import path from "node:path"; +import type { ReactNode } from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { NextIntlClientProvider } from "next-intl"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { UploadPriceDialog } from "@/app/[locale]/settings/prices/_components/upload-price-dialog"; +import type { PriceUpdateResult } from "@/types/model-price"; + +const modelPricesActionMocks = vi.hoisted(() => ({ + uploadPriceTable: vi.fn(async () => ({ ok: true, data: null as PriceUpdateResult | null })), +})); +vi.mock("@/actions/model-prices", () => modelPricesActionMocks); + +const sonnerMocks = vi.hoisted(() => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})); +vi.mock("sonner", () => sonnerMocks); + +const navigationMocks = vi.hoisted(() => { + const push = vi.fn(); + return { + __push: push, + useRouter: () => ({ push }), + }; +}); +vi.mock("next/navigation", () => ({ + useRouter: navigationMocks.useRouter, +})); + +function loadMessages() { + const base = path.join(process.cwd(), "messages/en"); + const read = (name: string) => JSON.parse(fs.readFileSync(path.join(base, name), "utf8")); + + return { + common: read("common.json"), + errors: read("errors.json"), + ui: read("ui.json"), + forms: read("forms.json"), + settings: read("settings.json"), + }; +} + +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 flushPromises() { + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +function setFileInput(input: HTMLInputElement, file: File) { + Object.defineProperty(input, "files", { + configurable: true, + value: [file], + }); +} + +describe("UploadPriceDialog: 上传流程", () => { + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = ""; + }); + + test("选择非 JSON/TOML 文件应提示错误并跳过上传", async () => { + const messages = loadMessages(); + const originalFetch = globalThis.fetch; + const fetchMock = vi.fn().mockResolvedValue({ + json: async () => ({ ok: true, data: { count: 1 } }), + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + globalThis.fetch = fetchMock as any; + + const { unmount } = render( + + + + ); + + const input = document.getElementById("price-file-input") as HTMLInputElement | null; + expect(input).toBeTruthy(); + + const file = new File(["x"], "prices.txt", { type: "text/plain" }); + setFileInput(input!, file); + + await act(async () => { + input?.dispatchEvent(new Event("change", { bubbles: true })); + await flushPromises(); + }); + + expect(modelPricesActionMocks.uploadPriceTable).not.toHaveBeenCalled(); + expect(sonnerMocks.toast.error).toHaveBeenCalled(); + + globalThis.fetch = originalFetch; + unmount(); + }); + + test("文件过大应提示错误并跳过上传", async () => { + const messages = loadMessages(); + const originalFetch = globalThis.fetch; + const fetchMock = vi.fn().mockResolvedValue({ + json: async () => ({ ok: true, data: { count: 1 } }), + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + globalThis.fetch = fetchMock as any; + + const { unmount } = render( + + + + ); + + const input = document.getElementById("price-file-input") as HTMLInputElement | null; + expect(input).toBeTruthy(); + + const file = new File(["{}"], "prices.json", { type: "application/json" }); + Object.defineProperty(file, "size", { configurable: true, value: 10 * 1024 * 1024 + 1 }); + setFileInput(input!, file); + + await act(async () => { + input?.dispatchEvent(new Event("change", { bubbles: true })); + await flushPromises(); + }); + + expect(modelPricesActionMocks.uploadPriceTable).not.toHaveBeenCalled(); + expect(sonnerMocks.toast.error).toHaveBeenCalled(); + + globalThis.fetch = originalFetch; + unmount(); + }); + + test("上传成功应展示结果并触发 price-data-updated 事件", async () => { + const messages = loadMessages(); + const originalFetch = globalThis.fetch; + const fetchMock = vi.fn().mockResolvedValue({ + json: async () => ({ ok: true, data: { count: 1 } }), + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + globalThis.fetch = fetchMock as any; + + const result: PriceUpdateResult = { + added: ["a", "b"], + updated: ["c"], + unchanged: [], + failed: [], + total: 3, + }; + modelPricesActionMocks.uploadPriceTable.mockResolvedValueOnce({ ok: true, data: result }); + + const priceUpdatedListener = vi.fn(); + window.addEventListener("price-data-updated", priceUpdatedListener); + + const { unmount } = render( + + + + ); + + const input = document.getElementById("price-file-input") as HTMLInputElement | null; + expect(input).toBeTruthy(); + + const file = new File(["{}"], "prices.json", { type: "application/json" }); + setFileInput(input!, file); + + await act(async () => { + input?.dispatchEvent(new Event("change", { bubbles: true })); + await flushPromises(); + await flushPromises(); + }); + + expect(modelPricesActionMocks.uploadPriceTable).toHaveBeenCalledWith("{}"); + expect(sonnerMocks.toast.success).toHaveBeenCalled(); + expect(priceUpdatedListener).toHaveBeenCalled(); + + // 结果视图应出现 total 数字 + expect(document.body.textContent).toContain("3"); + + window.removeEventListener("price-data-updated", priceUpdatedListener); + globalThis.fetch = originalFetch; + unmount(); + }); + + test("上传成功包含 failed/unchanged 时应渲染对应区块", async () => { + const messages = loadMessages(); + const originalFetch = globalThis.fetch; + const fetchMock = vi.fn().mockResolvedValue({ + json: async () => ({ ok: true, data: { count: 1 } }), + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + globalThis.fetch = fetchMock as any; + + const result: PriceUpdateResult = { + added: [], + updated: [], + unchanged: ["same"], + failed: ["bad-model"], + total: 2, + }; + modelPricesActionMocks.uploadPriceTable.mockResolvedValueOnce({ ok: true, data: result }); + + const { unmount } = render( + + + + ); + + const input = document.getElementById("price-file-input") as HTMLInputElement | null; + expect(input).toBeTruthy(); + + const file = new File(["{}"], "prices.json", { type: "application/json" }); + setFileInput(input!, file); + + await act(async () => { + input?.dispatchEvent(new Event("change", { bubbles: true })); + await flushPromises(); + await flushPromises(); + }); + + expect(document.body.textContent).toContain("Failed:"); + expect(document.body.textContent).toContain("Skipped:"); + + globalThis.fetch = originalFetch; + unmount(); + }); + + test("上传失败(ok=false)应提示 updateFailed", async () => { + const messages = loadMessages(); + const originalFetch = globalThis.fetch; + const fetchMock = vi.fn().mockResolvedValue({ + json: async () => ({ ok: true, data: { count: 1 } }), + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + globalThis.fetch = fetchMock as any; + + modelPricesActionMocks.uploadPriceTable.mockResolvedValueOnce({ ok: false, error: "bad" }); + + const { unmount } = render( + + + + ); + + const input = document.getElementById("price-file-input") as HTMLInputElement | null; + expect(input).toBeTruthy(); + + const file = new File(["{}"], "prices.json", { type: "application/json" }); + setFileInput(input!, file); + + await act(async () => { + input?.dispatchEvent(new Event("change", { bubbles: true })); + await flushPromises(); + await flushPromises(); + }); + + expect(modelPricesActionMocks.uploadPriceTable).toHaveBeenCalled(); + expect(sonnerMocks.toast.error).toHaveBeenCalled(); + + globalThis.fetch = originalFetch; + unmount(); + }); + + test("上传失败(data=null)应提示 updateFailed", async () => { + const messages = loadMessages(); + const originalFetch = globalThis.fetch; + const fetchMock = vi.fn().mockResolvedValue({ + json: async () => ({ ok: true, data: { count: 1 } }), + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + globalThis.fetch = fetchMock as any; + + modelPricesActionMocks.uploadPriceTable.mockResolvedValueOnce({ ok: true, data: null }); + + const { unmount } = render( + + + + ); + + const input = document.getElementById("price-file-input") as HTMLInputElement | null; + expect(input).toBeTruthy(); + + const file = new File(["{}"], "prices.json", { type: "application/json" }); + setFileInput(input!, file); + + await act(async () => { + input?.dispatchEvent(new Event("change", { bubbles: true })); + await flushPromises(); + await flushPromises(); + }); + + expect(modelPricesActionMocks.uploadPriceTable).toHaveBeenCalled(); + expect(sonnerMocks.toast.error).toHaveBeenCalled(); + expect(sonnerMocks.toast.success).not.toHaveBeenCalled(); + + globalThis.fetch = originalFetch; + unmount(); + }); + + test("uploading=true 时应阻止关闭对话框", async () => { + const messages = loadMessages(); + const originalFetch = globalThis.fetch; + const fetchMock = vi.fn().mockResolvedValue({ + json: async () => ({ ok: true, data: { count: 1 } }), + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + globalThis.fetch = fetchMock as any; + + let resolveUpload: ((value: unknown) => void) | null = null; + const uploadPromise = new Promise((resolve) => { + resolveUpload = resolve; + }); + modelPricesActionMocks.uploadPriceTable.mockReturnValueOnce(uploadPromise as never); + + const { unmount } = render( + + + + ); + + const input = document.getElementById("price-file-input") as HTMLInputElement | null; + expect(input).toBeTruthy(); + + const file = new File(["{}"], "prices.json", { type: "application/json" }); + setFileInput(input!, file); + + await act(async () => { + input?.dispatchEvent(new Event("change", { bubbles: true })); + await flushPromises(); + }); + + const closeButton = document.querySelector( + '[data-slot="dialog-close"]' + ) as HTMLButtonElement | null; + expect(closeButton).toBeTruthy(); + + await act(async () => { + closeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await flushPromises(); + }); + + // uploading 阶段关闭应被阻止:对话框内容仍存在 + expect(document.querySelector('[data-slot="dialog-content"]')).toBeTruthy(); + + resolveUpload?.({ ok: false, error: "bad" }); + + await act(async () => { + await flushPromises(); + await flushPromises(); + }); + + globalThis.fetch = originalFetch; + unmount(); + }); + + test("isRequired=true 且有更新时,点击底部按钮应跳转 /dashboard", async () => { + const messages = loadMessages(); + const originalFetch = globalThis.fetch; + const fetchMock = vi.fn().mockResolvedValue({ + json: async () => ({ ok: true, data: { count: 1 } }), + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + globalThis.fetch = fetchMock as any; + + const result: PriceUpdateResult = { + added: ["a"], + updated: [], + unchanged: [], + failed: [], + total: 1, + }; + modelPricesActionMocks.uploadPriceTable.mockResolvedValueOnce({ ok: true, data: result }); + + const { unmount } = render( + + + + ); + + const input = document.getElementById("price-file-input") as HTMLInputElement | null; + expect(input).toBeTruthy(); + + const file = new File(["{}"], "prices.json", { type: "application/json" }); + setFileInput(input!, file); + + await act(async () => { + input?.dispatchEvent(new Event("change", { bubbles: true })); + await flushPromises(); + await flushPromises(); + }); + + const footerButton = Array.from(document.querySelectorAll("button")).find( + (el) => el.textContent?.trim() === "Confirm" + ); + expect(footerButton).toBeTruthy(); + + await act(async () => { + footerButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await flushPromises(); + }); + + expect(navigationMocks.__push).toHaveBeenCalledWith("/dashboard"); + + globalThis.fetch = originalFetch; + unmount(); + }); +}); From d0cb42bda9081a9bee584a88aeef4587c73de6cd Mon Sep 17 00:00:00 2001 From: Ding <44717411+ding113@users.noreply.github.com> Date: Sun, 11 Jan 2026 01:10:20 +0800 Subject: [PATCH 14/17] docs: add OpenCode setup guide (#582) * docs: add OpenCode config guide to usage-doc * docs: fix OpenCode Scoop command and refactor config snippet * docs: remove OpenAI-compatible wording from OpenCode guide * docs: refine OpenCode install and config --- messages/en/usage.json | 102 ++++++ messages/ja/usage.json | 102 ++++++ messages/ru/usage.json | 102 ++++++ messages/zh-CN/usage.json | 102 ++++++ messages/zh-TW/usage.json | 102 ++++++ .../usage-doc/_components/quick-links.tsx | 7 +- .../usage-doc/_components/toc-nav.tsx | 7 +- src/app/[locale]/usage-doc/page.tsx | 296 ++++++++++++++++-- .../usage-doc/opencode-usage-doc.test.tsx | 125 ++++++++ tests/unit/usage-doc/usage-doc-page.test.tsx | 108 +++++++ 10 files changed, 1028 insertions(+), 25 deletions(-) create mode 100644 tests/unit/usage-doc/opencode-usage-doc.test.tsx create mode 100644 tests/unit/usage-doc/usage-doc-page.test.tsx diff --git a/messages/en/usage.json b/messages/en/usage.json index 126a9cca8..27b7ea6db 100644 --- a/messages/en/usage.json +++ b/messages/en/usage.json @@ -473,6 +473,108 @@ } }, + "opencode": { + "title": "OpenCode Usage Guide", + "description": "OpenCode is a CLI + TUI AI coding agent that runs in your terminal and also provides IDE integrations. You can point OpenCode to cch as a single entry to access Claude, GPT, and Gemini models.", + + "installation": { + "title": "Install", + + "macos": { + "description": "On macOS, install OpenCode using one of the following methods:", + "homebrew": { + "title": "Option 2: Homebrew", + "description": "Install via Homebrew:" + } + }, + + "linux": { + "description": "On Linux, install OpenCode using one of the following methods:", + "homebrew": { + "title": "Option 2: Homebrew", + "description": "Install via Homebrew:" + }, + "paru": { + "title": "Option 5: Paru (Arch Linux)", + "description": "Install via paru (AUR):" + } + }, + + "script": { + "title": "Option 1: Official install script", + "description": "Run the following command to install the latest version:" + }, + + "npm": { + "title": "Option 3: npm", + "description": "Install globally via npm:" + }, + + "bun": { + "title": "Option 4: Bun", + "description": "Install globally via Bun:" + }, + + "windows": { + "description": "On Windows, we recommend a package manager (Chocolatey/Scoop); npm also works:", + "choco": { + "title": "Option 1: Chocolatey", + "description": "Install via Chocolatey:", + "command": "choco install opencode" + }, + "scoop": { + "title": "Option 2: Scoop", + "description": "Install via Scoop:", + "command": "scoop bucket add extras\nscoop install extras/opencode" + }, + "note": "Note: Bun on Windows is still in progress. Use Chocolatey/Scoop/npm, or download a binary from GitHub Releases." + } + }, + + "configuration": { + "title": "Connect to cch service", + + "configFile": { + "title": "Configure opencode.json", + "path": "Config file path:", + "instruction": "Edit the file and add the following content (one file covers all models):", + + "important": "Important", + "importantPoints": [ + "Create an API key in the cch console and set the CCH_API_KEY environment variable", + "All providers use ${resolvedOrigin}/v1 as baseURL (cch v1 API base URL)", + "When selecting models, use provider_id/model_id (e.g. cchClaude/claude-sonnet-4-5-20250929)" + ] + }, + + "modelSelection": { + "title": "Select a model", + "description": "After starting OpenCode, use the following command in the TUI to view/select models:", + "command": "/models" + } + }, + + "startup": { + "title": "Start opencode", + "description": "Run in your project directory:", + "initNote": "On first launch, opencode loads the config and creates a session." + }, + + "commonIssues": { + "title": "Common issues", + + "commandNotFound": "1. Command not found", + "commandNotFoundWindows": [ + "If installed via npm, make sure your global npm bin directory is on PATH", + "Open a new terminal and try again" + ], + "commandNotFoundUnix": "Check the install location and add it to PATH (e.g. ~/.local/bin or npm global bin)", + + "connectionFailed": "2. API connection failed", + "updateCli": "3. Update opencode" + } + }, + "droid": { "title": "Droid CLI Usage Guide", "description": "Droid is an interactive terminal AI programming assistant developed by Factory AI that supports integration through the cch proxy service. You must register and log in to your Droid official account before use.", diff --git a/messages/ja/usage.json b/messages/ja/usage.json index e9648d5cb..d2c8b9509 100644 --- a/messages/ja/usage.json +++ b/messages/ja/usage.json @@ -473,6 +473,108 @@ } }, + "opencode": { + "title": "OpenCode 利用ガイド", + "description": "OpenCode はターミナルで動作する CLI + TUI の AI コーディングエージェントで、IDE 連携も提供します。OpenCode の接続先を cch に設定することで、Claude / GPT / Gemini の各モデルを利用できます。", + + "installation": { + "title": "インストール", + + "macos": { + "description": "macOS では以下のいずれかの方法で OpenCode をインストールできます:", + "homebrew": { + "title": "方法2:Homebrew", + "description": "Homebrew でインストール:" + } + }, + + "linux": { + "description": "Linux では以下のいずれかの方法で OpenCode をインストールできます:", + "homebrew": { + "title": "方法2:Homebrew", + "description": "Homebrew でインストール:" + }, + "paru": { + "title": "方法5:Paru(Arch Linux)", + "description": "Arch Linux の場合、paru(AUR)でインストール:" + } + }, + + "script": { + "title": "方法1:公式インストールスクリプト", + "description": "最新版をインストールするには次のコマンドを実行します:" + }, + + "npm": { + "title": "方法3:npm", + "description": "npm でグローバルにインストール:" + }, + + "bun": { + "title": "方法4:Bun", + "description": "Bun でグローバルにインストール:" + }, + + "windows": { + "description": "Windows ではパッケージマネージャ(Chocolatey/Scoop)を推奨します。npm でもインストールできます:", + "choco": { + "title": "方法1:Chocolatey", + "description": "Chocolatey でインストール:", + "command": "choco install opencode" + }, + "scoop": { + "title": "方法2:Scoop", + "description": "Scoop でインストール:", + "command": "scoop bucket add extras\nscoop install extras/opencode" + }, + "note": "注: Windows での Bun インストールは現在対応中です。Chocolatey/Scoop/npm を使用するか、GitHub Releases からバイナリを取得してください。" + } + }, + + "configuration": { + "title": "cch サービスに接続", + + "configFile": { + "title": "opencode.json を設定", + "path": "設定ファイルのパス:", + "instruction": "設定ファイルを編集し、以下を追加します(1 つの設定ファイルで全モデルをカバーします):", + + "important": "重要", + "importantPoints": [ + "cch の管理画面で API Key を作成し、環境変数 CCH_API_KEY を設定してください", + "3 つの provider すべてで baseURL は ${resolvedOrigin}/v1(cch の v1 API ベース URL)", + "モデル選択は provider_id/model_id 形式(例:cchClaude/claude-sonnet-4-5-20250929)" + ] + }, + + "modelSelection": { + "title": "モデルを選択", + "description": "OpenCode を起動したら、TUI で次のコマンドを入力してモデルを表示/選択します:", + "command": "/models" + } + }, + + "startup": { + "title": "opencode を起動", + "description": "プロジェクトディレクトリで次を実行します:", + "initNote": "初回起動時に、opencode は設定を読み込み、セッションを作成します。" + }, + + "commonIssues": { + "title": "よくある問題", + + "commandNotFound": "1. コマンドが見つからない", + "commandNotFoundWindows": [ + "npm でインストールした場合、npm のグローバル bin パスが PATH に含まれているか確認してください", + "新しいターミナルを開いて再試行してください" + ], + "commandNotFoundUnix": "インストール先を確認し、PATH に追加してください(例:~/.local/bin または npm のグローバル bin)", + + "connectionFailed": "2. API 接続に失敗", + "updateCli": "3. opencode を更新" + } + }, + "droid": { "title": "Droid CLI 使用ガイド", "description": "Droid は Factory AI が開発したインタラクティブターミナル AI プログラミングアシスタントで、cch プロキシサービスを通じた統合をサポートしています。使用する前に、Droid 公式アカウントに登録してログインする必要があります。", diff --git a/messages/ru/usage.json b/messages/ru/usage.json index d9461b47e..44b1cc368 100644 --- a/messages/ru/usage.json +++ b/messages/ru/usage.json @@ -473,6 +473,108 @@ } }, + "opencode": { + "title": "Руководство по OpenCode", + "description": "OpenCode — это CLI + TUI агент для программирования в терминале, также есть интеграции для IDE. Вы можете настроить OpenCode на cch как единый вход для доступа к моделям Claude, GPT и Gemini.", + + "installation": { + "title": "Установка", + + "macos": { + "description": "В macOS установите OpenCode одним из следующих способов:", + "homebrew": { + "title": "Способ 2: Homebrew", + "description": "Установка через Homebrew:" + } + }, + + "linux": { + "description": "В Linux установите OpenCode одним из следующих способов:", + "homebrew": { + "title": "Способ 2: Homebrew", + "description": "Установка через Homebrew:" + }, + "paru": { + "title": "Способ 5: Paru (Arch Linux)", + "description": "Для Arch Linux можно установить через paru (AUR):" + } + }, + + "script": { + "title": "Способ 1: Официальный install-скрипт", + "description": "Выполните команду для установки последней версии:" + }, + + "npm": { + "title": "Способ 3: npm", + "description": "Глобальная установка через npm:" + }, + + "bun": { + "title": "Способ 4: Bun", + "description": "Глобальная установка через Bun:" + }, + + "windows": { + "description": "В Windows рекомендуем использовать пакетный менеджер (Chocolatey/Scoop), либо npm:", + "choco": { + "title": "Способ 1: Chocolatey", + "description": "Установка через Chocolatey:", + "command": "choco install opencode" + }, + "scoop": { + "title": "Способ 2: Scoop", + "description": "Установка через Scoop:", + "command": "scoop bucket add extras\nscoop install extras/opencode" + }, + "note": "Примечание: поддержка установки через Bun в Windows пока в процессе. Используйте Chocolatey/Scoop/npm или скачайте бинарник из GitHub Releases." + } + }, + + "configuration": { + "title": "Подключение к cch", + + "configFile": { + "title": "Настройка opencode.json", + "path": "Путь к конфигурационному файлу:", + "instruction": "Отредактируйте файл и добавьте следующий контент (один файл покрывает все модели):", + + "important": "Важно", + "importantPoints": [ + "Создайте API key в панели cch и задайте переменную окружения CCH_API_KEY", + "Все provider используют ${resolvedOrigin}/v1 как baseURL (базовый URL cch v1 API)", + "При выборе модели используйте provider_id/model_id (например, cchClaude/claude-sonnet-4-5-20250929)" + ] + }, + + "modelSelection": { + "title": "Выбор модели", + "description": "После запуска OpenCode в TUI выполните команду для просмотра/выбора моделей:", + "command": "/models" + } + }, + + "startup": { + "title": "Запуск opencode", + "description": "В каталоге проекта выполните:", + "initNote": "При первом запуске opencode загрузит конфигурацию и создаст сессию." + }, + + "commonIssues": { + "title": "Типичные проблемы", + + "commandNotFound": "1. Команда не найдена", + "commandNotFoundWindows": [ + "Если установили через npm, убедитесь, что глобальный npm bin добавлен в PATH", + "Откройте новый терминал и повторите" + ], + "commandNotFoundUnix": "Проверьте путь установки и добавьте его в PATH (например, ~/.local/bin или глобальный npm bin)", + + "connectionFailed": "2. Сбой подключения API", + "updateCli": "3. Обновление opencode" + } + }, + "droid": { "title": "Руководство по использованию Droid CLI", "description": "Droid - это интерактивный помощник программирования на основе ИИ, разработанный Factory AI, поддерживающий интеграцию через прокси-сервис cch. Перед использованием вы должны зарегистрировать и войти в свой официальный аккаунт Droid.", diff --git a/messages/zh-CN/usage.json b/messages/zh-CN/usage.json index fe5d700ca..0c5a0085a 100644 --- a/messages/zh-CN/usage.json +++ b/messages/zh-CN/usage.json @@ -469,6 +469,108 @@ } }, + "opencode": { + "title": "OpenCode 使用指南", + "description": "OpenCode 是一款在终端中运行的 CLI + TUI AI 编程代理工具,也提供 IDE 插件集成。你可以将 OpenCode 指向 cch 作为统一入口来接入 Claude、GPT 与 Gemini 等模型。", + + "installation": { + "title": "安装", + + "macos": { + "description": "在 macOS 上可以选择以下任一种方式安装 OpenCode:", + "homebrew": { + "title": "方式二:Homebrew", + "description": "也可以使用 Homebrew 安装:" + } + }, + + "linux": { + "description": "在 Linux 上可以选择以下任一种方式安装 OpenCode:", + "homebrew": { + "title": "方式二:Homebrew", + "description": "也可以使用 Homebrew 安装:" + }, + "paru": { + "title": "方式五:Paru(Arch Linux)", + "description": "如果你使用 Arch Linux,也可以通过 paru(AUR)安装:" + } + }, + + "script": { + "title": "方式一:官方安装脚本", + "description": "执行以下命令安装最新版:" + }, + + "npm": { + "title": "方式三:npm", + "description": "也可以通过 npm 全局安装:" + }, + + "bun": { + "title": "方式四:Bun", + "description": "如果你使用 Bun,也可以全局安装:" + }, + + "windows": { + "description": "Windows 推荐使用包管理器(Chocolatey/Scoop),也可以使用 npm:", + "choco": { + "title": "方式一:Chocolatey", + "description": "使用 Chocolatey 安装:", + "command": "choco install opencode" + }, + "scoop": { + "title": "方式二:Scoop", + "description": "使用 Scoop 安装:", + "command": "scoop bucket add extras\nscoop install extras/opencode" + }, + "note": "提示:官方说明 Windows 上通过 Bun 安装仍在推进。建议使用 Chocolatey/Scoop/npm,或从 GitHub Releases 下载二进制。" + } + }, + + "configuration": { + "title": "连接 cch 服务", + + "configFile": { + "title": "配置 opencode.json", + "path": "配置文件路径:", + "instruction": "编辑配置文件,写入以下内容(只需一份配置文件即可覆盖全部模型):", + + "important": "重要说明", + "importantPoints": [ + "请先在 cch 后台创建 API Key,并设置环境变量 CCH_API_KEY", + "三个 provider 的 baseURL 都使用 ${resolvedOrigin}/v1(cch v1 API 地址)", + "模型选择时使用 provider_id/model_id 格式(例如 cchClaude/claude-sonnet-4-5-20250929)" + ] + }, + + "modelSelection": { + "title": "选择模型", + "description": "启动 OpenCode 后,在 TUI 中输入以下命令查看/选择模型:", + "command": "/models" + } + }, + + "startup": { + "title": "启动 opencode", + "description": "在项目目录下运行:", + "initNote": "首次启动时,opencode 会加载配置并创建会话。" + }, + + "commonIssues": { + "title": "常见问题", + + "commandNotFound": "1. 命令未找到", + "commandNotFoundWindows": [ + "如果使用 npm 安装,请确保 npm 全局路径已添加到系统 PATH", + "重新打开终端窗口后再试" + ], + "commandNotFoundUnix": "检查安装路径并添加到 PATH(例如 ~/.local/bin 或 npm 全局目录)", + + "connectionFailed": "2. API 连接失败", + "updateCli": "3. 更新 opencode" + } + }, + "droid": { "title": "Droid CLI 使用指南", "description": "Droid 是 Factory AI 开发的交互式终端 AI 编程助手,支持通过 cch 代理服务使用。使用前必须先注册并登录 Droid 官方账号。", diff --git a/messages/zh-TW/usage.json b/messages/zh-TW/usage.json index e2956a282..b875c0a56 100644 --- a/messages/zh-TW/usage.json +++ b/messages/zh-TW/usage.json @@ -469,6 +469,108 @@ } }, + "opencode": { + "title": "OpenCode 使用指南", + "description": "OpenCode 是一款在終端中運行的 CLI + TUI AI 編程代理工具,也提供 IDE 插件集成。你可以將 OpenCode 指向 cch 作為統一入口接入 Claude、GPT 與 Gemini 等模型。", + + "installation": { + "title": "安裝", + + "macos": { + "description": "在 macOS 上可以選擇以下任一方式安裝 OpenCode:", + "homebrew": { + "title": "方式二:Homebrew", + "description": "也可以使用 Homebrew 安裝:" + } + }, + + "linux": { + "description": "在 Linux 上可以選擇以下任一方式安裝 OpenCode:", + "homebrew": { + "title": "方式二:Homebrew", + "description": "也可以使用 Homebrew 安裝:" + }, + "paru": { + "title": "方式五:Paru(Arch Linux)", + "description": "如果你使用 Arch Linux,也可以通過 paru(AUR)安裝:" + } + }, + + "script": { + "title": "方式一:官方安裝腳本", + "description": "執行以下命令安裝最新版:" + }, + + "npm": { + "title": "方式三:npm", + "description": "也可以通過 npm 全局安裝:" + }, + + "bun": { + "title": "方式四:Bun", + "description": "如果你使用 Bun,也可以全局安裝:" + }, + + "windows": { + "description": "Windows 推薦使用包管理器(Chocolatey/Scoop),也可以使用 npm:", + "choco": { + "title": "方式一:Chocolatey", + "description": "使用 Chocolatey 安裝:", + "command": "choco install opencode" + }, + "scoop": { + "title": "方式二:Scoop", + "description": "使用 Scoop 安裝:", + "command": "scoop bucket add extras\nscoop install extras/opencode" + }, + "note": "提示:官方說明 Windows 上通過 Bun 安裝仍在推進。建議使用 Chocolatey/Scoop/npm,或從 GitHub Releases 下載二進制。" + } + }, + + "configuration": { + "title": "連接 cch 服務", + + "configFile": { + "title": "配置 opencode.json", + "path": "配置文件路徑:", + "instruction": "編輯配置文件,寫入以下內容(只需一份配置文件即可覆蓋全部模型):", + + "important": "重要說明", + "importantPoints": [ + "請先在 cch 後台創建 API Key,並設置環境變量 CCH_API_KEY", + "三個 provider 的 baseURL 都使用 ${resolvedOrigin}/v1(cch v1 API 地址)", + "模型選擇時使用 provider_id/model_id 格式(例如 cchClaude/claude-sonnet-4-5-20250929)" + ] + }, + + "modelSelection": { + "title": "選擇模型", + "description": "啟動 OpenCode 後,在 TUI 中輸入以下命令查看/選擇模型:", + "command": "/models" + } + }, + + "startup": { + "title": "啟動 opencode", + "description": "在項目目錄下運行:", + "initNote": "首次啟動時,opencode 會加載配置並創建會話。" + }, + + "commonIssues": { + "title": "常見問題", + + "commandNotFound": "1. 命令未找到", + "commandNotFoundWindows": [ + "如果使用 npm 安裝,請確保 npm 全局路徑已添加到系統 PATH", + "重新打開終端窗口後再試" + ], + "commandNotFoundUnix": "檢查安裝路徑並添加到 PATH(例如 ~/.local/bin 或 npm 全局目錄)", + + "connectionFailed": "2. API 連接失敗", + "updateCli": "3. 更新 opencode" + } + }, + "droid": { "title": "Droid CLI 使用指南", "description": "Droid 是 Factory AI 開發的交互式終端 AI 編程助手,支持通過 cch 代理服務使用。使用前必須先註冊並登錄 Droid 官方賬號。", diff --git a/src/app/[locale]/usage-doc/_components/quick-links.tsx b/src/app/[locale]/usage-doc/_components/quick-links.tsx index d51698723..45fdc2ade 100644 --- a/src/app/[locale]/usage-doc/_components/quick-links.tsx +++ b/src/app/[locale]/usage-doc/_components/quick-links.tsx @@ -1,5 +1,6 @@ "use client"; +import { useTranslations } from "next-intl"; import { Link } from "@/i18n/routing"; interface QuickLinksProps { @@ -12,6 +13,8 @@ interface QuickLinksProps { * 支持桌面端和移动端复用 */ export function QuickLinks({ isLoggedIn, onBackToTop }: QuickLinksProps) { + const t = useTranslations("usage"); + const handleBackToTop = () => { window.scrollTo({ top: 0, behavior: "smooth" }); onBackToTop?.(); @@ -24,14 +27,14 @@ export function QuickLinks({ isLoggedIn, onBackToTop }: QuickLinksProps) { href="/dashboard" className="block text-sm text-muted-foreground hover:text-primary transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 rounded px-2 py-1" > - 返回仪表盘 + {t("navigation.backToDashboard")} )}
); diff --git a/src/app/[locale]/usage-doc/_components/toc-nav.tsx b/src/app/[locale]/usage-doc/_components/toc-nav.tsx index c70b8beda..f7df3a331 100644 --- a/src/app/[locale]/usage-doc/_components/toc-nav.tsx +++ b/src/app/[locale]/usage-doc/_components/toc-nav.tsx @@ -1,5 +1,6 @@ "use client"; +import { useTranslations } from "next-intl"; import { Skeleton } from "@/components/ui/skeleton"; import { cn } from "@/lib/utils"; @@ -24,8 +25,10 @@ interface TocNavProps { * 支持桌面端和移动端复用 */ export function TocNav({ tocItems, activeId, tocReady, onItemClick }: TocNavProps) { + const t = useTranslations("usage"); + return ( -
+

{t("opencode.installation.npm.note")}

{t("opencode.installation.bun.title")}

{t("opencode.installation.bun.description")}

diff --git a/tests/unit/usage-doc/opencode-usage-doc.test.tsx b/tests/unit/usage-doc/opencode-usage-doc.test.tsx index f0adc8baa..3016735c7 100644 --- a/tests/unit/usage-doc/opencode-usage-doc.test.tsx +++ b/tests/unit/usage-doc/opencode-usage-doc.test.tsx @@ -92,6 +92,7 @@ describe("UsageDoc - OpenCode 配置教程", () => { expect(text).toContain("curl -fsSL https://opencode.ai/install | bash"); expect(text).toContain("npm install -g opencode-ai"); + expect(text).toContain("npm mirror registries"); expect(text).toContain("bun add -g opencode-ai"); expect(text).toContain("brew install anomalyco/tap/opencode"); expect(text).toContain("paru -S opencode-bin"); @@ -112,6 +113,7 @@ describe("UsageDoc - OpenCode 配置教程", () => { expect(usageMessages).toHaveProperty("opencode.installation.title"); expect(usageMessages).toHaveProperty("opencode.installation.script.title"); expect(usageMessages).toHaveProperty("opencode.installation.npm.title"); + expect(usageMessages).toHaveProperty("opencode.installation.npm.note"); expect(usageMessages).toHaveProperty("opencode.installation.bun.title"); expect(usageMessages).toHaveProperty("opencode.installation.macos.homebrew.title"); expect(usageMessages).toHaveProperty("opencode.installation.linux.homebrew.title"); diff --git a/tests/unit/usage-doc/usage-doc-page.test.tsx b/tests/unit/usage-doc/usage-doc-page.test.tsx index 1ddebb80f..055b578ed 100644 --- a/tests/unit/usage-doc/usage-doc-page.test.tsx +++ b/tests/unit/usage-doc/usage-doc-page.test.tsx @@ -62,7 +62,10 @@ describe("UsageDocPage - 目录/快速链接交互", () => { writable: true, }); - document.cookie = "auth-token=test-token"; + Object.defineProperty(document, "cookie", { + configurable: true, + get: () => "auth-token=test-token", + }); const { unmount } = await renderWithIntl("en", ); @@ -73,6 +76,8 @@ describe("UsageDocPage - 目录/快速链接交互", () => { expect(dashboardLink).not.toBeNull(); await unmount(); + + Reflect.deleteProperty(document, "cookie"); }); test("目录项点击后应触发平滑滚动", async () => { From 6aa68905a9ed1d281595118d65b5e5aca0453d49 Mon Sep 17 00:00:00 2001 From: ding113 Date: Sun, 11 Jan 2026 01:41:47 +0800 Subject: [PATCH 17/17] chore: bump version to 0.4.0 --- VERSION | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index 0bdfd66f8..1d0ba9ea1 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.42 +0.4.0 diff --git a/package.json b/package.json index 9f51e3525..13c3dfdd7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claude-code-hub", - "version": "0.3.0", + "version": "0.4.0", "private": true, "scripts": { "dev": "next dev --port 13500",